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
+7 -3
View File
@@ -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 (0010) — the WHAT
├── architecture/ # Architecture playbook (0009) — the HOW (self-contained)
└── IMPLEMENTATION-PLAN.md
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0M6)
└── WHY-A-SEPARATE-REBUILD.md
```
---
+2 -1
View File
@@ -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
(0009): 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 (M0M6), MVP boundary, per-milestone tests + manual checks, and an architecture
reference index.
+87 -6
View File
@@ -420,9 +420,10 @@ mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` 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<T extends HTMLElement = HTMLDivElement>(active: boolean) {
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
active: boolean,
initialSelector?: string,
) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
@@ -441,7 +445,10 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
if (!active || !el) return;
returnTo.current = document.activeElement;
el.querySelector<HTMLElement>(FOCUSABLE)?.focus();
const initial =
(initialSelector ? el.querySelector<HTMLElement>(initialSelector) : null) ??
el.querySelector<HTMLElement>(FOCUSABLE);
initial?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
@@ -463,7 +470,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(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<T extends HTMLElement = HTMLDivElement>(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<boolean>` 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:
+27
View File
@@ -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 12px). 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
+75
View File
@@ -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 @@
</div>
</section>
<!-- OVERLAY DIALOG -->
<section>
<h2>Overlay dialog — confirm / alert</h2>
<p class="cap">
The in-app replacement for <code>window.confirm</code>. Backdrop dims but does NOT
dismiss (Carbon transactional rule); danger variant for destructive intent, with Cancel
taking focus. Behavior lives in
<code>docs/architecture/03 → Confirmation &amp; alert dialogs</code>.
</p>
<div class="dialog-stage">
<div class="dialog" role="alertdialog" aria-label="Danger confirm example">
<h3>Delete snippet</h3>
<p>Delete “Quarterly revenue”? This cannot be undone.</p>
<div class="actions">
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-danger">Delete</button>
</div>
</div>
<div class="dialog" role="alertdialog" aria-label="Default confirm example">
<h3>Discard changes?</h3>
<p>You have unsaved edits — leaving will discard them.</p>
<div class="actions">
<button class="btn btn-secondary">Keep editing</button>
<button class="btn btn-primary">Discard</button>
</div>
</div>
</div>
</section>
<!-- CODE -->
<section>
<h2>Code / editor surface — IBM Plex Mono</h2>
+5
View File
@@ -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() {
<LivePreview />
</section>
</main>
{/* Global confirmation layer — sits above the (future) feature-modal
shell so a discard-changes prompt can appear over an open modal. */}
<ConfirmDialog />
</div>
);
}
@@ -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);
}}
>
+58
View File
@@ -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<T extends HTMLElement = HTMLDivElement>(
active: boolean,
initialSelector?: string,
) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
useEffect(() => {
const el = ref.current;
if (!active || !el) return;
returnTo.current = document.activeElement;
const initial =
(initialSelector ? el.querySelector<HTMLElement>(initialSelector) : null) ??
el.querySelector<HTMLElement>(FOCUSABLE);
initial?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const f = el.querySelectorAll<HTMLElement>(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;
}
+50
View File
@@ -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);
});
});
+80
View File
@@ -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<boolean>: `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<boolean>;
/** Settle the open request with the user's choice and close the dialog. */
resolve: (accepted: boolean) => void;
}
export const useConfirmStore = create<ConfirmState>((set, get) => ({
request: null,
confirm: (options) =>
new Promise<boolean>((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<boolean> =>
useConfirmStore.getState().confirm(options);