diff --git a/i18n/en.json b/i18n/en.json index ad9f282359..e8726b3d20 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1150,6 +1150,7 @@ "second": "Second", "see_all_people": "See all people", "select_album_cover": "Select album cover", + "select": "Select", "select_all": "Select all", "select_all_duplicates": "Select all duplicates", "select_avatar_color": "Select avatar color", diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts new file mode 100644 index 0000000000..62f0802a22 --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -0,0 +1,63 @@ +import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; +import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { fireEvent, render, screen } from '@testing-library/svelte'; + +describe('Thumbnail component', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); + }); + + it('should only contain a single tabbable element (the container)', () => { + const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + render(Thumbnail, { + asset, + focussed: false, + overrideDisplayForTest: true, + selected: true, + }); + + const container = screen.getByTestId('container-with-tabindex'); + expect(container.getAttribute('tabindex')).toBe('0'); + + // This isn't capturing all tabbable elements, but should be the most likely ones. Mainly guarding against + // inserting extra tabbable elments in future in + let allTabbableElements = screen.queryAllByRole('link'); + allTabbableElements = allTabbableElements.concat(screen.queryAllByRole('checkbox')); + expect(allTabbableElements.length).toBeGreaterThan(0); + for (const tabbableElement of allTabbableElements) { + const testIdValue = tabbableElement.dataset.testid; + if (testIdValue === null || testIdValue !== 'container-with-tabindex') { + expect(tabbableElement.getAttribute('tabindex')).toBe('-1'); + } + } + }); + + it('handleFocus should be called on focus of container', async () => { + const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + const handleFocusSpy = vi.fn(); + render(Thumbnail, { + asset, + overrideDisplayForTest: true, + handleFocus: handleFocusSpy, + }); + + const container = screen.getByTestId('container-with-tabindex'); + await fireEvent(container, new FocusEvent('focus')); + + expect(handleFocusSpy).toBeCalled(); + }); + + it('element will be focussed if not already', () => { + const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + const handleFocusSpy = vi.fn(); + render(Thumbnail, { + asset, + overrideDisplayForTest: true, + focussed: true, + handleFocus: handleFocusSpy, + }); + + expect(handleFocusSpy).toBeCalled(); + }); +}); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 49d6e3dbf4..3f867cce01 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -40,6 +40,7 @@ thumbnailWidth?: number | undefined; thumbnailHeight?: number | undefined; selected?: boolean; + focussed?: boolean; selectionCandidate?: boolean; disabled?: boolean; readonly?: boolean; @@ -60,7 +61,9 @@ onRetrieveElement?: ((elment: HTMLElement) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined; onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; + handleFocus?: (() => void) | undefined; class?: string; + overrideDisplayForTest?: boolean; } let { @@ -72,6 +75,7 @@ thumbnailWidth = undefined, thumbnailHeight = undefined, selected = false, + focussed = false, selectionCandidate = false, disabled = false, readonly = false, @@ -85,7 +89,9 @@ onRetrieveElement = undefined, onSelect = undefined, onMouseEvent = undefined, + handleFocus = undefined, class: className = '', + overrideDisplayForTest = false, }: Props = $props(); let { @@ -94,6 +100,7 @@ const componentId = generateId(); let element: HTMLElement | undefined = $state(); + let focussableElement: HTMLElement | undefined = $state(); let mouseOver = $state(false); let intersecting = $state(false); let lastRetrievedElement: HTMLElement | undefined = $state(); @@ -111,6 +118,12 @@ } }); + $effect(() => { + if (focussed && document.activeElement !== focussableElement) { + focussableElement?.focus(); + } + }); + let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); let display = $derived(intersecting); @@ -217,7 +230,7 @@ > {/if} - {#if display} + {#if display || overrideDisplayForTest}
{ + onkeydown={(evt) => { if (evt.key === 'Enter') { callClickHandlers(); } + if (evt.key === 'x') { + onSelect?.(asset); + } }} tabindex={0} onclick={handleClick} role="link" + bind:this={focussableElement} + onfocus={handleFocus} + data-testid="container-with-tabindex" > {#if mouseOver && !disableMouseOver} @@ -244,7 +263,7 @@ style:height="{height}px" href={currentUrlReplaceAssetId(asset.id)} onclick={(evt) => evt.preventDefault()} - tabindex={0} + tabindex={-1} aria-label="Thumbnail URL" > @@ -258,6 +277,8 @@ class="absolute p-2 focus:outline-none" class:cursor-not-allowed={disabled} role="checkbox" + tabindex={-1} + onfocus={handleFocus} aria-checked={selected} {disabled} > 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 c175247615..4cc43ef199 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -93,6 +93,10 @@ } }; + const assetOnFocusHandler = (asset: AssetResponseDto) => { + assetInteraction.focussedAssetId = asset.id; + }; + onDestroy(() => { assetStore.taskManager.removeAllTasksForComponent(componentId); }); @@ -223,6 +227,8 @@ onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)} + handleFocus={() => assetOnFocusHandler(asset)} + focussed={assetInteraction.isFocussedAsset(asset)} selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} disabled={assetStore.albumAssets.has(asset.id)} thumbnailWidth={width} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index ea862cdd1a..1f4f9aca85 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -42,8 +42,8 @@ isSelectionMode?: boolean; singleSelect?: boolean; /** `true` if this asset grid is responds to navigation events; if `true`, then look at the - `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and - additionally, update the page location/url with the asset as the asset-grid is scrolled */ + `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and + additionally, update the page location/url with the asset as the asset-grid is scrolled */ enableRouting: boolean; assetStore: AssetStore; assetInteraction: AssetInteraction; @@ -706,6 +706,36 @@ e.preventDefault(); } }; + + const focusNextAsset = async () => { + if (assetInteraction.focussedAssetId === null) { + const firstAsset = assetStore.getFirstAsset(); + if (firstAsset !== null) { + assetInteraction.focussedAssetId = firstAsset.id; + } + } else { + const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + if (focussedAsset) { + const nextAsset = await assetStore.getNextAsset(focussedAsset); + if (nextAsset !== null) { + assetInteraction.focussedAssetId = nextAsset.id; + } + } + } + }; + + const focusPreviousAsset = async () => { + if (assetInteraction.focussedAssetId !== null) { + const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + if (focussedAsset) { + const previousAsset = await assetStore.getPreviousAsset(focussedAsset); + if (previousAsset) { + assetInteraction.focussedAssetId = previousAsset.id; + } + } + } + }; + onDestroy(() => { assetStore.taskManager.removeAllTasksForComponent(componentId); }); @@ -749,6 +779,8 @@ { 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 }, ]; if (assetInteraction.selectionActive) { diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 0e1e611486..e7f6bfc5f1 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -1,5 +1,5 @@