/** * Modal registry (docs/architecture/03 → Layer 1). * * One metadata entry per feature modal — the single source of truth the * coordinator and shell read, so adding a modal is one entry plus its component * rather than edits scattered across the shell, URL sync, and close logic. * * Divergence from the arch sketch's optional generic footer: each modal renders * its OWN action row inside its body (the Datasets manager is multi-view, so a * single shell-level Save/Cancel doesn't fit). The registry therefore omits * `hasError`/`getError` (validity is the modal's own concern) and keeps only * `getState` for unsaved-change detection on close. The registry is a partial * map — only the implemented ones are registered here. Settings is deliberately NOT * a modal — preferences are distributed to per-pane disclosure popovers (spec §07; * see components/SettingsPopover). */ import type { ComponentType } from 'react'; import type { ActiveModal, ModalName } from './types'; import { AboutModal } from '../components/AboutModal'; import { ChartBuilderModal } from '../components/ChartBuilderModal'; import { DatasetsModal } from '../components/DatasetsModal'; import { DonateModal } from '../components/DonateModal'; import { ExtractModal } from '../components/ExtractModal'; import { useChartBuilderStore } from '../stores/ChartBuilderStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { useExtractStore } from '../stores/ExtractStore'; export interface ModalConfig { name: ModalName; /** Header title (literal for now; an i18n key once strings are centralized). */ title: string; /** The body rendered inside the shell. */ component: ComponentType; /** Initialize transient state on open. `arg` carries an optional sub-target. */ init?: (arg?: string) => void; /** * Serializable snapshot of in-progress edits for unsaved-change detection. * Return null when there is nothing to lose (browsing, or no open form) so * closing doesn't prompt. Omit entirely for modals that apply immediately. */ getState?: () => Record | null; /** Whether the modal is reflected in the URL hash (navigable). */ isUrlNavigable?: boolean; /** * Whether a backdrop (click-outside) closes the modal. Defaults to `true`. Set * `false` for modals holding in-progress work an accidental click shouldn't * discard (the Chart Builder) — Escape and the close button still dismiss. */ dismissOnBackdrop?: boolean; } export const MODAL_REGISTRY: Partial> = { // Navigable, multi-view manager. The snapshot captures only the open create/edit // form, so browsing list↔detail never trips a false discard prompt. datasets: { name: 'datasets', title: 'Datasets', component: DatasetsModal, isUrlNavigable: true, init: (datasetId) => useDatasetStore.getState().select(datasetId ? Number(datasetId) : null), getState: () => { const s = useDatasetStore.getState(); return s.view === 'new' || s.view === 'edit' ? { form: s.form } : null; }, }, // Opened from the snippet editor with the active draft's inline data to lift out. extract: { name: 'extract', title: 'Extract to Dataset', component: ExtractModal, init: () => useExtractStore.getState().init(), getState: () => ({ name: useExtractStore.getState().name }), }, // Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads // the dataset and pre-populates a smart default config (§06). Applies on Create // (a new snippet), so there is nothing transient to lose on close — no getState. // Backdrop dismissal is off: the config is real in-progress work, and a stray // click outside this large surface shouldn't throw it away (Escape/× still close). chartBuilder: { name: 'chartBuilder', title: 'Chart Builder', component: ChartBuilderModal, isUrlNavigable: true, dismissOnBackdrop: false, init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null), }, // Pure info modals — no state, no validity check, no discard prompt on close. // `about` is URL-navigable (reload-restore); `donate` is not (spec §01E hash // table omits both, but `about` is still a permanent, bookmark-worthy surface). about: { name: 'about', title: 'About & Help', component: AboutModal, }, donate: { name: 'donate', title: 'Donate', component: DonateModal, }, }; 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;