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>
354 lines
7.4 KiB
Svelte
354 lines
7.4 KiB
Svelte
<!--
|
|
Confirmation modal — replaces window.confirm/prompt for destructive
|
|
actions so the dashboard can render the full context (counts, lists,
|
|
warnings) in its own style.
|
|
|
|
Usage:
|
|
{#if showing}
|
|
<ConfirmModal
|
|
title="Delete app"
|
|
variant="danger"
|
|
confirmLabel="Delete app"
|
|
confirmPhrase={app.slug}
|
|
onConfirm={() => doDelete()}
|
|
onCancel={() => (showing = false)}
|
|
>
|
|
<p>Body content — counts, lists, warnings.</p>
|
|
</ConfirmModal>
|
|
{/if}
|
|
|
|
When `confirmPhrase` is set the confirm button stays disabled until
|
|
the user types the phrase exactly — same pattern GitHub uses for
|
|
irreversible repo deletes. Omit it for low-stakes confirmations.
|
|
-->
|
|
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
|
|
type Variant = 'danger' | 'neutral';
|
|
|
|
interface Props {
|
|
title: string;
|
|
children: Snippet;
|
|
onConfirm: () => void | Promise<void>;
|
|
onCancel: () => void;
|
|
variant?: Variant;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
/** When set, the confirm button is disabled until the user types
|
|
* this string exactly (case-sensitive). */
|
|
confirmPhrase?: string;
|
|
/** Shown above the confirm input. Defaults to a sensible message
|
|
* that mentions the phrase. */
|
|
confirmPhrasePrompt?: string;
|
|
/** While true the buttons are disabled and the confirm label is
|
|
* replaced with a "busy" form (e.g. "Deleting…"). */
|
|
busy?: boolean;
|
|
busyLabel?: string;
|
|
}
|
|
|
|
let {
|
|
title,
|
|
children,
|
|
onConfirm,
|
|
onCancel,
|
|
variant = 'neutral',
|
|
confirmLabel = 'Confirm',
|
|
cancelLabel = 'Cancel',
|
|
confirmPhrase,
|
|
confirmPhrasePrompt,
|
|
busy = false,
|
|
busyLabel
|
|
}: Props = $props();
|
|
|
|
let typed = $state('');
|
|
let phraseMatches = $derived(
|
|
confirmPhrase === undefined || typed === confirmPhrase
|
|
);
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && !busy) {
|
|
event.preventDefault();
|
|
onCancel();
|
|
}
|
|
}
|
|
|
|
function handleBackdrop(event: MouseEvent) {
|
|
// Only cancel when clicking the backdrop itself, not bubbled
|
|
// clicks from the dialog content.
|
|
if (event.target === event.currentTarget && !busy) {
|
|
onCancel();
|
|
}
|
|
}
|
|
|
|
async function handleConfirm() {
|
|
if (!phraseMatches || busy) return;
|
|
await onConfirm();
|
|
}
|
|
|
|
// Focus management: when the modal mounts, focus the slug-retype
|
|
// input (if present) or the cancel button (so an accidental Enter
|
|
// 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} />
|
|
|
|
<div
|
|
class="backdrop"
|
|
role="presentation"
|
|
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">
|
|
{@render children()}
|
|
</div>
|
|
|
|
{#if confirmPhrase}
|
|
<label class="phrase">
|
|
<span>
|
|
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
|
|
<code>{confirmPhrase}</code>
|
|
</span>
|
|
<input
|
|
bind:this={inputRef}
|
|
bind:value={typed}
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
{/if}
|
|
|
|
<div class="actions">
|
|
<button
|
|
type="button"
|
|
class="secondary"
|
|
bind:this={cancelRef}
|
|
onclick={onCancel}
|
|
disabled={busy}
|
|
>
|
|
{cancelLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class={variant === 'danger' ? 'danger' : ''}
|
|
onclick={handleConfirm}
|
|
disabled={!phraseMatches || busy}
|
|
>
|
|
{busy ? (busyLabel ?? `${confirmLabel}…`) : confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(2, 6, 23, 0.7);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
z-index: 100;
|
|
}
|
|
|
|
.dialog {
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
padding: 1.5rem;
|
|
width: 100%;
|
|
max-width: 32rem;
|
|
max-height: calc(100vh - 2rem);
|
|
overflow-y: auto;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
|
}
|
|
|
|
.dialog.danger {
|
|
border-color: #7f1d1d;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0 0 1rem;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.dialog.danger h2 {
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.body {
|
|
font-size: 0.9rem;
|
|
line-height: 1.5;
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
.body :global(p) {
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
|
|
.body :global(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.body :global(code) {
|
|
background: #1e293b;
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.body :global(strong) {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.body :global(ul) {
|
|
margin: 0.5rem 0 0.75rem;
|
|
padding-left: 1.25rem;
|
|
}
|
|
|
|
.body :global(.impact-list) {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0.75rem 0;
|
|
background: #1e293b;
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem 0.75rem;
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.body :global(.impact-list li) {
|
|
padding: 0.25rem 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.body :global(.impact-list li + li) {
|
|
border-top: 1px solid #334155;
|
|
}
|
|
|
|
.body :global(.modal-error) {
|
|
margin-top: 0.75rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 1px solid #b91c1c;
|
|
background: #450a0a;
|
|
color: #fecaca;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.body :global(.muted) {
|
|
color: #64748b;
|
|
}
|
|
|
|
.phrase {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
margin: 1rem 0 0;
|
|
font-size: 0.85rem;
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
.phrase code {
|
|
background: #1e293b;
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 0.25rem;
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.phrase input {
|
|
background: #0b1220;
|
|
color: #e2e8f0;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem 0.75rem;
|
|
font: inherit;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
}
|
|
|
|
.phrase input:focus {
|
|
outline: none;
|
|
border-color: #38bdf8;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
button {
|
|
background: #38bdf8;
|
|
color: #0b1220;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 600;
|
|
font: inherit;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button.secondary {
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
border: 1px solid #334155;
|
|
}
|
|
|
|
button.danger {
|
|
background: #7f1d1d;
|
|
color: #fecaca;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|