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).
|
// doesn't auto-confirm a destructive action).
|
||||||
let inputRef = $state<HTMLInputElement | null>(null);
|
let inputRef = $state<HTMLInputElement | null>(null);
|
||||||
let cancelRef = $state<HTMLButtonElement | null>(null);
|
let cancelRef = $state<HTMLButtonElement | null>(null);
|
||||||
|
let dialogRef = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (inputRef) inputRef.focus();
|
if (inputRef) inputRef.focus();
|
||||||
else if (cancelRef) cancelRef.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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
@@ -105,11 +128,13 @@
|
|||||||
onclick={handleBackdrop}
|
onclick={handleBackdrop}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
bind:this={dialogRef}
|
||||||
class="dialog"
|
class="dialog"
|
||||||
class:danger={variant === 'danger'}
|
class:danger={variant === 'danger'}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="confirm-title"
|
aria-labelledby="confirm-title"
|
||||||
|
onkeydown={handleTrapTab}
|
||||||
>
|
>
|
||||||
<h2 id="confirm-title">{title}</h2>
|
<h2 id="confirm-title">{title}</h2>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
|||||||
Reference in New Issue
Block a user