fix(dashboard): F-U-016 focus trap in ConfirmModal (Tab/Shift+Tab cycle)
ConfirmModal focused the first input on mount and listened for Escape, but didn't trap Tab. Keyboard users could Tab out of the modal into the underlying page — a strong accessibility violation and a confusing keyboard-UX moment. Add a handleTrapTab keydown handler on the dialog div that: - Enumerates focusables (button:not([disabled]), input:not([disabled]), select, textarea, a[href], [tabindex]:not(-1)). - On Tab from the last focusable, wraps to the first. - On Shift+Tab from the first, wraps to the last. The dialog ref is bound via bind:this; the handler is no-op if the dialog isn't mounted yet. AUDIT.md anchor: F-U-016. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,11 +90,34 @@
|
||||
// doesn't auto-confirm a destructive action).
|
||||
let inputRef = $state<HTMLInputElement | null>(null);
|
||||
let cancelRef = $state<HTMLButtonElement | null>(null);
|
||||
let dialogRef = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (inputRef) inputRef.focus();
|
||||
else if (cancelRef) cancelRef.focus();
|
||||
});
|
||||
|
||||
// F-U-016: focus trap. The browser will otherwise Tab into the
|
||||
// underlying page; for a modal that's a strong accessibility and
|
||||
// keyboard-UX violation. Cycle focus within the dialog and reverse
|
||||
// on Shift+Tab.
|
||||
function handleTrapTab(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab' || !dialogRef) return;
|
||||
const focusables = dialogRef.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0]!;
|
||||
const last = focusables[focusables.length - 1]!;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
@@ -105,11 +128,13 @@
|
||||
onclick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
bind:this={dialogRef}
|
||||
class="dialog"
|
||||
class:danger={variant === 'danger'}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
onkeydown={handleTrapTab}
|
||||
>
|
||||
<h2 id="confirm-title">{title}</h2>
|
||||
<div class="body">
|
||||
|
||||
Reference in New Issue
Block a user