Files
astrolabe/docs/architecture/03-modal-system.md
T

530 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
| 'settings' // Appearance, editor, performance, formatting prefs
| '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
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 that apply immediately (settings,
* 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; Donate is not. */
isUrlNavigable?: boolean;
}
```
### Example entries
```ts
import { DatasetsModal } from '../components/DatasetsModal';
import { SettingsModal } from '../components/SettingsModal';
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';
import { useSettingsStore } from '../stores/SettingsStore';
// 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'),
},
// Applies immediately — no getState, so closing never prompts.
settings: {
name: 'settings',
title: 'modals.settings.title',
component: SettingsModal,
isUrlNavigable: true,
init: () => useSettingsStore.getState().loadFromPrefs(),
},
// Pure info modals — no state, no validity, not navigable for donate.
about: {
name: 'about',
title: 'modals.about.title',
component: AboutModal,
isUrlNavigable: true,
},
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` (settings, about, donate) snapshot to `null`, so `hasUnsavedChanges`
short-circuits and they close instantly — correct, because they either apply
immediately or 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 the first focusable
child on open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on
close.
```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) {
const ref = useRef<T>(null);
const returnTo = useRef<Element | null>(null);
useEffect(() => {
const el = ref.current;
if (!active || !el) return;
returnTo.current = document.activeElement;
el.querySelector<HTMLElement>(FOCUSABLE)?.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]);
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).
---
## 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`,
`#settings`). Non-navigable modals (donate) 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()`,
`Cmd/Ctrl+,``openModal('settings')`, and `Escape``closeModal()` (the
Escape binding is a no-op when `activeModal` is `null`).
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.