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:
@@ -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<HTMLDivElement | null>(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<HTMLElement>(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}
|
||||
<div
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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="px-6 pt-6 pb-3">
|
||||
|
||||
Reference in New Issue
Block a user