# 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. - **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; // 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 | 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 = { // 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 = 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 { 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(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 (
{/* ...library · editor · preview panes, header... */} {config && (
void closeModal()} // backdrop dismisses onKeyDown={(e) => { if (e.key === 'Escape') void closeModal(); }} >
e.stopPropagation()} // inside body never dismisses >
{/* The ONE place the active modal is mapped to a component. */}
{/* Optional generic action row for editing modals. A modal with no primary action (about, donate) can render its own footer/none. */} {config.getState && (
)}
)}
); } ``` Rendering `` from the registry is the only modal-name→view mapping in the app. There is no `name === 'datasets' && ` 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. ```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( active: boolean, initialSelector?: string, ) { const ref = useRef(null); const returnTo = useRef(null); useEffect(() => { const el = ref.current; if (!active || !el) return; returnTo.current = document.activeElement; const initial = (initialSelector ? el.querySelector(initialSelector) : null) ?? el.querySelector(FOCUSABLE); initial?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const f = el.querySelectorAll(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 `` — 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). --- ## 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` 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-`, `#datasets/dataset-/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). 3. Write the body component; it reads/writes its feature store (e.g. `useDatasetStore`, `useChartBuilderStore`) via a narrow selector. 4. If navigable, add its hash form to the URL sync and restore logic. 5. If it has a keyboard shortcut or workflow trigger, wire that to `openModal(name, arg)` — never to `activeModal` directly. No edits to the shell render, the close logic, or the change-detection code are needed: those are generic and driven entirely by the registry.