aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-05-15 09:17:28 +0000
committerFuwn <[email protected]>2026-05-15 09:17:28 +0000
commitdd04b2a400958359d7a76e9c5564adbfdf75a01a (patch)
tree52e7387520da16f6043ef2ba2d108c75aec4df45 /src/lib
parentfeat(a11y): respect prefers-reduced-motion (diff)
downloaddue.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.
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/Layout/Dropdown.svelte89
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);