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