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:
@@ -50,18 +50,22 @@ external repo is needed to work from them.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
|
├── main.tsx # App entry (font wiring, startup, render)
|
||||||
├── core/ # Portable spec engine (no browser/React/Monaco)
|
├── core/ # Portable spec engine (no browser/React/Monaco)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── components/ # React UI (CSS Modules co-located)
|
│ ├── 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
|
│ ├── services/ # Business logic
|
||||||
│ ├── orchestration/ # Startup wiring: store↔adapter subscribers (persistence)
|
│ ├── orchestration/ # Startup wiring: store↔adapter subscribers (persistence)
|
||||||
│ └── infrastructure/ # IndexedDB, localStorage, URL hash adapters
|
│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters
|
||||||
styles/ # Global CSS (tokens, base)
|
styles/ # Global CSS (tokens, base)
|
||||||
docs/
|
docs/
|
||||||
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
|
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
|
||||||
├── architecture/ # Architecture playbook (00–09) — the HOW (self-contained)
|
├── architecture/ # Architecture playbook (00–09) — the HOW (self-contained)
|
||||||
└── IMPLEMENTATION-PLAN.md
|
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
|
||||||
|
├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0–M6)
|
||||||
|
└── WHY-A-SEPARATE-REBUILD.md
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
|
||||||
(00–09): the **how** (state, persistence, modals, routing, rendering, inference,
|
(00–09): the **how** (state, persistence, modals, routing, rendering, inference,
|
||||||
relationships, vega-editor techniques, visual design). Self-contained — no external
|
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
|
- **[docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)** — incremental milestone
|
||||||
plan (M0–M6), MVP boundary, per-milestone tests + manual checks, and an architecture
|
plan (M0–M6), MVP boundary, per-milestone tests + manual checks, and an architecture
|
||||||
reference index.
|
reference index.
|
||||||
|
|||||||
@@ -420,9 +420,10 @@ mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` chain.
|
|||||||
|
|
||||||
### Focus trap
|
### Focus trap
|
||||||
|
|
||||||
A small hook saves the previously focused element, focuses the first focusable
|
A small hook saves the previously focused element, focuses a focusable child on
|
||||||
child on open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on
|
open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on close. The
|
||||||
close.
|
optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a
|
||||||
|
destructive confirm); it falls back to the first focusable child.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/app/hooks/useFocusTrap.ts
|
// src/app/hooks/useFocusTrap.ts
|
||||||
@@ -432,7 +433,10 @@ const FOCUSABLE =
|
|||||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
|
||||||
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
'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 ref = useRef<T>(null);
|
||||||
const returnTo = useRef<Element | null>(null);
|
const returnTo = useRef<Element | null>(null);
|
||||||
|
|
||||||
@@ -441,7 +445,10 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
|||||||
if (!active || !el) return;
|
if (!active || !el) return;
|
||||||
|
|
||||||
returnTo.current = document.activeElement;
|
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) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Tab') return;
|
if (e.key !== 'Tab') return;
|
||||||
@@ -463,7 +470,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
|||||||
el.removeEventListener('keydown', onKey);
|
el.removeEventListener('keydown', onKey);
|
||||||
(returnTo.current as HTMLElement | null)?.focus(); // restore focus on close
|
(returnTo.current as HTMLElement | null)?.focus(); // restore focus on close
|
||||||
};
|
};
|
||||||
}, [active]);
|
}, [active, initialSelector]);
|
||||||
|
|
||||||
return ref;
|
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
|
## URL & Keyboard Integration
|
||||||
|
|
||||||
The coordinator is the join point for navigation:
|
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),
|
(filled `--accent`), **secondary** (bordered), **ghost** (text-only),
|
||||||
**danger** (filled `--support-error`). 600-weight label. Clear hover/active and
|
**danger** (filled `--support-error`). 600-weight label. Clear hover/active and
|
||||||
a visible focus ring.
|
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
|
- **Focus ring:** a 2px `--focus` outline (offset 1–2px). Always visible on
|
||||||
keyboard focus — accessibility is non-negotiable (principle 4).
|
keyboard focus — accessibility is non-negotiable (principle 4).
|
||||||
- **Fields** (text, textarea, select, search): square, 1px `--border`, `--layer-01`
|
- **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
|
- **Status indicators:** a small dot/tag for draft vs. published; a dataset glyph
|
||||||
when references exist. Status colors only.
|
when references exist. Status colors only.
|
||||||
- **Toasts:** `--layer-02`, 1px border in the support color, square, brief.
|
- **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.
|
- **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
|
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.
|
`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
|
## 7. Inspiration sources — where to look for more
|
||||||
|
|||||||
@@ -696,6 +696,52 @@
|
|||||||
border-left-color: var(--support-error);
|
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 surface ----- */
|
||||||
.code {
|
.code {
|
||||||
background: var(--layer-01);
|
background: var(--layer-01);
|
||||||
@@ -1052,6 +1098,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- CODE -->
|
||||||
<section>
|
<section>
|
||||||
<h2>Code / editor surface — IBM Plex Mono</h2>
|
<h2>Code / editor surface — IBM Plex Mono</h2>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
import { SnippetLibrary } from './components/SnippetLibrary';
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
||||||
import { SpecEditor } from './components/SpecEditor';
|
import { SpecEditor } from './components/SpecEditor';
|
||||||
@@ -33,6 +34,10 @@ export function App() {
|
|||||||
<LivePreview />
|
<LivePreview />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Global confirmation layer — sits above the (future) feature-modal
|
||||||
|
shell so a discard-changes prompt can appear over an open modal. */}
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import styles from './SnippetLibrary.module.css';
|
import styles from './SnippetLibrary.module.css';
|
||||||
|
|
||||||
@@ -37,11 +38,16 @@ export function SnippetLibrary() {
|
|||||||
// Default ordering: newest-modified first (spec §02 → Sort).
|
// Default ordering: newest-modified first (spec §02 → Sort).
|
||||||
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
|
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
|
||||||
|
|
||||||
const handleDelete = (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
// TODO: M1 stopgap — route destructive confirms through the modal coordinator
|
// In-app confirmation (docs/architecture/03 → confirmation dialogs).
|
||||||
// (docs/architecture/03) when the modal system lands, and surface a deletion
|
// TODO: surface a deletion toast (spec §02) once the toast system lands (M6).
|
||||||
// toast (spec §02), instead of the native window.confirm.
|
const ok = await confirm({
|
||||||
if (window.confirm(`Delete "${name}"? This cannot be undone.`)) removeSnippet(id);
|
title: 'Delete snippet',
|
||||||
|
message: `Delete "${name}"? This cannot be undone.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (ok) removeSnippet(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +75,7 @@ export function SnippetLibrary() {
|
|||||||
title="Delete snippet"
|
title="Delete snippet"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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