You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-06-27 05:11:11 +02: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:
@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton, { type Color } from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import {
|
||||
getContextMenuPositionFromBoundingRect,
|
||||
getContextMenuPositionFromEvent,
|
||||
type Align,
|
||||
} from '$lib/utils/context-menu';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
|
||||
export let icon: string;
|
||||
export let title: string;
|
||||
/**
|
||||
* The alignment of the context menu relative to the button.
|
||||
*/
|
||||
export let align: Align = 'top-left';
|
||||
/**
|
||||
* The direction in which the context menu should open.
|
||||
*/
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let color: Color = 'transparent';
|
||||
export let size: string | undefined = undefined;
|
||||
export let padding: string | undefined = undefined;
|
||||
/**
|
||||
* Additional classes to apply to the button.
|
||||
*/
|
||||
export let buttonClass: string | undefined = undefined;
|
||||
|
||||
let isOpen = false;
|
||||
let contextMenuPosition = { x: 0, y: 0 };
|
||||
let menuContainer: HTMLUListElement;
|
||||
let buttonContainer: HTMLDivElement;
|
||||
|
||||
const id = generateId();
|
||||
const buttonId = `context-menu-button-${id}`;
|
||||
const menuId = `context-menu-${id}`;
|
||||
|
||||
$: {
|
||||
if (isOpen) {
|
||||
$optionClickCallbackStore = handleOptionClick;
|
||||
}
|
||||
}
|
||||
|
||||
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
|
||||
contextMenuPosition = getContextMenuPositionFromEvent(event, align);
|
||||
isOpen = true;
|
||||
menuContainer?.focus();
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
openDropdown(event);
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (isOpen) {
|
||||
// if the dropdown is open, stop the event from propagating
|
||||
event.stopPropagation();
|
||||
}
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
focusButton();
|
||||
isOpen = false;
|
||||
$selectedIdStore = undefined;
|
||||
};
|
||||
|
||||
const handleOptionClick = () => {
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const focusButton = () => {
|
||||
const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
|
||||
button?.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={onResize} />
|
||||
<div
|
||||
use:contextMenuNavigation={{
|
||||
closeDropdown,
|
||||
container: menuContainer,
|
||||
isOpen,
|
||||
onEscape,
|
||||
openDropdown,
|
||||
selectedId: $selectedIdStore,
|
||||
selectionChanged: (id) => ($selectedIdStore = id),
|
||||
}}
|
||||
use:clickOutside={{ onOutclick: closeDropdown }}
|
||||
on:resize={onResize}
|
||||
>
|
||||
<div bind:this={buttonContainer}>
|
||||
<CircleIconButton
|
||||
{color}
|
||||
{icon}
|
||||
{padding}
|
||||
{size}
|
||||
{title}
|
||||
ariaControls={menuId}
|
||||
ariaExpanded={isOpen}
|
||||
ariaHasPopup={true}
|
||||
class={buttonClass}
|
||||
id={buttonId}
|
||||
on:click={handleClick}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
use:shortcuts={[
|
||||
{
|
||||
shortcut: { key: 'Tab' },
|
||||
onShortcut: closeDropdown,
|
||||
preventDefault: false,
|
||||
},
|
||||
{
|
||||
shortcut: { key: 'Tab', shift: true },
|
||||
onShortcut: closeDropdown,
|
||||
preventDefault: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContextMenu
|
||||
{...contextMenuPosition}
|
||||
{direction}
|
||||
ariaActiveDescendant={$selectedIdStore}
|
||||
ariaLabelledBy={buttonId}
|
||||
bind:menuElement={menuContainer}
|
||||
id={menuId}
|
||||
isVisible={isOpen}
|
||||
>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
@ -3,11 +3,16 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
|
||||
export let isVisible: boolean = false;
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
export let id: string | undefined = undefined;
|
||||
export let ariaLabel: string | undefined = undefined;
|
||||
export let ariaLabelledBy: string | undefined = undefined;
|
||||
export let ariaActiveDescendant: string | undefined = undefined;
|
||||
|
||||
export let menuElement: HTMLDivElement | undefined = undefined;
|
||||
export let menuElement: HTMLUListElement | undefined = undefined;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
|
||||
let left: number;
|
||||
@ -30,16 +35,25 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
bind:clientHeight={height}
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style:top="{top}px"
|
||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style:left="{left}px"
|
||||
role="menu"
|
||||
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
||||
style:top="{top}px"
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
use:clickOutside={{ onOutclick: onClose }}
|
||||
>
|
||||
<div class="flex flex-col rounded-lg">
|
||||
<ul
|
||||
{id}
|
||||
aria-activedescendant={ariaActiveDescendant ?? ''}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
bind:this={menuElement}
|
||||
class:max-h-[100vh]={isVisible}
|
||||
class:max-h-0={!isVisible}
|
||||
class="flex flex-col transition-all duration-[250ms] ease-in-out"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -1,15 +1,37 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
|
||||
export let text = '';
|
||||
export let subtitle = '';
|
||||
export let icon = '';
|
||||
|
||||
let id: string = generateId();
|
||||
|
||||
$: isActive = $selectedIdStore === id;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
click: void;
|
||||
}>();
|
||||
|
||||
const handleClick = () => {
|
||||
$optionClickCallbackStore?.();
|
||||
dispatch('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click
|
||||
class="w-full bg-slate-100 p-4 text-left text-sm font-medium text-immich-fg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
||||
<li
|
||||
{id}
|
||||
on:click={handleClick}
|
||||
on:mouseover={() => ($selectedIdStore = id)}
|
||||
on:mouseleave={() => ($selectedIdStore = undefined)}
|
||||
class="w-full p-4 text-left text-sm font-medium text-immich-fg focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg cursor-pointer border-gray-200"
|
||||
class:bg-slate-300={isActive}
|
||||
class:bg-slate-100={!isActive}
|
||||
role="menuitem"
|
||||
>
|
||||
{#if text}
|
||||
@ -30,4 +52,4 @@
|
||||
{subtitle}
|
||||
</p>
|
||||
</slot>
|
||||
</button>
|
||||
</li>
|
||||
|
@ -1,3 +0,0 @@
|
||||
const key = {};
|
||||
|
||||
export { key };
|
@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
|
||||
export let title: string;
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
@ -9,7 +14,19 @@
|
||||
export let onClose: (() => unknown) | undefined;
|
||||
|
||||
let uniqueKey = {};
|
||||
let contextMenuElement: HTMLDivElement;
|
||||
let menuContainer: HTMLUListElement;
|
||||
let triggerElement: HTMLElement | undefined = undefined;
|
||||
|
||||
const id = generateId();
|
||||
const menuId = `context-menu-${id}`;
|
||||
|
||||
$: {
|
||||
if (isOpen && menuContainer) {
|
||||
triggerElement = document.activeElement as HTMLElement;
|
||||
menuContainer.focus();
|
||||
$optionClickCallbackStore = closeContextMenu;
|
||||
}
|
||||
}
|
||||
|
||||
const reopenContextMenu = async (event: MouseEvent) => {
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
@ -22,7 +39,7 @@
|
||||
|
||||
const elements = document.elementsFromPoint(event.x, event.y);
|
||||
|
||||
if (elements.includes(contextMenuElement)) {
|
||||
if (elements.includes(menuContainer)) {
|
||||
// User right-clicked on the context menu itself, we keep the context
|
||||
// menu as is
|
||||
return;
|
||||
@ -38,20 +55,51 @@
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
triggerElement?.focus();
|
||||
onClose?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#key uniqueKey}
|
||||
{#if isOpen}
|
||||
<section
|
||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||
on:contextmenu|preventDefault={reopenContextMenu}
|
||||
role="presentation"
|
||||
<div
|
||||
use:contextMenuNavigation={{
|
||||
closeDropdown: closeContextMenu,
|
||||
container: menuContainer,
|
||||
isOpen,
|
||||
selectedId: $selectedIdStore,
|
||||
selectionChanged: (id) => ($selectedIdStore = id),
|
||||
}}
|
||||
use:shortcuts={[
|
||||
{
|
||||
shortcut: { key: 'Tab' },
|
||||
onShortcut: closeContextMenu,
|
||||
},
|
||||
{
|
||||
shortcut: { key: 'Tab', shift: true },
|
||||
onShortcut: closeContextMenu,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContextMenu {x} {y} {direction} onClose={closeContextMenu} bind:menuElement={contextMenuElement}>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</section>
|
||||
<section
|
||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||
on:contextmenu|preventDefault={reopenContextMenu}
|
||||
role="presentation"
|
||||
>
|
||||
<ContextMenu
|
||||
{direction}
|
||||
{x}
|
||||
{y}
|
||||
ariaActiveDescendant={$selectedIdStore}
|
||||
ariaLabel={title}
|
||||
bind:menuElement={menuContainer}
|
||||
id={menuId}
|
||||
isVisible
|
||||
onClose={closeContextMenu}
|
||||
>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
|
Reference in New Issue
Block a user