diff options
| author | Fuwn <[email protected]> | 2026-05-15 09:17:28 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-05-15 09:17:28 +0000 |
| commit | dd04b2a400958359d7a76e9c5564adbfdf75a01a (patch) | |
| tree | 52e7387520da16f6043ef2ba2d108c75aec4df45 | |
| parent | feat(a11y): respect prefers-reduced-motion (diff) | |
| download | due.moe-dd04b2a400958359d7a76e9c5564adbfdf75a01a.tar.xz due.moe-dd04b2a400958359d7a76e9c5564adbfdf75a01a.zip | |
fix(a11y): make header Dropdown keyboard-operable
Wire Enter/Space/ArrowDown/ArrowUp/Escape on the toggle and
ArrowDown/ArrowUp/Home/End/Escape on each item so the Schedule and
Profile menus are reachable without a mouse. Add aria-haspopup,
aria-expanded, aria-controls, and role=menu/menuitem; give each
instance a unique toggle/menu id so the two header dropdowns no longer
collide. Close the menu on item activation so preventDefault items
(e.g. Log Out) don't leave it hanging open. Focus moves via
`await tick()` so :focus-visible matches reliably, with a scoped
fallback outline tuned to var(--base0D) for the menu items.
| -rw-r--r-- | src/lib/Layout/Dropdown.svelte | 89 |
1 files changed, 83 insertions, 6 deletions
diff --git a/src/lib/Layout/Dropdown.svelte b/src/lib/Layout/Dropdown.svelte index d27b2590..be35acff 100644 --- a/src/lib/Layout/Dropdown.svelte +++ b/src/lib/Layout/Dropdown.svelte @@ -1,4 +1,6 @@ <script lang="ts"> +import { tick } from "svelte"; + interface Item { name: string; url: string; @@ -12,10 +14,68 @@ export let header = true; export let center = false; let open = false; +let toggleRef: HTMLSpanElement; +let itemRefs: HTMLAnchorElement[] = []; +const instanceId = `dropdown-${Math.random().toString(36).slice(2, 10)}`; +const toggleId = `${instanceId}-toggle`; +const menuId = `${instanceId}-menu`; const handleClickOutside = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".dropdown")) open = false; }; + +const focusItem = async (index: number) => { + if (!items.length) return; + + const wrapped = ((index % items.length) + items.length) % items.length; + + await tick(); + itemRefs[wrapped]?.focus(); +}; + +const handleToggleKey = async (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open = !open; + + if (open) await focusItem(0); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + + if (!open) open = true; + + await focusItem(0); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + + if (!open) open = true; + + await focusItem(-1); + } else if (e.key === "Escape" && open) { + e.preventDefault(); + open = false; + } +}; + +const handleItemKey = async (e: KeyboardEvent, index: number) => { + if (e.key === "Escape") { + e.preventDefault(); + open = false; + toggleRef?.focus(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + await focusItem(index + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + await focusItem(index - 1); + } else if (e.key === "Home") { + e.preventDefault(); + await focusItem(0); + } else if (e.key === "End") { + e.preventDefault(); + await focusItem(-1); + } +}; </script> <svelte:window onclick={handleClickOutside} /> @@ -28,18 +88,20 @@ const handleClickOutside = (event: MouseEvent) => { };`} > <span + bind:this={toggleRef} class={`${header ? 'header-item' : ''} dropdown-toggle`} - id="dropdown-toggle" + id={toggleId} onclick={(e) => { e.preventDefault(); open = !open; }} - onkeydown={(_e) => { - // if (e.key === 'Enter' || e.key === ' ') open = !open; - }} + onkeydown={handleToggleKey} role="button" tabindex="0" + aria-haspopup="menu" + aria-expanded={open} + aria-controls={menuId} > {#if title} {title} @@ -48,15 +110,24 @@ const handleClickOutside = (event: MouseEvent) => { {/if} </span> - <div class={`dropdown-content card card-small ${open ? 'dropdown-open' : ''}`}> - {#each items as item} + <div + class={`dropdown-content card card-small ${open ? 'dropdown-open' : ''}`} + id={menuId} + role="menu" + aria-labelledby={toggleId} + > + {#each items as item, i} <a + bind:this={itemRefs[i]} href={item.url} class="header-item" + role="menuitem" onclick={(e) => { if (item.preventDefault) e.preventDefault(); if (item.onClick) item.onClick(); + open = false; }} + onkeydown={(e) => handleItemKey(e, i)} > {item.name} </a> @@ -128,6 +199,12 @@ const handleClickOutside = (event: MouseEvent) => { display: block; } + .dropdown-content a:focus-visible { + outline: 2px solid var(--base0D); + outline-offset: 2px; + border-radius: 4px; + } + .dropdown-content a:hover { border-radius: 8px; backdrop-filter: blur(160px); |