Add in-app confirmation dialog replacing window.confirm

This commit is contained in:
2026-06-05 02:28:37 +03:00
parent edadc2a3fa
commit 7d7034ef1a
12 changed files with 554 additions and 16 deletions
@@ -0,0 +1,92 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000; /* above the future feature-modal layer (discard-over-modal) */
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgb(0 0 0 / 0.5);
}
.dialog {
width: 100%;
max-width: 28rem;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
/* Minimal shadow, reserved for true overlays (doc §09). */
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.message {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: var(--text-secondary);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-2);
}
.cancel,
.confirm {
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.cancel {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.cancel:hover {
background: var(--layer-02);
}
.confirm {
background: var(--accent);
color: var(--accent-contrast);
}
.confirm:hover {
background: var(--accent-hover);
}
.danger {
background: var(--support-error);
color: var(--on-status);
}
.danger:hover {
/* Slightly darken; the error token already carries the meaning. */
filter: brightness(0.92);
}
.cancel:focus-visible,
.confirm:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
+59
View File
@@ -0,0 +1,59 @@
import { useConfirmStore } from '../stores/ConfirmStore';
import { useFocusTrap } from '../hooks/useFocusTrap';
import styles from './ConfirmDialog.module.css';
/**
* Renders the active confirmation request from ConfirmStore (one global
* instance, mounted once at the app root). The in-app replacement for
* `window.confirm` — see docs/architecture/03 → "Confirmation & alert dialogs".
*
* Dismissal follows Carbon's transactional-modal rule: the user must pick an
* action. Escape and the Cancel button resolve `false`; a backdrop click does
* NOT dismiss (unlike passive feature modals) so a destructive choice is never
* made by an accidental outside click. For `danger` requests, focus defaults to
* Cancel so a stray Enter can't destroy anything.
*/
export function ConfirmDialog() {
const request = useConfirmStore((s) => s.request);
const resolve = useConfirmStore((s) => s.resolve);
// Focus Cancel first for destructive prompts, the primary action otherwise.
const initialFocus = request?.danger ? `.${styles.cancel}` : `.${styles.confirm}`;
const trapRef = useFocusTrap<HTMLDivElement>(request !== null, initialFocus);
if (!request) return null;
const { title, message, confirmLabel, cancelLabel, danger } = request;
return (
<div className={styles.backdrop} onKeyDown={(e) => e.key === 'Escape' && resolve(false)}>
<div
ref={trapRef}
className={styles.dialog}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
>
<h2 id="confirm-title" className={styles.title}>
{title}
</h2>
<p id="confirm-message" className={styles.message}>
{message}
</p>
<div className={styles.actions}>
<button type="button" className={styles.cancel} onClick={() => resolve(false)}>
{cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={`${styles.confirm} ${danger ? styles.danger : ''}`}
onClick={() => resolve(true)}
>
{confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
);
}
+12 -6
View File
@@ -7,6 +7,7 @@
*/
import { useShallow } from 'zustand/react/shallow';
import { confirm } from '../stores/ConfirmStore';
import { useSnippetStore } from '../stores/SnippetStore';
import styles from './SnippetLibrary.module.css';
@@ -37,11 +38,16 @@ export function SnippetLibrary() {
// Default ordering: newest-modified first (spec §02 → Sort).
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
const handleDelete = (id: string, name: string) => {
// TODO: M1 stopgap — route destructive confirms through the modal coordinator
// (docs/architecture/03) when the modal system lands, and surface a deletion
// toast (spec §02), instead of the native window.confirm.
if (window.confirm(`Delete "${name}"? This cannot be undone.`)) removeSnippet(id);
const handleDelete = async (id: string, name: string) => {
// In-app confirmation (docs/architecture/03 → confirmation dialogs).
// TODO: surface a deletion toast (spec §02) once the toast system lands (M6).
const ok = await confirm({
title: 'Delete snippet',
message: `Delete "${name}"? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
});
if (ok) removeSnippet(id);
};
return (
@@ -69,7 +75,7 @@ export function SnippetLibrary() {
title="Delete snippet"
onClick={(e) => {
e.stopPropagation();
handleDelete(s.id, s.name);
void handleDelete(s.id, s.name);
}}
>