diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index 2f3868e97b..2b03282c2d 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -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(); } }, diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts index cd4214f700..e7f140c18d 100644 --- a/web/src/lib/actions/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -26,7 +26,7 @@ export const listNavigation: Action = ( const element = children.at(newIndex); if (element instanceof HTMLElement) { - element.focus(); + // element.focus(); } }; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index c01c1c9bea..cf6b9ccd9f 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -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 @@
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" > + +
{#if (!loaded || thumbError) && asset.thumbhash}
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" > {#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}
{/if} - -
{#if !isSharedLink() && asset.isFavorite} @@ -371,3 +365,9 @@ {/if}
+ + diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte index 331814813c..5be1a954f0 100644 --- a/web/src/lib/components/elements/buttons/skip-link.svelte +++ b/web/src/lib/components/elements/buttons/skip-link.svelte @@ -27,7 +27,7 @@ if (targetEl) { const element = getTabbable(targetEl)[0]; if (element) { - element.focus(); + // element.focus(); } } }; diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte index d5fb77a24f..a93d2e7cb8 100644 --- a/web/src/lib/components/elements/date-input.svelte +++ b/web/src/lib/components/elements/date-input.svelte @@ -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); }} /> diff --git a/web/src/lib/components/photos-page/actions/focus-actions.ts b/web/src/lib/components/photos-page/actions/focus-actions.ts new file mode 100644 index 0000000000..f06fc2e944 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/focus-actions.ts @@ -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, 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, + 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; + } +}; diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index fe2f779324..593840abfe 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -19,6 +19,7 @@ import { flip } from 'svelte/animate'; import { uploadAssetsStore } from '$lib/stores/upload'; + import { onDestroy } from 'svelte'; let { isUploading } = uploadAssetsStore; diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index d15fb62283..83c6ec19e1 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -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 = !showShortcuts)} /> {/if} +{#if isShowSelectDate} + { + isShowSelectDate = false; + const asset = await assetStore.getClosestAssetToDate(date); + if (asset) { + await setFocusAsset(asset); + } + }} + onCancel={() => (isShowSelectDate = false)} + /> +{/if} + {#if assetStore.buckets.length > 0} { - 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} /> diff --git a/web/src/lib/components/shared-components/select-date.svelte b/web/src/lib/components/shared-components/select-date.svelte new file mode 100644 index 0000000000..b22a06c881 --- /dev/null +++ b/web/src/lib/components/shared-components/select-date.svelte @@ -0,0 +1,52 @@ + + + + {#snippet promptSnippet()} +
+
+ + { + if (e.key === 'Enter') { + handleConfirm(); + } + if (e.key === 'Escape') { + onCancel(); + } + }} + /> +
+
+ {/snippet} +
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index cee7545f48..1204853fdc 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -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') }, diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index f523406a31..25dc5d4b86 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -92,7 +92,7 @@ class IntersectingAsset { }); position: CommonPosition | undefined = $state(); - asset: AssetResponseDto | undefined = $state(); + asset: 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 { - let bucket = await this.#getBucketInfoForAsset(asset); + async getPreviousAsset( + idable: { id: string }, + skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + 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 { - 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 { + 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]; diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts index 5ee654e263..5bce9954e7 100644 --- a/web/src/lib/utils/focus-util.ts +++ b/web/src/lib/utils/focus-util.ts @@ -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) { diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts new file mode 100644 index 0000000000..2dc289b06f --- /dev/null +++ b/web/src/lib/utils/invocationTracker.ts @@ -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; + } +} diff --git a/web/src/lib/utils/retry.ts b/web/src/lib/utils/retry.ts new file mode 100644 index 0000000000..3f57c3c239 --- /dev/null +++ b/web/src/lib/utils/retry.ts @@ -0,0 +1,30 @@ +export const retry = ( + fn: (arg: A) => R, + interval: number = 10, + timeout: number = 1000, +): ((args: A) => Promise) => { + let timer: ReturnType | undefined; + + return (args: A): Promise => { + if (timer) { + clearTimeout(timer); + } + + return new Promise((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(); + }); + }; +};