Compare commits

...

1 Commits

Author SHA1 Message Date
ben-basten
e525aa04ab feat: tag/folder tree keyboard accessibility 2025-12-02 21:08:32 -05:00
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"> <script lang="ts">
import { useActions, type ActionArray } from '$lib/actions/use-actions'; import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; 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="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{#if title} {#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}
{#if description} {#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p> <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@@ -7,15 +7,14 @@
active: string; active: string;
icons: { default: string; active: string }; icons: { default: string; active: string };
getLink: (path: string) => string; getLink: (path: string) => string;
isNested?: boolean;
} }
let { tree, active, icons, getLink }: Props = $props(); let { tree, active, icons, getLink, isNested = false }: Props = $props();
</script> </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)} {#each tree.children as node (node.color ? node.path + node.color : node.path)}
<li> <Tree {node} {icons} {active} {getLink} />
<Tree {node} {icons} {active} {getLink} />
</li>
{/each} {/each}
</ul> </ul>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { TreeNode } from '$lib/utils/tree-utils'; import { TreeNode } from '$lib/utils/tree-utils';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
@@ -21,30 +22,108 @@
event.preventDefault(); event.preventDefault();
isOpen = !isOpen; 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> </script>
<a <!-- href={getLink(node.path)} -->
href={getLink(node.path)} <li
title={node.value} role="treeitem"
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'}`} aria-selected={false}
data-sveltekit-keepfocus tabindex="0"
class="outline-none"
onkeydown={(event) => handleKeydown(event, node)}
onclick={(event) => handleSelect(event, node.path)}
> >
{#if node.size > 0} <div
<button type="button" {onclick}> 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'}`}
<Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" /> >
</button> {#if node.size > 0}
{/if} <button tabindex={-1} aria-hidden="true" type="button" {onclick}>
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}> <Icon icon={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size="20" />
<Icon </button>
icon={isActive ? icons.active : icons.default} {/if}
class={isActive ? 'text-primary' : 'text-gray-400'} <div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
color={node.color} <Icon
size="20" 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> </div>
<span class="text-nowrap overflow-hidden text-ellipsis font-mono ps-1 pt-1 whitespace-pre-wrap">{node.value}</span>
</a>
{#if isOpen} {#if isOpen}
<TreeItems tree={node} {icons} {active} {getLink} /> <TreeItems tree={node} {icons} {active} {getLink} isNested />
{/if} {/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"> <script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; 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 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 GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.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 TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types'; import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte'; import { foldersStore } from '$lib/stores/folders.svelte';
@@ -79,7 +78,6 @@
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
{#snippet sidebar()} {#snippet sidebar()}
<Sidebar> <Sidebar>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section> <section>
<div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div> <div class="uppercase text-xs ps-4 mb-2 dark:text-white">{$t('explorer')}</div>
<div class="h-full"> <div class="h-full">

View File

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