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

19 KiB
Raw Blame History

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.

// 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

// 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

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:

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.

// useAppStore already exposes:
//   activeModal: ModalName | null
//   setActiveModal: (modal: ModalName | null) => void

Open / close

// 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

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

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.

// 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.

// 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+KtoggleDatasets(), Cmd/Ctrl+,openModal('settings'), and EscapecloseModal() (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.