From dd04b2a400958359d7a76e9c5564adbfdf75a01a Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Fri, 15 May 2026 09:17:28 +0000
Subject: 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.
---
 src/lib/Layout/Dropdown.svelte | 89 +++++++++++++++++++++++++++++++++++++++---
 1 file changed, 83 insertions(+), 6 deletions(-)

(limited to 'src/lib/Layout/Dropdown.svelte')

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);
-- 
cgit v1.2.3

