mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add in-app confirmation dialog replacing window.confirm
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user