feat: tag/folder tree keyboard accessibility

This commit is contained in:
ben-basten
2025-12-01 23:24:08 -05:00
parent 4f93eda8d8
commit e525aa04ab
5 changed files with 108 additions and 38 deletions

View File

@@ -1,7 +1,3 @@
<script lang="ts" module>
export const headerId = 'user-page-header';
</script>
<script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
@@ -68,7 +64,7 @@
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>
<div class="font-medium outline-none pe-8" tabindex="-1">{title}</div>
{/if}
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@@ -7,15 +7,14 @@
active: string;
icons: { default: string; active: string };
getLink: (path: string) => string;
isNested?: boolean;
}
let { tree, active, icons, getLink }: Props = $props();
let { tree, active, icons, getLink, isNested = false }: Props = $props();
</script>
<ul class="list-none ms-2">
<ul role={isNested ? 'group' : 'tree'} class="list-none ms-2">
{#each tree.children as node (node.color ? node.path + node.color : node.path)}
<li>
<Tree {node} {icons} {active} {getLink} />
</li>
<Tree {node} {icons} {active} {getLink} />
{/each}
</ul>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui';
@@ -21,30 +22,108 @@
event.preventDefault();
isOpen = !isOpen;
};
const handleSelect = (event: MouseEvent | KeyboardEvent, path: string) => {
event.preventDefault();
event.stopPropagation();
navigateTo(path);
};
const handleKeydown = (event: KeyboardEvent, node: TreeNode) => {
switch (event.key) {
case 'Enter':
case ' ': {
handleSelect(event, node.path);
break;
}
case 'ArrowRight': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
const target = event.target as HTMLElement;
const child = target.querySelector<HTMLLIElement>('ul[role="group"] > li[role="treeitem"]');
child?.focus();
} else if (!isOpen && hasChildren) {
isOpen = true;
}
break;
}
case 'ArrowLeft': {
event.preventDefault();
event.stopPropagation();
const hasChildren = node.children.length > 0;
if (isOpen && hasChildren) {
isOpen = false;
} else if (node.parents.length > 0) {
const target = event.target as HTMLElement;
const parent = target.parentElement?.closest<HTMLLIElement>('li[role="treeitem"]');
parent?.focus();
}
break;
}
case 'ArrowUp': {
event.preventDefault();
event.stopPropagation();
console.log('focus previous node');
break;
}
case 'ArrowDown': {
event.preventDefault();
event.stopPropagation();
console.log('focus next node');
break;
}
}
};
const navigateTo = (path: string) => {
const link = getLink(path);
void goto(link, { keepFocus: true });
};
</script>
<a
href={getLink(node.path)}
title={node.value}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
<!-- href={getLink(node.path)} -->
<li
role="treeitem"
aria-selected={false}
tabindex="0"
class="outline-none"
onkeydown={(event) => handleKeydown(event, node)}
onclick={(event) => handleSelect(event, node.path)}
>
{#if node.size > 0}
<button type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
<div
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
>
{#if node.size > 0}
<button tabindex={-1} aria-hidden="true" type="button" {onclick}>
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
</button>
{/if}
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} />
{/if}
{#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} isNested />
{/if}
</li>
<style>
li[role='treeitem']:focus-visible > div {
outline-style: var(--tw-outline-style);
outline-width: 2px;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
@@ -20,7 +20,6 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
@@ -79,7 +78,6 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">

View File

@@ -1,13 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
import TagEditModal from '$lib/modals/TagEditModal.svelte';
@@ -84,7 +83,6 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full">