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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user