From fa734c869fd7e2a84037caf950b3773723d71c0d Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 29 Apr 2026 12:04:32 +0100 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20P5=20/=20G4=20=E2=80=94=20add=20fo?= =?UTF-8?q?cus=20trap=20to=20MorningTriageModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10a a11y audit (2026-04-29) flagged the morning triage modal as having role="dialog" and aria-modal="true" but no focus trap, so Tab leaks out to the sidebar/page beneath. Escape-to-close was already wired and is preserved. Behaviour now matches the W3C dialog pattern: - On open, capture the invoking element (document.activeElement) and move focus to the first focusable inside the dialog. - Tab cycles forward; Shift+Tab cycles backward. Wrap-around between first and last focusable. - On close, restore focus to the captured invoker. - The dialog container itself gets tabindex=-1 so it can hold focus if no children are focusable (e.g. during the loading state). The focusable selector excludes aria-hidden elements and elements with no offsetParent (display:none / visibility:hidden), so dynamic content between loading -> tasks -> action buttons stays in sync. Resolves: G4. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/components/MorningTriageModal.svelte | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) 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}