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:
MechaCat02
2026-06-07 21:14:37 +02:00
parent f2b16da2b5
commit a8094067eb

View File

@@ -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">