feat: keyboard nav

This commit is contained in:
Min Idzelis 2025-04-23 02:30:38 +00:00
parent 83eced6f78
commit 12a76a750f
15 changed files with 487 additions and 91 deletions

View File

@ -70,13 +70,12 @@ export function focusTrap(container: HTMLElement, options?: Options) {
update(newOptions?: Options) {
options = newOptions;
if (withDefaults(options).active) {
setInitialFocus();
void setInitialFocus();
}
},
destroy() {
destroyShortcuts?.();
if (triggerElement instanceof HTMLElement) {
console.log('destroy triggerElement', triggerElement.textContent);
triggerElement.focus();
}
},

View File

@ -26,7 +26,7 @@ export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
const element = children.at(newIndex);
if (element instanceof HTMLElement) {
element.focus();
// element.focus();
}
};

View File

@ -46,7 +46,6 @@
onClick?: ((asset: AssetResponseDto) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
handleFocus?: (() => void) | undefined;
}
let {
@ -65,7 +64,6 @@
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
handleFocus = undefined,
imageClass = '',
brokenAssetClass = '',
dimmed = false,
@ -177,18 +175,42 @@
</script>
<div
data-asset={asset.id}
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<!-- Outline on focus -->
<div
class={['absolute z-40 size-full outline-4 -outline-offset-4 outline-immich-primary', { 'rounded-xl': selected }]}
data-outline
></div>
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover"
class="absolute object-cover z-30"
style:width="{width}px"
style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
@ -202,29 +224,9 @@
slow: ??ms
-->
<div
class={['group absolute top-[0px] bottom-[0px]', { 'curstor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
style:height="inherit"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
onfocus={handleFocus}
data-thumbnail-focus-container
tabindex={0}
role="link"
>
<!-- Select asset button -->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
@ -246,7 +248,6 @@
class={['absolute z-20 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
role="checkbox"
tabindex={-1}
onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
@ -285,13 +286,6 @@
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full z-30 bg-gray-700/40', { 'rounded-xl': selected }]}></div>
{/if}
<!-- Outline on focus -->
<div
class={[
'absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
></div>
<!-- Favorite asset star -->
{#if !isSharedLink() && asset.isFavorite}
@ -371,3 +365,9 @@
{/if}
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>

View File

@ -27,7 +27,7 @@
if (targetEl) {
const element = getTabbable(targetEl)[0];
if (element) {
element.focus();
// element.focus();
}
}
};

View File

@ -8,9 +8,11 @@
id?: string;
name?: string;
placeholder?: string;
autofocus?: boolean;
onkeydown?: (e: KeyboardEvent) => void;
}
let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
@ -30,5 +32,6 @@
if (e.key === 'Enter') {
value = updatedValue;
}
onkeydown?.(e);
}}
/>

View File

@ -0,0 +1,70 @@
import type { AssetStore } from '$lib/stores/assets-store.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { retry } from '$lib/utils/retry';
const waitForElement = retry((query: string) => document.querySelector(query) as HTMLElement, 10, 100);
const tracker = new InvocationTracker();
const getFocusedThumb = () => {
const current = document.activeElement as HTMLElement;
if (current && current.dataset.thumbnailFocusContainer !== undefined) {
return current;
}
};
export const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
export const focusPreviousAsset = () =>
focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => {
const scrolled = await scrollToAsset(asset.id);
if (scrolled) {
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export const setFocusTo = async (
scrollToAsset: (id: string) => Promise<boolean>,
store: AssetStore,
direction: 'next' | 'previous',
skip: 'day' | 'month' | 'year',
) => {
const thumb = getFocusedThumb();
if (!thumb) {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
return;
}
return direction === 'next' ? focusNextAsset() : focusPreviousAsset();
}
const invocation = tracker.startInvocation();
try {
if (thumb) {
const id = thumb?.dataset.asset;
if (id) {
const asset =
direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip);
invocation.checkStillValid();
if (asset) {
const scrolled = await scrollToAsset(asset.id);
invocation.checkStillValid();
if (scrolled) {
invocation.checkStillValid();
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
invocation.checkStillValid();
element?.focus();
}
}
}
}
invocation.endInvocation();
} catch (error: unknown) {
if (invocation.isInvalidInvocationError(error)) {
// expected
return;
}
throw error;
}
};

View File

@ -19,6 +19,7 @@
import { flip } from 'svelte/animate';
import { uploadAssetsStore } from '$lib/stores/upload';
import { onDestroy } from 'svelte';
let { isUploading } = uploadAssetsStore;

View File

@ -14,7 +14,7 @@
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte';
import { onMount, tick, type Snippet } from 'svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
@ -26,7 +26,15 @@
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import SelectDate from '$lib/components/shared-components/select-date.svelte';
import { DateTime } from 'luxon';
import {
focusNextAsset,
focusPreviousAsset,
setFocusTo as setFocusToInit,
setFocusToAsset as setFocusAssetInit,
} from '$lib/components/photos-page/actions/focus-actions';
interface Props {
isSelectionMode?: boolean;
@ -76,6 +84,7 @@
let timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false);
let showSkeleton = $state(true);
let isShowSelectDate = $state(false);
let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0);
@ -101,26 +110,37 @@
scrollTo(0);
};
const scrollToAsset = async (assetId: string) => {
try {
const bucket = await assetStore.findBucketForAsset(assetId);
if (bucket) {
const height = bucket.findAssetAbsolutePosition(assetId);
if (height) {
scrollTo(height);
assetStore.updateIntersections();
return true;
}
}
} catch {
// ignore errors - asset may not be in the store
}
return false;
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
try {
const bucket = await assetStore.findBucketForAsset(scrollTarget);
if (bucket) {
const height = bucket.findAssetAbsolutePosition(scrollTarget);
if (height) {
scrollTo(height);
assetStore.updateIntersections();
return;
}
}
} catch {
// ignore errors - asset may not be in the store
}
scrolled = await scrollToAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollToTop();
}
scrollToTop();
};
beforeNavigate(() => (assetStore.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
@ -347,12 +367,6 @@
deselectAllAssets();
};
const focusElement = () => {
if (document.activeElement === document.body) {
element?.focus();
}
};
const handleSelectAsset = (asset: AssetResponseDto) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
@ -608,9 +622,6 @@
}
};
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
@ -621,6 +632,9 @@
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
@ -632,10 +646,15 @@
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
{ shortcut: { key: 'ArrowRight' }, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: focusPreviousAsset },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('next', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('previous', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('next', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('previous', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('next', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('previous', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (assetInteraction.selectionActive) {
@ -685,6 +704,20 @@
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if isShowSelectDate}
<SelectDate
initialDate={DateTime.now()}
onConfirm={async (date: DateTime) => {
isShowSelectDate = false;
const asset = await assetStore.getClosestAssetToDate(date);
if (asset) {
await setFocusAsset(asset);
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}
{#if assetStore.buckets.length > 0}
<Scrubber
{assetStore}

View File

@ -417,10 +417,6 @@
}
};
const assetOnFocusHandler = (asset: AssetResponseDto) => {
assetInteraction.focussedAssetId = asset.id;
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
@ -490,12 +486,10 @@
}}
onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)}
{showArchiveIcon}
{asset}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset.id)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { DateTime } from 'luxon';
import ConfirmDialog from './dialog/confirm-dialog.svelte';
import DateInput from '../elements/date-input.svelte';
import { t } from 'svelte-i18n';
interface Props {
initialDate?: DateTime;
onCancel: () => void;
onConfirm: (date: DateTime) => void;
}
let { initialDate = DateTime.now(), onCancel, onConfirm }: Props = $props();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
const handleConfirm = () => {
const value = date;
if (value) {
onConfirm(value);
}
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
let date = $derived(DateTime.fromISO(selectedDate));
</script>
<ConfirmDialog confirmColor="primary" title="Go to date" disabled={!date.isValid} onConfirm={handleConfirm} {onCancel}>
{#snippet promptSnippet()}
<div class="flex flex-col text-left gap-2">
<div class="flex flex-col">
<label for="datetime">{$t('date_and_time')}</label>
<DateInput
class="immich-form-input"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
autofocus
onkeydown={(e) => {
if (e.key === 'Enter') {
handleConfirm();
}
if (e.key === 'Escape') {
onCancel();
}
}}
/>
</div>
</div>
{/snippet}
</ConfirmDialog>

View File

@ -25,6 +25,9 @@
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
{ key: ['D', 'd'], action: 'Next or Previous Day' },
{ key: ['M', 'm'], action: 'Next or Previous Month' },
{ key: ['Y', 'y'], action: 'Next or Previous Year' },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },

View File

@ -92,7 +92,7 @@ class IntersectingAsset {
});
position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state();
asset: AssetResponseDto = <AssetResponseDto>$state();
id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
@ -504,6 +504,36 @@ export class AssetBucket {
return -1;
}
*assets() {
for (const group of this.dateGroups) {
for (const asset of group.intersetingAssets) {
yield asset.asset;
}
}
}
findById(id: string) {
return this.assets().find((asset) => asset.id === id);
}
findClosest(target: DateTime) {
let closest = this.assets().next().value;
if (!closest) {
return;
}
let smallestDiff = Math.abs(target.toMillis() - DateTime.fromISO(closest.localDateTime).toUTC().toMillis());
for (const current of this.assets()) {
const diff = Math.abs(target.toMillis() - DateTime.fromISO(current.localDateTime).toUTC().toMillis());
if (diff < smallestDiff) {
smallestDiff = diff;
closest = current;
}
}
return closest;
}
cancel() {
this.loader?.cancel();
}
@ -749,7 +779,6 @@ export class AssetStore {
return batch;
}
// todo: this should probably be a method isteat
#findBucketForAsset(id: string) {
for (const bucket of this.buckets) {
if (bucket.containsAssetId(id)) {
@ -758,6 +787,15 @@ export class AssetStore {
}
}
#findBucketForDate(date: DateTime) {
for (const bucket of this.buckets) {
const bucketDate = DateTime.fromISO(bucket.bucketDate).toUTC();
if (bucketDate.hasSame(date, 'month') && bucketDate.hasSame(date, 'year')) {
return bucket;
}
}
}
updateSlidingWindow(scrollTop: number) {
this.#scrollTop = scrollTop;
this.updateIntersections();
@ -1161,15 +1199,6 @@ export class AssetStore {
return this.getBucketByDate(year, month);
}
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
const bucketInfo = this.#findBucketForAsset(asset.id);
if (bucketInfo) {
return bucketInfo;
}
await this.#loadBucketAtTime(asset.localDateTime, options);
return this.#findBucketForAsset(asset.id);
}
getBucketIndexByAssetId(assetId: string) {
return this.#findBucketForAsset(assetId);
}
@ -1265,11 +1294,74 @@ export class AssetStore {
return this.buckets[0]?.getFirstAsset();
}
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset);
async getPreviousAsset(
idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id);
if (!bucket) {
return;
}
const asset = bucket.findById(idable.id);
if (!asset) {
return;
}
switch (skipTo) {
case 'day': {
let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1;
const bIdx = this.buckets.indexOf(bucket);
let nextDaygroup;
while (nextDay <= 31) {
nextDaygroup = bucket.findDateGroupByDay(nextDay);
if (nextDaygroup) {
break;
}
nextDay++;
}
if (nextDaygroup === undefined) {
let bucketIndex = bIdx - 1;
while (bucketIndex >= 0) {
bucket = this.buckets[bucketIndex];
if (!bucket) {
return;
}
await this.loadBucket(bucket.bucketDate, { cancelable: false });
const previous = bucket.lastDateGroup?.intersetingAssets.at(0)?.asset;
if (previous) {
return previous;
}
bucketIndex--;
}
} else {
return nextDaygroup.intersetingAssets.at(0)?.asset;
}
return;
}
case 'month': {
const bIdx = this.buckets.indexOf(bucket);
const otherBucket = this.buckets[bIdx - 1];
if (otherBucket) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
}
case 'year': {
const nextYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') + 1;
const bIdx = this.buckets.indexOf(bucket);
for (let idx = bIdx; idx >= 0; idx--) {
const otherBucket = this.buckets[idx];
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear >= nextYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
}
return;
}
}
// Find which date group contains this asset
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
@ -1308,12 +1400,96 @@ export class AssetStore {
}
}
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset);
async getClosestAssetToDate(date: DateTime) {
let bucket = this.#findBucketForDate(date);
if (!bucket) {
return;
}
await this.loadBucket(bucket.bucketDate, { cancelable: false });
const asset = bucket.findClosest(date);
if (asset) {
return asset;
}
let bucketIndex = this.buckets.indexOf(bucket) + 1;
while (bucketIndex < this.buckets.length) {
bucket = this.buckets[bucketIndex];
await this.loadBucket(bucket.bucketDate);
const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset;
if (next) {
return next;
}
bucketIndex++;
}
}
async getNextAsset(
idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id);
if (!bucket) {
return;
}
const asset = bucket.findById(idable.id);
if (!asset) {
return;
}
switch (skipTo) {
case 'day': {
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1;
const bIdx = this.buckets.indexOf(bucket);
let prevDayGroup;
while (prevDay >= 0) {
prevDayGroup = bucket.findDateGroupByDay(prevDay);
if (prevDayGroup) {
break;
}
prevDay--;
}
if (prevDayGroup === undefined) {
let bucketIndex = bIdx + 1;
while (bucketIndex < this.buckets.length) {
const otherBucket = this.buckets[bucketIndex];
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
if (next) {
return next;
}
bucketIndex++;
}
} else {
return prevDayGroup.intersetingAssets.at(0)?.asset;
}
return;
}
case 'month': {
const bIdx = this.buckets.indexOf(bucket);
const otherBucket = this.buckets[bIdx + 1];
if (otherBucket) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
}
case 'year': {
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1;
const bIdx = this.buckets.indexOf(bucket);
for (let idx = bIdx; idx < this.buckets.length - 1; idx++) {
const otherBucket = this.buckets[idx];
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear <= prevYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
const a = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
return a;
}
}
return;
}
}
// Find which date group contains this asset
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
const group = bucket.dateGroups[groupIndex];

View File

@ -13,10 +13,19 @@ export const getTabbable = (container: Element, includeContainer: boolean = fals
tabbable(container, { ...defaultOpts, includeContainer });
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
const focusElements = focusable(document.body);
const focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current);
if (index === -1) {
for (const element of focusElements) {
if (selector(element)) {
element.focus();
return;
}
}
focusElements[0].focus();
return;
}
if (forwardDirection) {
let i = index + 1;
while (i !== index) {
@ -34,7 +43,7 @@ export const focusNext = (selector: (element: HTMLElement | SVGElement) => boole
}
} else {
let i = index - 1;
while (i !== index) {
while (i !== index && i >= 0) {
const next = focusElements[i];
if (!isTabbable(next) || !selector(next)) {
if (i === 0) {

View File

@ -0,0 +1,26 @@
export class InvocationTracker {
invocationsStarted = 0;
invocationsEnded = 0;
constructor() {}
startInvocation() {
this.invocationsStarted++;
const invocation = this.invocationsStarted;
return {
isInvalidInvocationError(error: unknown) {
return error instanceof Error && error.message === 'Invocation not valid';
},
checkStillValid: () => {
if (invocation !== this.invocationsStarted) {
throw new Error('Invocation not valid');
}
},
endInvocation: () => {
this.invocationsEnded = invocation;
},
};
}
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
}

View File

@ -0,0 +1,30 @@
export const retry = <R, A = unknown>(
fn: (arg: A) => R,
interval: number = 10,
timeout: number = 1000,
): ((args: A) => Promise<R | null>) => {
let timer: ReturnType<typeof setTimeout> | undefined;
return (args: A): Promise<R | null> => {
if (timer) {
clearTimeout(timer);
}
return new Promise<R | null>((resolve) => {
const start = Date.now();
const attempt = () => {
const result = fn(args);
if (result) {
resolve(result);
} else if (Date.now() - start > timeout) {
resolve(null);
} else {
timer = setTimeout(attempt, interval);
}
};
attempt();
});
};
};