mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Format entire codebase with Prettier (mechanical, no behavior change)
This commit is contained in:
@@ -19,11 +19,11 @@ authoritative architecture for adding, opening, closing, and rendering modals.
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,12 +35,12 @@ 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
|
||||
| '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;
|
||||
```
|
||||
@@ -68,8 +68,8 @@ 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
|
||||
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). */
|
||||
@@ -120,8 +120,8 @@ export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
|
||||
getState: () => {
|
||||
const s = useDatasetStore.getState();
|
||||
return {
|
||||
view: s.view, // 'list' | 'detail' | 'new'
|
||||
draft: s.draftForm, // in-progress new/edit form
|
||||
view: s.view, // 'list' | 'detail' | 'new'
|
||||
draft: s.draftForm, // in-progress new/edit form
|
||||
};
|
||||
},
|
||||
hasError: () => useDatasetStore.getState().formError !== null,
|
||||
@@ -149,15 +149,25 @@ export const MODAL_REGISTRY: Record<ModalName, ModalConfig> = {
|
||||
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',
|
||||
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() },
|
||||
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 },
|
||||
about: {
|
||||
name: 'about',
|
||||
title: 'modals.about.title',
|
||||
component: AboutModal,
|
||||
isUrlNavigable: true,
|
||||
},
|
||||
donate: { name: 'donate', title: 'modals.donate.title', component: DonateModal },
|
||||
};
|
||||
```
|
||||
@@ -171,8 +181,7 @@ directly:
|
||||
export const getModalConfig = (name: ActiveModal): ModalConfig | undefined =>
|
||||
name ? MODAL_REGISTRY[name] : undefined;
|
||||
|
||||
export const getModalTitle = (name: ActiveModal): string =>
|
||||
getModalConfig(name)?.title ?? '';
|
||||
export const getModalTitle = (name: ActiveModal): string => getModalConfig(name)?.title ?? '';
|
||||
|
||||
export const isUrlNavigable = (name: ActiveModal): boolean =>
|
||||
getModalConfig(name)?.isUrlNavigable ?? false;
|
||||
@@ -185,12 +194,14 @@ export const isUrlNavigable = (name: ActiveModal): boolean =>
|
||||
> 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.
|
||||
@@ -231,15 +242,15 @@ 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; };
|
||||
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;
|
||||
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 {
|
||||
@@ -247,7 +258,7 @@ export function openModal(name: ModalName, arg?: string): void {
|
||||
useAppStore.getState().setActiveModal(name);
|
||||
getModalConfig(name)?.init?.(arg);
|
||||
stateSnapshot = snapshot(name);
|
||||
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
|
||||
syncModalToUrl(name, arg); // no-op when !isUrlNavigable
|
||||
}
|
||||
|
||||
/** Close the active modal. Prompts on unsaved changes unless `force`. */
|
||||
@@ -278,7 +289,7 @@ export function toggleDatasets(): void {
|
||||
```ts
|
||||
export function hasUnsavedChanges(): boolean {
|
||||
const name = useAppStore.getState().activeModal;
|
||||
if (!name || stateSnapshot === null) return false; // no snapshot ⇒ opted out
|
||||
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;
|
||||
@@ -306,11 +317,13 @@ export const activeModalError = (): string | null =>
|
||||
> 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.
|
||||
@@ -350,8 +363,10 @@ export function App() {
|
||||
{config && (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={() => void closeModal()} // backdrop dismisses
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') void closeModal(); }}
|
||||
onClick={() => void closeModal()} // backdrop dismisses
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') void closeModal();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
@@ -359,11 +374,13 @@ export function App() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => e.stopPropagation()} // inside body never dismisses
|
||||
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>
|
||||
<button aria-label={t('buttons.close')} onClick={() => void closeModal()}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
@@ -382,7 +399,9 @@ export function App() {
|
||||
className="btn-primary"
|
||||
aria-disabled={hasError || undefined}
|
||||
title={errorMsg ? t(errorMsg) : undefined}
|
||||
onClick={() => { if (!hasError) config.component /* invoke save handler */; }}
|
||||
onClick={() => {
|
||||
if (!hasError) config.component /* invoke save handler */;
|
||||
}}
|
||||
>
|
||||
{t('buttons.save')}
|
||||
</button>
|
||||
@@ -428,9 +447,15 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
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(); }
|
||||
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);
|
||||
@@ -451,6 +476,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
> 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.
|
||||
@@ -458,6 +484,7 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(active: boo
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user