fix(a11y): P5 / G4 — add focus trap to MorningTriageModal

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:04:32 +01:00
parent d089fdb37f
commit fa734c869f

View File

@@ -34,6 +34,11 @@
let tooManyFlash = $state(false); let tooManyFlash = $state(false);
let applying = $state(false); let applying = $state(false);
let focusHandler: (() => void) | null = null; 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<HTMLDivElement | null>(null);
let invokerEl: HTMLElement | null = null;
function todayKey(): string { function todayKey(): string {
const d = new Date(); 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<HTMLElement>(sel)).filter(
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
);
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (!open) return; if (!open) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
skipForToday(); 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(() => { onMount(() => {
maybeShow(); maybeShow();
focusHandler = () => maybeShow(); focusHandler = () => maybeShow();
@@ -194,10 +258,12 @@
{#if open} {#if open}
<div <div
bind:this={dialogEl}
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="triage-title" aria-labelledby="triage-title"
tabindex="-1"
> >
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col"> <div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
<div class="px-6 pt-6 pb-3"> <div class="px-6 pt-6 pb-3">