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
+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);