mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
626 lines
25 KiB
Markdown
626 lines
25 KiB
Markdown
# 03 · Modal System
|
||
|
||
How Astrolabe manages its modals: a single metadata-driven registry, a thin
|
||
lifecycle coordinator, and one rendering shell. This document is the
|
||
authoritative architecture for adding, opening, closing, and rendering modals.
|
||
|
||
## Goals
|
||
|
||
- **One source of truth** for modal metadata — no `switch` statements scattered
|
||
across the codebase keyed on the active modal.
|
||
- **At most one modal open at a time** (mandated by the product spec). Opening a
|
||
modal closes any other; the two never overlap.
|
||
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
|
||
click inside the body. A modal holding in-progress work can opt out of the
|
||
**backdrop** click (`dismissOnBackdrop: false`) so a stray click can't discard it
|
||
(the Chart Builder does); close button and `Escape` still dismiss.
|
||
- **Accessible by default**: focus moves into the modal on open and returns to
|
||
the trigger on close.
|
||
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
|
||
modals that apply changes immediately.
|
||
|
||
The system is three layers, each with a single responsibility:
|
||
|
||
| Layer | Responsibility | Lives in |
|
||
| --------------- | ----------------------------------------------------------- | ----------------------------------------- |
|
||
| **Registry** | Static metadata per modal (title, validity, snapshot, init) | `src/app/modals/modal-registry.ts` |
|
||
| **Coordinator** | Lifecycle: open, close, URL sync, change detection | `src/app/modals/ModalCoordinator.ts` |
|
||
| **Shell** | Render exactly one modal; backdrop / Escape / focus trap | `src/app/App.tsx` + a `useFocusTrap` hook |
|
||
|
||
---
|
||
|
||
## The Modal Set
|
||
|
||
Astrolabe has a small, fixed set of modals. Model it as a closed union so the
|
||
registry, coordinator, and shell are exhaustively type-checked.
|
||
|
||
```ts
|
||
// src/app/modals/types.ts
|
||
export type ModalName =
|
||
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
||
| 'about' // About & Help (shortcuts, privacy)
|
||
| 'donate' // Donate
|
||
| 'chartBuilder' // Visual no-JSON chart composition for a dataset
|
||
| 'extract'; // Extract inline spec data into a new dataset
|
||
// Settings is NOT a modal — preferences are distributed to per-pane disclosure
|
||
// popovers (spec §01C/§07; see components/SettingsPopover).
|
||
|
||
export type ActiveModal = ModalName | null;
|
||
```
|
||
|
||
Two of these — `chartBuilder` and `extract` — are **opened from within
|
||
workflows** (the Datasets manager and the snippet editor), not from the header
|
||
toolbar. That is a UI wiring detail, not a structural one: every modal opens
|
||
through the same coordinator regardless of where the trigger lives.
|
||
|
||
---
|
||
|
||
## Layer 1 — Registry
|
||
|
||
Each modal is registered once with its metadata. The registry is a plain lookup
|
||
object keyed by `ModalName`; order is irrelevant. Utility queries
|
||
(title, validity, whether a modal participates in URL state) read from the
|
||
registry so there is exactly one place to change when behavior shifts.
|
||
|
||
### Config shape
|
||
|
||
```ts
|
||
// src/app/modals/modal-registry.ts
|
||
import type { ComponentType } from 'react';
|
||
import type { ModalName } from './types';
|
||
|
||
export interface ModalConfig {
|
||
name: ModalName;
|
||
title: string; // i18n key or literal
|
||
component: ComponentType<any>; // the body rendered inside the shell
|
||
|
||
/** Initialize transient modal state when it opens. `arg` carries an
|
||
* optional sub-target (e.g. a dataset id for chartBuilder/extract). */
|
||
init?: (arg?: string) => void;
|
||
|
||
/** Serializable snapshot of in-progress edits, used to detect unsaved
|
||
* changes on close. OMIT for modals with no in-progress edits to guard
|
||
* (about, donate) — omission opts out of the discard-confirmation. */
|
||
getState?: () => Record<string, unknown> | null;
|
||
|
||
/** Whether the modal's primary action (Save / Apply) should be blocked
|
||
* because the current input is invalid. Drives the disabled button. */
|
||
hasError?: () => boolean;
|
||
|
||
/** Human-readable reason for the disabled action, shown as a tooltip. */
|
||
getError?: () => string | null;
|
||
|
||
/** Whether this modal is reflected in the URL hash (back/forward, reload
|
||
* restore). Datasets and Chart Builder are navigable; About/Donate/Extract
|
||
* are not (spec §01E). */
|
||
isUrlNavigable?: boolean;
|
||
}
|
||
```
|
||
|
||
> **Shipped divergence.** The implemented registry (`modals/modal-registry.ts`) **omits
|
||
> `hasError`/`getError`**: each modal renders its **own action row** inside its body (the
|
||
> multi-view Datasets manager doesn't fit a single shell-level Save/Cancel), so validity is
|
||
> each modal's own concern. The shipped `ModalConfig` keeps only `getState` (close-time
|
||
> unsaved-change detection) plus `init`/`isUrlNavigable`. The generic-footer sketch through
|
||
> the rest of this section is retained as the simpler pattern for a single-action modal —
|
||
> treat it as illustrative, not a description of current code.
|
||
|
||
### Example entries
|
||
|
||
```ts
|
||
import { DatasetsModal } from '../components/DatasetsModal';
|
||
import { ChartBuilderModal } from '../components/ChartBuilderModal';
|
||
import { ExtractModal } from '../components/ExtractModal';
|
||
import { DonateModal } from '../components/DonateModal';
|
||
import { AboutModal } from '../components/AboutModal';
|
||
import { useDatasetStore } from '../stores/DatasetStore';
|
||
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
|
||
import { useExtractStore } from '../stores/ExtractStore';
|
||
|
||
// Per-modal transient state lives in the relevant feature store; the registry
|
||
// reads it via `getState()` (Zustand), never through component hooks.
|
||
export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
|
||
// Navigable, editing modal — snapshot guards unsaved work.
|
||
datasets: {
|
||
name: 'datasets',
|
||
title: 'modals.datasets.title',
|
||
component: DatasetsModal,
|
||
isUrlNavigable: true,
|
||
init: (datasetId) => useDatasetStore.getState().select(datasetId ?? null),
|
||
getState: () => {
|
||
const s = useDatasetStore.getState();
|
||
return {
|
||
view: s.view, // 'list' | 'detail' | 'new'
|
||
draft: s.draftForm, // in-progress new/edit form
|
||
};
|
||
},
|
||
hasError: () => useDatasetStore.getState().formError !== null,
|
||
getError: () => useDatasetStore.getState().formError,
|
||
},
|
||
|
||
// Opened from a workflow (a specific dataset), navigable, editing.
|
||
chartBuilder: {
|
||
name: 'chartBuilder',
|
||
title: 'modals.chartBuilder.title',
|
||
component: ChartBuilderModal,
|
||
isUrlNavigable: true,
|
||
init: (datasetId) => useChartBuilderStore.getState().initFor(datasetId),
|
||
getState: () => ({ encoding: useChartBuilderStore.getState().encoding }),
|
||
hasError: () => !useChartBuilderStore.getState().markType,
|
||
getError: () =>
|
||
useChartBuilderStore.getState().markType ? null : 'modals.chartBuilder.pickMark',
|
||
},
|
||
|
||
// Opened from the snippet editor with the inline data to lift out.
|
||
extract: {
|
||
name: 'extract',
|
||
title: 'modals.extract.title',
|
||
component: ExtractModal,
|
||
init: (sourceKey) => useExtractStore.getState().initFrom(sourceKey),
|
||
getState: () => ({ name: useExtractStore.getState().name }),
|
||
hasError: () => useExtractStore.getState().name.trim() === '',
|
||
getError: () => (useExtractStore.getState().name.trim() ? null : 'modals.extract.nameRequired'),
|
||
},
|
||
|
||
// Pure info modals — no state, no validity, not navigable.
|
||
about: { name: 'about', title: 'modals.about.title', component: AboutModal },
|
||
donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal },
|
||
};
|
||
```
|
||
|
||
### Registry queries
|
||
|
||
All callers go through these helpers instead of inspecting the active modal
|
||
directly:
|
||
|
||
```ts
|
||
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
|
||
name ? MODAL_REGISTRY[name] : undefined;
|
||
|
||
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
|
||
|
||
export const isUrlNavigable = (name: ActiveModal): boolean =>
|
||
getModalConfig(name)?.isUrlNavigable ?? false;
|
||
```
|
||
|
||
> **Why metadata-driven?** The alternative — branching on the active modal in
|
||
> the shell, the URL sync, the keyboard handler, and the close logic — spreads
|
||
> one decision across four files. Each new modal then means four edits and a
|
||
> chance to forget one. With the registry, a new modal is one entry plus its
|
||
> component.
|
||
|
||
**Do**
|
||
|
||
- Add a modal by appending one `MODAL_REGISTRY` entry and writing its component.
|
||
- Express validity through `hasError` / `getError` so the shell's action button
|
||
and tooltip stay generic.
|
||
- Omit `getState` for any modal that commits changes immediately.
|
||
|
||
**Don't**
|
||
|
||
- Don't `switch (activeModal)` outside the shell's body render. Lookups belong
|
||
in registry helpers.
|
||
- Don't put rendering or DOM concerns in the registry — it is pure metadata.
|
||
- Don't read rapidly-changing input state inside `hasError`/`getState` from
|
||
component render paths; compute them with a selector at the shell boundary (see
|
||
Shell layer) so a keystroke doesn't re-render the whole app.
|
||
|
||
---
|
||
|
||
## Layer 2 — Coordinator
|
||
|
||
The coordinator owns the modal lifecycle. It mutates a single piece of state —
|
||
the active modal name — plus a snapshot used for change detection, and keeps the
|
||
URL in sync. It is framework-light: pure functions over a Zustand store, unit
|
||
testable without a DOM.
|
||
|
||
### State
|
||
|
||
The active modal name is **cross-cutting UI chrome**, so it lives on the central
|
||
`useAppStore` (`activeModal` + the `setActiveModal` primitive — see
|
||
docs/architecture/01). The coordinator never gets its own store; the only extra
|
||
piece of state it needs is the change-detection **snapshot**, which is
|
||
coordinator-internal (no component reads it), so it stays as a module-local
|
||
variable rather than store state.
|
||
|
||
```ts
|
||
// useAppStore already exposes:
|
||
// activeModal: ModalName | null
|
||
// setActiveModal: (modal: ModalName | null) => void
|
||
```
|
||
|
||
### Open / close
|
||
|
||
```ts
|
||
// src/app/modals/ModalCoordinator.ts
|
||
import { useAppStore } from '../stores/AppStore';
|
||
import { MODAL_REGISTRY, getModalConfig } from './modal-registry';
|
||
import { syncModalToUrl, clearModalFromUrl } from './UrlStateSync';
|
||
|
||
let confirmDiscard: (msg: string) => Promise<boolean> = async () => true;
|
||
export const setConfirm = (fn: typeof confirmDiscard) => {
|
||
confirmDiscard = fn;
|
||
};
|
||
|
||
// Coordinator-internal: the getState() JSON captured at open, compared on close.
|
||
let stateSnapshot: string | null = null;
|
||
|
||
const snapshot = (name: ActiveModal) =>
|
||
getModalConfig(name)?.getState ? JSON.stringify(getModalConfig(name)!.getState!()) : null;
|
||
|
||
/** Open `name`, optionally with a sub-target (dataset id, source key). */
|
||
export function openModal(name: ModalName, arg?: string): void {
|
||
// Opening any modal replaces the previous one — at most one open at a time.
|
||
useAppStore.getState().setActiveModal(name);
|
||
getModalConfig(name)?.init?.(arg);
|
||
stateSnapshot = snapshot(name);
|
||
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
|
||
}
|
||
|
||
/** Close the active modal. Prompts on unsaved changes unless `force`. */
|
||
export async function closeModal(force = false): Promise<void> {
|
||
const name = useAppStore.getState().activeModal;
|
||
if (!name) return;
|
||
|
||
if (!force && hasUnsavedChanges()) {
|
||
const ok = await confirmDiscard('modals.discardChanges');
|
||
if (!ok) return;
|
||
}
|
||
|
||
clearModalFromUrl(name);
|
||
useAppStore.getState().setActiveModal(null);
|
||
stateSnapshot = null;
|
||
getModalConfig(name)?.init?.(undefined); // optional: reset transient state
|
||
}
|
||
|
||
/** Cmd/Ctrl+K toggle for the Datasets manager. */
|
||
export function toggleDatasets(): void {
|
||
if (useAppStore.getState().activeModal === 'datasets') void closeModal();
|
||
else openModal('datasets');
|
||
}
|
||
```
|
||
|
||
### Change detection
|
||
|
||
```ts
|
||
export function hasUnsavedChanges(): boolean {
|
||
const name = useAppStore.getState().activeModal;
|
||
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
|
||
const current = getModalConfig(name)?.getState?.();
|
||
if (current == null) return false;
|
||
return JSON.stringify(current) !== stateSnapshot;
|
||
}
|
||
```
|
||
|
||
The snapshot is taken once on open and compared on close. Modals without
|
||
`getState` (about, donate) snapshot to `null`, so `hasUnsavedChanges`
|
||
short-circuits and they close instantly — correct, because they hold nothing to
|
||
lose.
|
||
|
||
### Validity passthrough
|
||
|
||
```ts
|
||
export const activeModalHasError = (): boolean =>
|
||
getModalConfig(useAppStore.getState().activeModal)?.hasError?.() ?? false;
|
||
|
||
export const activeModalError = (): string | null =>
|
||
getModalConfig(useAppStore.getState().activeModal)?.getError?.() ?? null;
|
||
```
|
||
|
||
> **Why a coordinator instead of letting components open/close themselves?**
|
||
> Centralizing means the "close the previous one", snapshot, URL-sync, and
|
||
> discard-prompt rules are enforced once. A component that opened a peer modal
|
||
> directly could bypass the discard check or leave the URL stale.
|
||
|
||
**Do**
|
||
|
||
- Route every open/close through `openModal` / `closeModal`.
|
||
- Take the snapshot in `openModal` (after `init`) and compare in `closeModal`.
|
||
- Keep the coordinator DOM-free so it can be tested with plain Vitest.
|
||
|
||
**Don't**
|
||
|
||
- Don't mutate `activeModal` directly from components or handlers.
|
||
- Don't skip `closeModal`'s unsaved-change check by toggling state manually;
|
||
pass `force` only when the user has explicitly saved or confirmed.
|
||
|
||
---
|
||
|
||
## Layer 3 — Shell
|
||
|
||
`App` renders **exactly one** modal — whichever `activeModal` names — inside a
|
||
single reusable shell. The shell provides the backdrop, header, focus trap, and
|
||
the generic close/action affordances; the modal's registered `component` fills
|
||
the body.
|
||
|
||
```tsx
|
||
// src/app/App.tsx (modal portion)
|
||
import { useAppStore } from '../stores/AppStore';
|
||
import { getModalConfig, getModalTitle } from '../modals/modal-registry';
|
||
import { closeModal, activeModalHasError, activeModalError } from '../modals/ModalCoordinator';
|
||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||
|
||
export function App() {
|
||
const name = useAppStore((s) => s.activeModal);
|
||
const config = getModalConfig(name);
|
||
|
||
// Move focus into the modal on open, return it to the trigger on close.
|
||
const modalRef = useFocusTrap<HTMLDivElement>(name !== null);
|
||
|
||
// Derived at the shell boundary so per-keystroke store reads don't
|
||
// re-render the whole app tree.
|
||
const hasError = activeModalHasError();
|
||
const errorMsg = activeModalError();
|
||
|
||
return (
|
||
<div className={styles.app}>
|
||
{/* ...library · editor · preview panes, header... */}
|
||
|
||
{config && (
|
||
<div
|
||
className={styles.backdrop}
|
||
onClick={() => void closeModal()} // backdrop dismisses
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') void closeModal();
|
||
}}
|
||
>
|
||
<div
|
||
ref={modalRef}
|
||
className={styles.modal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="modal-title"
|
||
onClick={(e) => e.stopPropagation()} // inside body never dismisses
|
||
>
|
||
<header className={styles.modalHeader}>
|
||
<h2 id="modal-title">{t(getModalTitle(name))}</h2>
|
||
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
<div className={styles.modalBody}>
|
||
{/* The ONE place the active modal is mapped to a component. */}
|
||
<config.component />
|
||
</div>
|
||
|
||
{/* Optional generic action row for editing modals. A modal with no
|
||
primary action (about, donate) can render its own footer/none. */}
|
||
{config.getState && (
|
||
<footer className={styles.modalFooter}>
|
||
<button className="btn-secondary" onClick={() => void closeModal()}>
|
||
{t('buttons.cancel')}
|
||
</button>
|
||
<button
|
||
className="btn-primary"
|
||
aria-disabled={hasError || undefined}
|
||
title={errorMsg ? t(errorMsg) : undefined}
|
||
onClick={() => {
|
||
if (!hasError) config.component /* invoke save handler */;
|
||
}}
|
||
>
|
||
{t('buttons.save')}
|
||
</button>
|
||
</footer>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Rendering `<config.component />` from the registry is the only modal-name→view
|
||
mapping in the app. There is no `name === 'datasets' && <DatasetsModal/>` chain.
|
||
|
||
### Focus trap
|
||
|
||
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.
|
||
|
||
The trap wraps Tab **only within the shell element**: content portaled to
|
||
`<body>` (a `SelectControl` panel or other disclosure popover opened from inside
|
||
a modal) is outside both the trap's DOM subtree and its keydown listener. Such
|
||
popovers must therefore handle Tab themselves — close and refocus their trigger
|
||
(the native-select convention) — so focus can't strand outside the dialog while
|
||
it is open.
|
||
|
||
```ts
|
||
// src/app/hooks/useFocusTrap.ts
|
||
import { useRef, useEffect } from 'react';
|
||
|
||
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,
|
||
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],
|
||
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(); // restore focus on close
|
||
};
|
||
}, [active, initialSelector]);
|
||
|
||
return ref;
|
||
}
|
||
```
|
||
|
||
> **Why one shell instead of each modal rendering its own chrome?** Backdrop
|
||
> behavior, the focus trap, `aria-modal`, Escape handling, and the close button
|
||
> are identical for every modal and easy to get subtly wrong (e.g. a backdrop
|
||
> that dismisses on inner clicks). Centralizing guarantees consistency and means
|
||
> accessibility is fixed once.
|
||
|
||
**Do**
|
||
|
||
- Render the active modal via `<config.component />` — the single mapping point.
|
||
- Put `onClick={closeModal}` on the backdrop and `stopPropagation` on the body.
|
||
- Compute `hasError`/`getError`/preview reads with a selector at the shell level.
|
||
- Gate the generic Save button on `hasError` and surface `getError` as its
|
||
tooltip.
|
||
|
||
**Don't**
|
||
|
||
- Don't render two modals simultaneously, and don't stack a second backdrop.
|
||
- Don't attach the focus trap to the backdrop — attach it to the modal body so
|
||
the backdrop click stays outside the trap.
|
||
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
|
||
modal is open (the handler only exists while a modal renders).
|
||
|
||
**Sizing & backdrop opt-out.** The shell picks a **size tier** by modal: a small form
|
||
(Extract), a large two-pane manager (Datasets), or a near-fullscreen **work surface**
|
||
(Chart Builder — a config pane plus a chart that wants room). The two larger tiers have a
|
||
definite height so their inner panes scroll **internally** rather than the modal growing
|
||
past the viewport. A modal opts a backdrop click out of dismissal with the registry's
|
||
`dismissOnBackdrop: false` (above).
|
||
|
||
---
|
||
|
||
## 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 feature modal (e.g. Datasets or Chart Builder) — 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:
|
||
|
||
- `openModal` calls `syncModalToUrl`; navigable modals write a hash
|
||
(`#datasets`, `#datasets/dataset-<id>`, `#datasets/dataset-<id>/build`).
|
||
Non-navigable modals (about, donate, extract) write nothing.
|
||
- `closeModal` calls `clearModalFromUrl`, returning to the underlying workspace
|
||
hash.
|
||
- On load, the URL restorer reads the hash and calls `openModal(name, arg)` to
|
||
rehydrate the right modal and sub-target.
|
||
- The global key handler maps `Cmd/Ctrl+K` → `toggleDatasets()` and `Escape` →
|
||
`closeModal()` (a no-op when `activeModal` is `null`). `Cmd/Ctrl+,` opens the
|
||
editor **settings popover**, not a modal (`openSettingsPopover('editor-settings')`;
|
||
settings are distributed — spec §01C/§07).
|
||
|
||
Because all of these call the same coordinator functions, browser
|
||
Back/Forward, keyboard shortcuts, and in-app triggers stay consistent — they
|
||
share the open/close/snapshot/URL logic rather than reimplementing it.
|
||
|
||
---
|
||
|
||
## Adding a Modal: Checklist
|
||
|
||
1. Add the name to the `ModalName` union.
|
||
2. Add one `MODAL_REGISTRY` entry (title, component; `getState`/`hasError`/
|
||
`getError` if it edits; `isUrlNavigable` + `init(arg)` if navigable;
|
||
`dismissOnBackdrop: false` if it holds in-progress work).
|
||
3. Write the body component; it reads/writes its feature store (e.g.
|
||
`useDatasetStore`, `useChartBuilderStore`) via a narrow selector.
|
||
4. If it isn't a small form, add its name to the **size-tier mapping in
|
||
`ModalShell`** (`isLarge`/`isXLarge`) — the one shell edit a new modal can
|
||
need; without it the modal renders at the small-form size.
|
||
5. If navigable, add its hash form to the URL sync and restore logic.
|
||
6. If it has a keyboard shortcut or workflow trigger, wire that to
|
||
`openModal(name, arg)` — never to `activeModal` directly.
|
||
|
||
The shell render, close logic, and change detection need no edits: those are
|
||
generic and driven entirely by the registry.
|