Format entire codebase with Prettier (mechanical, no behavior change)

This commit is contained in:
2026-06-05 01:43:28 +03:00
parent 939950b136
commit 0c7297624e
32 changed files with 1597 additions and 832 deletions
+62 -35
View File
@@ -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.