diff --git a/src/lib/components/MorningTriageModal.svelte b/src/lib/components/MorningTriageModal.svelte index e437cb8..4e62340 100644 --- a/src/lib/components/MorningTriageModal.svelte +++ b/src/lib/components/MorningTriageModal.svelte @@ -34,6 +34,11 @@ let tooManyFlash = $state(false); let applying = $state(false); let focusHandler: (() => void) | null = null; + // Phase 10a a11y G4: focus-trap state. The modal must keep keyboard + // focus inside its bounds while open and restore focus to the + // invoking element on close. + let dialogEl = $state(null); + let invokerEl: HTMLElement | null = null; function todayKey(): string { const d = new Date(); @@ -171,14 +176,73 @@ } } + // Phase 10a a11y G4: collect every focusable element inside the dialog + // for the focus trap. We re-query on every Tab so dynamic content + // (loading → tasks → buttons) stays in sync. + function focusableInDialog(): HTMLElement[] { + if (!dialogEl) return []; + const sel = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(','); + return Array.from(dialogEl.querySelectorAll(sel)).filter( + (el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null, + ); + } + function handleKeydown(e: KeyboardEvent) { if (!open) return; if (e.key === 'Escape') { e.preventDefault(); skipForToday(); + return; + } + if (e.key === 'Tab') { + const focusables = focusableInDialog(); + if (focusables.length === 0) { + // Nothing focusable inside — keep focus on the dialog container. + e.preventDefault(); + dialogEl?.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !dialogEl?.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !dialogEl?.contains(active)) { + e.preventDefault(); + first.focus(); + } + } } } + // Phase 10a a11y G4: when the dialog opens, remember the invoker so + // we can return focus on close, and move focus into the dialog. + $effect(() => { + if (open) { + invokerEl = (document.activeElement as HTMLElement) ?? null; + // Wait one tick for the dialog to mount before focusing. + queueMicrotask(() => { + const focusables = focusableInDialog(); + (focusables[0] ?? dialogEl)?.focus(); + }); + } else if (invokerEl) { + // Restore focus to the element that opened the modal. + try { invokerEl.focus(); } catch {} + invokerEl = null; + } + }); + onMount(() => { maybeShow(); focusHandler = () => maybeShow(); @@ -194,10 +258,12 @@ {#if open}