mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): keyboard accessible context menus (#10017)
* feat(web,a11y): context menu keyboard navigation * wip: all context menus visible * wip: more migrations to the ButtonContextMenu, usability improvements * wip: migrate Administration, PeopleCard * wip: refocus the button on click, docs * fix: more intuitive RightClickContextMenu - configurable title - focus management: tab keys, clicks, closing the menu - automatically closing when an option is selected * fix: refining the little details - adjust the aria attributes - intuitive escape key propagation - extract context into its own file * fix: dropdown options not clickable in a <Portal> * wip: small fixes - export selectedColor to prevent unexpected styling - better context function naming * chore: revert changes to list navigation, to reduce scope of the PR * fix: remove topBorder prop * feat: automatically select the first option on enter or space keypress * fix: use Svelte store instead to handle selecting menu options - better prop naming for ButtonContextMenu * feat: hovering the mouse can change the active element * fix: remove Portal, more predictable open/close behavior * feat: make selected item visible using a scroll - also: minor cleanup of the context-menu-navigation Svelte action * feat: maintain context menu position on resize * fix: use the whole padding class as better tailwind convention * fix: options not announcing with screen reader for ButtonContextMenu * fix: screen reader announcing right click context menu options * fix: handle focus out scenario --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
99c6fdbc1c
commit
b71aa4473b
26 changed files with 639 additions and 441 deletions
108
web/src/lib/actions/context-menu-navigation.ts
Normal file
108
web/src/lib/actions/context-menu-navigation.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { tick } from 'svelte';
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* A function that is called when the dropdown should be closed.
|
||||
*/
|
||||
closeDropdown: () => void;
|
||||
/**
|
||||
* The container element that with direct children that should be navigated.
|
||||
*/
|
||||
container: HTMLElement;
|
||||
/**
|
||||
* Indicates if the dropdown is open.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
/**
|
||||
* Override the default behavior for the escape key.
|
||||
*/
|
||||
onEscape?: (event: KeyboardEvent) => void;
|
||||
/**
|
||||
* A function that is called when the dropdown should be opened.
|
||||
*/
|
||||
openDropdown?: (event: KeyboardEvent) => void;
|
||||
/**
|
||||
* The id of the currently selected element.
|
||||
*/
|
||||
selectedId: string | undefined;
|
||||
/**
|
||||
* A function that is called when the selection changes, to notify consumers of the new selected id.
|
||||
*/
|
||||
selectionChanged: (id: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const contextMenuNavigation: Action<HTMLElement, Options> = (node, options: Options) => {
|
||||
const getCurrentElement = () => {
|
||||
const { container, selectedId: activeId } = options;
|
||||
return container?.querySelector(`#${activeId}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
const { closeDropdown, selectionChanged } = options;
|
||||
selectionChanged(undefined);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
|
||||
const { selectionChanged, container, openDropdown } = options;
|
||||
if (openDropdown) {
|
||||
openDropdown(event);
|
||||
await tick();
|
||||
}
|
||||
|
||||
const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
|
||||
if (children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEl = getCurrentElement();
|
||||
const currentIndex = currentEl ? children.indexOf(currentEl) : -1;
|
||||
const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
|
||||
const newIndex = (currentIndex + directionFactor + children.length) % children.length;
|
||||
const selectedNode = children[newIndex];
|
||||
selectedNode?.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
selectionChanged(selectedNode?.id);
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
const { onEscape } = options;
|
||||
if (onEscape) {
|
||||
onEscape(event);
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleClick = (event: KeyboardEvent) => {
|
||||
const { selectedId, isOpen, closeDropdown } = options;
|
||||
if (isOpen && !selectedId) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (!selectedId) {
|
||||
void moveSelection('down', event);
|
||||
return;
|
||||
}
|
||||
const currentEl = getCurrentElement();
|
||||
currentEl?.click();
|
||||
};
|
||||
|
||||
const { destroy } = shortcuts(node, [
|
||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
|
||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
|
||||
{ shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
|
||||
{ shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
|
||||
]);
|
||||
|
||||
return {
|
||||
update(newOptions) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue