feat: improve/refactor focus handling (#17796)

* feat: improve focus

* test

* lint

* use modulus in loop
This commit is contained in:
Min Idzelis 2025-04-30 00:19:38 -04:00 committed by GitHub
parent 2e8a286540
commit 4b1ced439b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 92 additions and 129 deletions

View File

@ -1,8 +1,11 @@
import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte'; import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte';
import { setDefaultTabbleOptions } from '$lib/utils/focus-util';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { tick } from 'svelte'; import { tick } from 'svelte';
setDefaultTabbleOptions({ displayCheck: 'none' });
describe('focusTrap action', () => { describe('focusTrap action', () => {
const user = userEvent.setup(); const user = userEvent.setup();
@ -38,6 +41,7 @@ describe('focusTrap action', () => {
const openButton = screen.getByText('Open'); const openButton = screen.getByText('Open');
await user.click(openButton); await user.click(openButton);
await tick();
expect(document.activeElement).toEqual(screen.getByTestId('one')); expect(document.activeElement).toEqual(screen.getByTestId('one'));
screen.getByText('Close').click(); screen.getByText('Close').click();

View File

@ -1,5 +1,5 @@
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { getFocusable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { tick } from 'svelte'; import { tick } from 'svelte';
interface Options { interface Options {
@ -18,18 +18,21 @@ export function focusTrap(container: HTMLElement, options?: Options) {
}; };
}; };
const setInitialFocus = () => { const setInitialFocus = async () => {
const focusableElement = getFocusable(container)[0]; const focusableElement = getTabbable(container, false)[0];
// Use tick() to ensure focus trap works correctly inside <Portal /> if (focusableElement) {
void tick().then(() => focusableElement?.focus()); // Use tick() to ensure focus trap works correctly inside <Portal />
await tick();
focusableElement?.focus();
}
}; };
if (withDefaults(options).active) { if (withDefaults(options).active) {
setInitialFocus(); void setInitialFocus();
} }
const getFocusableElements = () => { const getFocusableElements = () => {
const focusableElements = getFocusable(container); const focusableElements = getTabbable(container);
return [ return [
focusableElements.at(0), // focusableElements.at(0), //
focusableElements.at(-1), focusableElements.at(-1),
@ -67,7 +70,7 @@ export function focusTrap(container: HTMLElement, options?: Options) {
update(newOptions?: Options) { update(newOptions?: Options) {
options = newOptions; options = newOptions;
if (withDefaults(options).active) { if (withDefaults(options).active) {
setInitialFocus(); void setInitialFocus();
} }
}, },
destroy() { destroy() {

View File

@ -1,7 +1,8 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock'; import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory } from '@test-data/factories/asset-factory';
import { fireEvent, render, screen } from '@testing-library/svelte'; import { fireEvent, render } from '@testing-library/svelte';
vi.hoisted(() => { vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', { Object.defineProperty(globalThis, 'matchMedia', {
@ -31,51 +32,47 @@ describe('Thumbnail component', () => {
it('should only contain a single tabbable element (the container)', () => { it('should only contain a single tabbable element (the container)', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
render(Thumbnail, { const { baseElement } = render(Thumbnail, {
asset, asset,
focussed: false,
selected: true, selected: true,
}); });
const container = screen.getByTestId('container-with-tabindex'); const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container.getAttribute('tabindex')).toBe('0'); expect(container).not.toBeNull();
expect(container!.getAttribute('tabindex')).toBe('0');
// This isn't capturing all tabbable elements, but should be the most likely ones. Mainly guarding against // Guarding against inserting extra tabbable elments in future in <Thumbnail/>
// inserting extra tabbable elments in future in <Thumbnail/> const tabbables = getTabbable(container!);
let allTabbableElements = screen.queryAllByRole('link'); expect(tabbables.length).toBe(0);
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 () => { it('handleFocus should be called on focus of container', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn(); const handleFocusSpy = vi.fn();
render(Thumbnail, { const { baseElement } = render(Thumbnail, {
asset, asset,
handleFocus: handleFocusSpy, handleFocus: handleFocusSpy,
}); });
const container = screen.getByTestId('container-with-tabindex'); const container = baseElement.querySelector('[data-thumbnail-focus-container]');
await fireEvent(container, new FocusEvent('focus')); expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled(); expect(handleFocusSpy).toBeCalled();
}); });
it('element will be focussed if not already', () => { it('element will be focussed if not already', async () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const handleFocusSpy = vi.fn(); const handleFocusSpy = vi.fn();
render(Thumbnail, { const { baseElement } = render(Thumbnail, {
asset, asset,
focussed: true,
handleFocus: handleFocusSpy, handleFocus: handleFocusSpy,
}); });
const container = baseElement.querySelector('[data-thumbnail-focus-container]');
expect(container).not.toBeNull();
await fireEvent(container as HTMLElement, new FocusEvent('focus'));
expect(handleFocusSpy).toBeCalled(); expect(handleFocusSpy).toBeCalled();
}); });
}); });

View File

@ -19,7 +19,7 @@
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util'; import { focusNext } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -35,7 +35,6 @@
thumbnailWidth?: number | undefined; thumbnailWidth?: number | undefined;
thumbnailHeight?: number | undefined; thumbnailHeight?: number | undefined;
selected?: boolean; selected?: boolean;
focussed?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
disabled?: boolean; disabled?: boolean;
disableLinkMouseOver?: boolean; disableLinkMouseOver?: boolean;
@ -58,7 +57,6 @@
thumbnailWidth = undefined, thumbnailWidth = undefined,
thumbnailHeight = undefined, thumbnailHeight = undefined,
selected = false, selected = false,
focussed = false,
selectionCandidate = false, selectionCandidate = false,
disabled = false, disabled = false,
disableLinkMouseOver = false, disableLinkMouseOver = false,
@ -79,17 +77,11 @@
} = TUNABLES; } = TUNABLES;
let usingMobileDevice = $derived(mobileDevice.pointerCoarse); let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let focussableElement: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let mouseOver = $state(false); let mouseOver = $state(false);
let loaded = $state(false); let loaded = $state(false);
let thumbError = $state(false); let thumbError = $state(false);
$effect(() => {
if (focussed && document.activeElement !== focussableElement) {
focussableElement?.focus();
}
});
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
@ -236,31 +228,14 @@
if (evt.key === 'x') { if (evt.key === 'x') {
onSelect?.(asset); onSelect?.(asset);
} }
if (document.activeElement === focussableElement && evt.key === 'Escape') { if (document.activeElement === element && evt.key === 'Escape') {
const focusable = getFocusable(document); focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
const index = focusable.indexOf(focussableElement);
let i = index + 1;
while (i !== index) {
const next = focusable[i];
if (next.dataset.thumbnailFocusContainer !== undefined) {
if (i === focusable.length - 1) {
i = 0;
} else {
i++;
}
continue;
}
next.focus();
break;
}
} }
}} }}
onclick={handleClick} onclick={handleClick}
bind:this={focussableElement} bind:this={element}
onfocus={handleFocus} onfocus={handleFocus}
data-thumbnail-focus-container data-thumbnail-focus-container
data-testid="container-with-tabindex"
tabindex={0} tabindex={0}
role="link" role="link"
> >

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Button from './button.svelte'; import Button from './button.svelte';
import { getTabbable } from '$lib/utils/focus-util';
interface Props { interface Props {
/** /**
@ -23,7 +24,12 @@
const moveFocus = () => { const moveFocus = () => {
const targetEl = document.querySelector<HTMLElement>(target); const targetEl = document.querySelector<HTMLElement>(target);
targetEl?.focus(); if (targetEl) {
const element = getTabbable(targetEl)[0];
if (element) {
element.focus();
}
}
}; };
const getBreakpoint = () => { const getBreakpoint = () => {

View File

@ -100,9 +100,6 @@
} }
}; };
const assetOnFocusHandler = (asset: AssetResponseDto) => {
assetInteraction.focussedAssetId = asset.id;
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) { function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting); return intersectable.filter((int) => int.intersecting);
} }
@ -182,13 +179,11 @@
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
{groupIndex} {groupIndex}
focussed={assetInteraction.isFocussedAsset(asset.id)}
onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)} onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
handleFocus={() => assetOnFocusHandler(asset)}
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)} disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
thumbnailWidth={position.width} thumbnailWidth={position.width}
thumbnailHeight={position.height} thumbnailHeight={position.height}

View File

@ -26,6 +26,7 @@
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
@ -616,34 +617,8 @@
} }
}; };
const focusNextAsset = async () => { const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
if (assetInteraction.focussedAssetId === null) { const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
const firstAsset = assetStore.getFirstAsset();
if (firstAsset) {
assetInteraction.focussedAssetId = firstAsset.id;
}
} else {
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const nextAsset = await assetStore.getNextAsset(focussedAsset);
if (nextAsset) {
assetInteraction.focussedAssetId = nextAsset.id;
}
}
}
};
const focusPreviousAsset = async () => {
if (assetInteraction.focussedAssetId !== null) {
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
if (previousAsset) {
assetInteraction.focussedAssetId = previousAsset.id;
}
}
}
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);

View File

@ -22,6 +22,7 @@
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
@ -259,25 +260,8 @@
} }
}; };
const focusNextAsset = () => { const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
if (assetInteraction.focussedAssetId === null && assets.length > 0) { const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
assetInteraction.focussedAssetId = assets[0].id;
} else if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
if (currentIndex !== -1 && currentIndex + 1 < assets.length) {
assetInteraction.focussedAssetId = assets[currentIndex + 1].id;
}
}
};
const focusPreviousAsset = () => {
if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
if (currentIndex >= 1) {
assetInteraction.focussedAssetId = assets[currentIndex - 1].id;
}
}
};
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {
@ -417,10 +401,6 @@
} }
}; };
const assetOnFocusHandler = (asset: AssetResponseDto) => {
assetInteraction.focussedAssetId = asset.id;
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
@ -490,12 +470,10 @@
}} }}
onSelect={(asset) => handleSelectAssets(asset)} onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)} onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)}
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
selected={assetInteraction.hasSelectedAsset(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset.id)}
thumbnailWidth={layout.width} thumbnailWidth={layout.width}
thumbnailHeight={layout.height} thumbnailHeight={layout.height}
/> />

View File

@ -2,7 +2,7 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte'; import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js'; import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@ -376,7 +376,7 @@
if (forward || backward) { if (forward || backward) {
event.preventDefault(); event.preventDefault();
const focusable = getFocusable(document); const focusable = getTabbable(document.body);
if (scrollBar) { if (scrollBar) {
const index = focusable.indexOf(scrollBar); const index = focusable.indexOf(scrollBar);
if (index !== -1) { if (index !== -1) {

View File

@ -14,7 +14,6 @@ export class AssetInteraction {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId); return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
} }
assetSelectionStart = $state<AssetResponseDto | null>(null); assetSelectionStart = $state<AssetResponseDto | null>(null);
focussedAssetId = $state<string | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0); selectionActive = $derived(this.selectedAssets.length > 0);
private user = fromStore<UserAdminResponseDto | undefined>(user); private user = fromStore<UserAdminResponseDto | undefined>(user);
@ -73,8 +72,4 @@ export class AssetInteraction {
this.assetSelectionCandidates = []; this.assetSelectionCandidates = [];
this.assetSelectionStart = null; this.assetSelectionStart = null;
} }
isFocussedAsset(assetId: string) {
return this.focussedAssetId === assetId;
}
} }

View File

@ -1,4 +1,39 @@
const selectors = import { focusable, isTabbable, tabbable, type CheckOptions, type TabbableOptions } from 'tabbable';
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
export const getFocusable = (container: ParentNode) => [...container.querySelectorAll<HTMLElement>(selectors)]; type TabbableOpts = TabbableOptions & CheckOptions;
let defaultOpts: TabbableOpts = {
includeContainer: false,
};
export const setDefaultTabbleOptions = (options: TabbableOpts) => {
defaultOpts = options;
};
export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer });
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
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;
}
const totalElements = focusElements.length;
let i = index;
do {
i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
const next = focusElements[i];
if (isTabbable(next) && selector(next)) {
next.focus();
break;
}
} while (i !== index);
};