From 7d7034ef1a7f67364e5e5f76444149052a340c05 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 02:28:37 +0300 Subject: [PATCH] Add in-app confirmation dialog replacing window.confirm --- AGENTS.md | 10 ++- CLAUDE.md | 3 +- docs/architecture/03-modal-system.md | 93 +++++++++++++++++++-- docs/architecture/09-visual-design.md | 27 ++++++ docs/architecture/visual-specimen.html | 75 +++++++++++++++++ src/app/App.tsx | 5 ++ src/app/components/ConfirmDialog.module.css | 92 ++++++++++++++++++++ src/app/components/ConfirmDialog.tsx | 59 +++++++++++++ src/app/components/SnippetLibrary.tsx | 18 ++-- src/app/hooks/useFocusTrap.ts | 58 +++++++++++++ src/app/stores/ConfirmStore.test.ts | 50 +++++++++++ src/app/stores/ConfirmStore.ts | 80 ++++++++++++++++++ 12 files changed, 554 insertions(+), 16 deletions(-) create mode 100644 src/app/components/ConfirmDialog.module.css create mode 100644 src/app/components/ConfirmDialog.tsx create mode 100644 src/app/hooks/useFocusTrap.ts create mode 100644 src/app/stores/ConfirmStore.test.ts create mode 100644 src/app/stores/ConfirmStore.ts diff --git a/AGENTS.md b/AGENTS.md index 5e68b46..59d781c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,18 +50,22 @@ external repo is needed to work from them. ``` src/ +├── main.tsx # App entry (font wiring, startup, render) ├── core/ # Portable spec engine (no browser/React/Monaco) ├── app/ │ ├── components/ # React UI (CSS Modules co-located) -│ ├── stores/ # Zustand stores +│ ├── hooks/ # Reusable React hooks (e.g. useFocusTrap — shared by overlays) +│ ├── stores/ # Zustand stores (incl. ConfirmStore — in-app confirm dialogs) │ ├── services/ # Business logic │ ├── orchestration/ # Startup wiring: store↔adapter subscribers (persistence) -│ └── infrastructure/ # IndexedDB, localStorage, URL hash adapters +│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters styles/ # Global CSS (tokens, base) docs/ ├── spec/ # Authoritative behavioral specification (00–10) — the WHAT ├── architecture/ # Architecture playbook (00–09) — the HOW (self-contained) -└── IMPLEMENTATION-PLAN.md +│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog +├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0–M6) +└── WHY-A-SEPARATE-REBUILD.md ``` --- diff --git a/CLAUDE.md b/CLAUDE.md index 77e5373..454652f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,8 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr - **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook (00–09): the **how** (state, persistence, modals, routing, rendering, inference, relationships, vega-editor techniques, visual design). Self-contained — no external - repo needed. + repo needed. Companion: **[visual-specimen.html](docs/architecture/visual-specimen.html)** — + token sandbox + reusable-primitive catalog (open in a browser). - **[docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)** — incremental milestone plan (M0–M6), MVP boundary, per-milestone tests + manual checks, and an architecture reference index. diff --git a/docs/architecture/03-modal-system.md b/docs/architecture/03-modal-system.md index 6dca922..5fd012a 100644 --- a/docs/architecture/03-modal-system.md +++ b/docs/architecture/03-modal-system.md @@ -420,9 +420,10 @@ mapping in the app. There is no `name === 'datasets' && ` chain. ### Focus trap -A small hook saves the previously focused element, focuses the first focusable -child on open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on -close. +A small hook saves the previously focused element, focuses a focusable child on +open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on close. The +optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a +destructive confirm); it falls back to the first focusable child. ```ts // src/app/hooks/useFocusTrap.ts @@ -432,7 +433,10 @@ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; -export function useFocusTrap(active: boolean) { +export function useFocusTrap( + active: boolean, + initialSelector?: string, +) { const ref = useRef(null); const returnTo = useRef(null); @@ -441,7 +445,10 @@ export function useFocusTrap(active: boo if (!active || !el) return; returnTo.current = document.activeElement; - el.querySelector(FOCUSABLE)?.focus(); + const initial = + (initialSelector ? el.querySelector(initialSelector) : null) ?? + el.querySelector(FOCUSABLE); + initial?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; @@ -463,7 +470,7 @@ export function useFocusTrap(active: boo el.removeEventListener('keydown', onKey); (returnTo.current as HTMLElement | null)?.focus(); // restore focus on close }; - }, [active]); + }, [active, initialSelector]); return ref; } @@ -493,6 +500,80 @@ export function useFocusTrap(active: boo --- +## Confirmation & alert dialogs + +The registry/coordinator/shell above governs the **named feature modals** — a +fixed, registered, URL-navigable set with "at most one open at a time". A +destructive **confirmation** ("Delete _Name_? This cannot be undone.") is a +different animal and gets a **separate, lighter layer** rather than a `ModalName` +entry. Three properties force the split: + +- **Ephemeral & content-on-call.** A confirm isn't a fixed surface with a stored + component; its title/message/labels are supplied at the call site. There's + nothing to register. +- **Stacks _above_ a feature modal.** The discard-changes prompt must appear over + an already-open Datasets/Settings modal — which directly violates the feature + layer's "at most one open" rule. So confirmations live on a higher z-layer + (`z-index: 1000`, above the future modal shell). +- **Not navigable.** A confirmation is never a URL destination or a reload-restore + target; it only exists for the duration of one decision. + +### The primitive + +A promise-based store + one globally-mounted renderer. `confirm(opts)` returns +`Promise` and is callable from anywhere — React components and non-React +code alike: + +```ts +// src/app/stores/ConfirmStore.ts +import { confirm } from '../stores/ConfirmStore'; + +const ok = await confirm({ + title: 'Delete snippet', + message: `Delete "${name}"? This cannot be undone.`, + confirmLabel: 'Delete', + danger: true, // Carbon "danger" styling + Cancel-defaulted focus +}); +if (ok) removeSnippet(id); +``` + +| Piece | Responsibility | Lives in | +| ------------------------------- | --------------------------------------------------------------- | -------------------------------------- | +| `useConfirmStore` / `confirm()` | Hold the open request; resolve the awaiting promise | `src/app/stores/ConfirmStore.ts` | +| `ConfirmDialog` | Render the active request; backdrop, focus trap, Escape, danger | `src/app/components/ConfirmDialog.tsx` | +| `useFocusTrap` | Shared overlay focus trap (this dialog now, the shell later) | `src/app/hooks/useFocusTrap.ts` | + +`ConfirmDialog` is mounted once at the app root. Only one confirmation shows at a +time; opening a second resolves the first `false` so no awaiter hangs. + +### Dismissal — Carbon's transactional rule + +Confirmations follow Carbon's **transactional / danger modal** behavior, not the +passive-modal behavior the feature shell uses: + +- **Escape** and **Cancel** resolve `false`. +- A **backdrop click does _not_ dismiss** — the user must pick an action, so a + destructive choice is never made by an accidental outside click. (Contrast the + feature shell, where backdrop-click dismiss is correct for passive modals.) +- For `danger` requests, initial focus goes to **Cancel**, so a stray Enter can't + destroy anything; non-danger confirms focus the primary action. +- `role="alertdialog"` (not `dialog`) with `aria-describedby` on the message. + +### The coordinator seam + +The feature-modal coordinator exposes `setConfirm(fn)` for its unsaved-change +prompt. Once the feature-modal system lands, wire it to this same primitive: + +```ts +setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true })); +``` + +That keeps every destructive/lossy decision — deletes, revert, reset, and +discard-on-close — flowing through one consistent dialog. Per spec §10, all +destructive actions confirm; per §01, _non_-blocking outcomes (success, info) are +**toasts**, not dialogs — don't reach for a confirm where a toast is the right +tool. + ## URL & Keyboard Integration The coordinator is the join point for navigation: diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 447e543..a6ff97a 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -140,6 +140,13 @@ UI by swapping one set of values. Borrowed from Carbon's layering model: (filled `--accent`), **secondary** (bordered), **ghost** (text-only), **danger** (filled `--support-error`). 600-weight label. Clear hover/active and a visible focus ring. +- **Hover is variant-specific:** filled buttons (primary/danger) **darken** + (`--accent-hover` / a slight brightness drop); outlined/ghost buttons **gain a + fill one elevation step above their surface** — on `--bg` → `--layer-01`, on a + `--layer-01` surface (dialogs, panels) → `--layer-02`. Filling to the _same_ + layer as the surface reads as no hover at all (the collision that left the + confirm dialog's Cancel looking dead). Any button placed on an elevated surface + must step its hover fill up to match. - **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on keyboard focus — accessibility is non-negotiable (principle 4). - **Fields** (text, textarea, select, search): square, 1px `--border`, `--layer-01` @@ -150,6 +157,15 @@ UI by swapping one set of values. Borrowed from Carbon's layering model: - **Status indicators:** a small dot/tag for draft vs. published; a dataset glyph when references exist. Status colors only. - **Toasts:** `--layer-02`, 1px border in the support color, square, brief. +- **Dialogs (confirm / alert):** centered card on a dimmed backdrop + (`rgb(0 0 0 / 0.5)`), `--layer-01` fill, 1px `--border`, minimal overlay shadow, + square. A title, a `--text-secondary` message, and a right-aligned action row: + **Cancel** (secondary) + the primary, which is a **danger** button (filled + `--support-error`, `--on-status` label) for destructive intent. The in-app + replacement for `window.confirm`; see [arch 03 → Confirmation & alert dialogs](03-modal-system.md#confirmation--alert-dialogs) + for behavior (Carbon transactional rule: backdrop does **not** dismiss; Cancel + takes focus for danger). Use a dialog only when a decision is required — + non-blocking outcomes are **toasts**. - **Code / editor surfaces:** `--font-mono`, `--layer-01`, generous line-height. --- @@ -182,6 +198,17 @@ The chart `Config` is themed to match the app, per theme: Plex in `base.css` → restyle existing M1 components against the tokens → align `vega-themes.ts`. Verify by rendering the real app, not just the specimen. +**What the specimen is (and isn't).** It is the **token sandbox** (try accents, +ramps, themes before touching `tokens.css`) and a **catalog of reusable +primitives** in both themes — buttons, fields, tabs/status/tags, the library-row +pattern, toasts, the overlay dialog, the code surface. It is **kept in sync** with +those primitives: when a primitive's canonical look changes or a new one lands +(e.g. the confirm dialog), add/update its specimen entry. It does **not** mirror +**feature surfaces** — the Datasets / Settings / Chart Builder modals, the editor, +the full shell — those are app screens, verified in the running app (headless +Chrome, both themes), not catalogued here. That primitive-vs-feature line is what +keeps the specimen finite, honest, and worth trusting. + --- ## 7. Inspiration sources — where to look for more diff --git a/docs/architecture/visual-specimen.html b/docs/architecture/visual-specimen.html index 14f7fe6..7f75917 100644 --- a/docs/architecture/visual-specimen.html +++ b/docs/architecture/visual-specimen.html @@ -696,6 +696,52 @@ border-left-color: var(--support-error); } + /* ----- Overlay dialog (confirm / alert) ----- */ + /* Shown inline on a dimmed stage — the real one is a fixed full-screen + overlay (src/app/components/ConfirmDialog). Mirror its look only. */ + .dialog-stage { + display: flex; + align-items: flex-start; /* each card sizes to its own content, not the tallest */ + gap: var(--space-5); + flex-wrap: wrap; + padding: var(--space-7) var(--space-6); + 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); + box-shadow: 0 2px 12px rgb(0 0 0 / 0.3); + } + .dialog h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text); + } + .dialog p { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--text-secondary); + } + .dialog .actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-2); + } + /* Secondary's default hover fill (--layer-01) equals the card bg, so it + would read as dead. Mirror the real ConfirmDialog: hover to --layer-02. */ + .dialog .btn-secondary:hover { + background: var(--layer-02); + } + /* ----- Code surface ----- */ .code { background: var(--layer-01); @@ -1052,6 +1098,35 @@ + +
+

Overlay dialog — confirm / alert

+

+ The in-app replacement for window.confirm. Backdrop dims but does NOT + dismiss (Carbon transactional rule); danger variant for destructive intent, with Cancel + taking focus. Behavior lives in + docs/architecture/03 → Confirmation & alert dialogs. +

+
+
+

Delete snippet

+

Delete “Quarterly revenue”? This cannot be undone.

+
+ + +
+
+
+

Discard changes?

+

You have unsaved edits — leaving will discard them.

+
+ + +
+
+
+
+

Code / editor surface — IBM Plex Mono

diff --git a/src/app/App.tsx b/src/app/App.tsx index f1ea74d..f0b0a42 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,3 +1,4 @@ +import { ConfirmDialog } from './components/ConfirmDialog'; import { LivePreview } from './components/LivePreview'; import { SnippetLibrary } from './components/SnippetLibrary'; import { SpecEditor } from './components/SpecEditor'; @@ -33,6 +34,10 @@ export function App() {
+ + {/* Global confirmation layer — sits above the (future) feature-modal + shell so a discard-changes prompt can appear over an open modal. */} + ); } diff --git a/src/app/components/ConfirmDialog.module.css b/src/app/components/ConfirmDialog.module.css new file mode 100644 index 0000000..89f0279 --- /dev/null +++ b/src/app/components/ConfirmDialog.module.css @@ -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; +} diff --git a/src/app/components/ConfirmDialog.tsx b/src/app/components/ConfirmDialog.tsx new file mode 100644 index 0000000..b09b3fe --- /dev/null +++ b/src/app/components/ConfirmDialog.tsx @@ -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(request !== null, initialFocus); + + if (!request) return null; + + const { title, message, confirmLabel, cancelLabel, danger } = request; + + return ( +
e.key === 'Escape' && resolve(false)}> +
+

+ {title} +

+

+ {message} +

+
+ + +
+
+
+ ); +} diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx index 0c1e39f..4548209 100644 --- a/src/app/components/SnippetLibrary.tsx +++ b/src/app/components/SnippetLibrary.tsx @@ -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); }} > ✕ diff --git a/src/app/hooks/useFocusTrap.ts b/src/app/hooks/useFocusTrap.ts new file mode 100644 index 0000000..563fd7e --- /dev/null +++ b/src/app/hooks/useFocusTrap.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' + + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Trap keyboard focus within an overlay while `active` (docs/architecture/03 → + * Shell). On activation it remembers the previously focused element and moves + * focus into the container; `Tab`/`Shift+Tab` cycle within it; on deactivation + * focus returns to the trigger. Shared by every overlay (the confirm dialog now, + * the feature-modal shell later) so accessibility is implemented once. + * + * `initialSelector` optionally picks which child receives focus on open + * (e.g. the Cancel button for a destructive confirm); it falls back to the + * first focusable child. + */ +export function useFocusTrap( + active: boolean, + initialSelector?: string, +) { + const ref = useRef(null); + const returnTo = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!active || !el) return; + + returnTo.current = document.activeElement; + const initial = + (initialSelector ? el.querySelector(initialSelector) : null) ?? + el.querySelector(FOCUSABLE); + initial?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const f = el.querySelectorAll(FOCUSABLE); + if (!f.length) return; + const first = f[0]; + const last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + el.addEventListener('keydown', onKey); + return () => { + el.removeEventListener('keydown', onKey); + (returnTo.current as HTMLElement | null)?.focus(); + }; + }, [active, initialSelector]); + + return ref; +} diff --git a/src/app/stores/ConfirmStore.test.ts b/src/app/stores/ConfirmStore.test.ts new file mode 100644 index 0000000..9c5f71c --- /dev/null +++ b/src/app/stores/ConfirmStore.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { confirm, useConfirmStore } from './ConfirmStore'; + +afterEach(() => { + // Resolve any dangling request so promises never leak between tests. + useConfirmStore.getState().resolve(false); + useConfirmStore.setState({ request: null }); +}); + +describe('ConfirmStore', () => { + it('opens a request carrying the provided options', () => { + void confirm({ title: 'Delete snippet', message: 'Cannot be undone.', danger: true }); + const req = useConfirmStore.getState().request; + expect(req).not.toBeNull(); + expect(req?.title).toBe('Delete snippet'); + expect(req?.message).toBe('Cannot be undone.'); + expect(req?.danger).toBe(true); + }); + + it('resolves true when accepted and clears the request', async () => { + const p = confirm({ title: 'T', message: 'M' }); + useConfirmStore.getState().resolve(true); + await expect(p).resolves.toBe(true); + expect(useConfirmStore.getState().request).toBeNull(); + }); + + it('resolves false when cancelled and clears the request', async () => { + const p = confirm({ title: 'T', message: 'M' }); + useConfirmStore.getState().resolve(false); + await expect(p).resolves.toBe(false); + expect(useConfirmStore.getState().request).toBeNull(); + }); + + it('resolve() with no open request is a no-op', () => { + expect(() => useConfirmStore.getState().resolve(true)).not.toThrow(); + expect(useConfirmStore.getState().request).toBeNull(); + }); + + it('opening a second confirmation cancels the first (no hung awaiter)', async () => { + const first = confirm({ title: 'First', message: 'M' }); + const second = confirm({ title: 'Second', message: 'M' }); + + // The first awaiter settles false immediately; the second is now open. + await expect(first).resolves.toBe(false); + expect(useConfirmStore.getState().request?.title).toBe('Second'); + + useConfirmStore.getState().resolve(true); + await expect(second).resolves.toBe(true); + }); +}); diff --git a/src/app/stores/ConfirmStore.ts b/src/app/stores/ConfirmStore.ts new file mode 100644 index 0000000..b2b2221 --- /dev/null +++ b/src/app/stores/ConfirmStore.ts @@ -0,0 +1,80 @@ +/** + * Confirmation dialogs — the in-app replacement for `window.confirm` + * (docs/architecture/03 → "Confirmation & alert dialogs"). + * + * This is a deliberately separate, lighter layer from the named feature-modal + * system (registry/coordinator/shell). Feature modals are a fixed, registered, + * URL-navigable set with "at most one open at a time". A confirmation is the + * opposite: ephemeral, content-on-call, and allowed to sit *above* an open + * feature modal (the discard-changes prompt appears over the Datasets modal). + * So it gets its own store and its own z-layer rather than a `ModalName` entry. + * + * The API is promise-based and callable from anywhere — React components and + * non-React code alike (the modal coordinator's `setConfirm` seam will resolve + * to `confirm()` here once the feature-modal system lands). `confirm(opts)` + * returns a Promise: `true` if the user accepted, `false` if they + * cancelled / dismissed. + * + * The store holds only request state; rendering lives in ConfirmDialog.tsx. + * Keeping the resolve/replace logic here makes it unit-testable without a DOM. + */ + +import { create } from 'zustand'; + +export interface ConfirmOptions { + /** Short dialog title, e.g. "Delete snippet". */ + title: string; + /** Body text; states the consequence ("This cannot be undone."). */ + message: string; + /** Primary (accept) button label. Defaults to "Confirm". */ + confirmLabel?: string; + /** Secondary (cancel) button label. Defaults to "Cancel". */ + cancelLabel?: string; + /** + * Destructive intent → Carbon "danger" styling (red primary) and a + * cancel-defaulted focus so an accidental Enter doesn't destroy anything. + */ + danger?: boolean; +} + +interface ConfirmRequest extends ConfirmOptions { + /** Settles the promise returned by `confirm()` for this request. */ + resolve: (accepted: boolean) => void; +} + +export interface ConfirmState { + /** The open request, or null when no dialog is showing. */ + request: ConfirmRequest | null; + /** Open a confirmation and resolve when the user chooses. */ + confirm: (options: ConfirmOptions) => Promise; + /** Settle the open request with the user's choice and close the dialog. */ + resolve: (accepted: boolean) => void; +} + +export const useConfirmStore = create((set, get) => ({ + request: null, + + confirm: (options) => + new Promise((resolve) => { + // Only one confirmation at a time. If one is somehow already open, + // treat it as cancelled so its awaiter never hangs, then replace it. + const prev = get().request; + if (prev) prev.resolve(false); + set({ request: { ...options, resolve } }); + }), + + resolve: (accepted) => { + const req = get().request; + if (!req) return; // nothing open — no-op + set({ request: null }); + req.resolve(accepted); + }, +})); + +/** + * Imperative entry point for non-React callers (e.g. the future modal + * coordinator's `setConfirm`). Components may use this too, but typically + * subscribe to `useConfirmStore` for rendering. + */ +export const confirm = (options: ConfirmOptions): Promise => + useConfirmStore.getState().confirm(options);