mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 20:25:32 -04:00
feat: improve/refactor focus handling (#17796)
* feat: improve focus * test * lint * use modulus in loop
This commit is contained in:
parent
2e8a286540
commit
4b1ced439b
@ -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();
|
||||||
|
@ -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() {
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
@ -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 = () => {
|
||||||
|
@ -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}
|
||||||
|
@ -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);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user