@tristan2828/ui-foundation 4.57.0 → 4.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission`. For a record opened in a dialog over its list (`docs/foundation/add-an-entity.md`): `RecordDialogRoute`, `RecordLink`, `useOpenRecord`, `useRecordDialog`, `recordDialogState` |
22
22
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
@@ -3,22 +3,23 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [The reference files](#the-reference-files) · lines 37–59
7
- - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–720
8
- - Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
9
- - Step 2: `npm run gen:api` · lines 107–108
10
- - Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
11
- - Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 121–130
12
- - Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
13
- - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–612
14
- - Step 7: Register routes · lines 613–623
15
- - Step 8: Add Playwright specs for all three screens · lines 624–702
16
- - Step 9: Register the nav entry and route names · lines 703–717
17
- - Step 10: `npm run verify`. · lines 718–720
18
- - [What not to copy](#what-not-to-copy) · lines 722–748
19
- - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 750–767
20
- - [Deleting](#deleting) · lines 769–798
21
- - [Fields only a script writes](#fields-only-a-script-writes) · lines 800–821
6
+ - [The reference files](#the-reference-files) · lines 38–60
7
+ - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 62–724
8
+ - Step 1: Add `<Entity>` to `openapi.yaml` · lines 100–107
9
+ - Step 2: `npm run gen:api` · lines 108–109
10
+ - Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 110–121
11
+ - Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 122–131
12
+ - Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 132–136
13
+ - Step 6: Copy the reference files, one for one, not just the two screens · lines 137–613
14
+ - Step 7: Register routes · lines 614–627
15
+ - Step 8: Add Playwright specs for all three screens · lines 628–706
16
+ - Step 9: Register the nav entry and route names · lines 707–721
17
+ - Step 10: `npm run verify`. · lines 722–724
18
+ - [Opening a record over its list](#opening-a-record-over-its-list) · lines 726–782
19
+ - [What not to copy](#what-not-to-copy) · lines 784–810
20
+ - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 812–829
21
+ - [Deleting](#deleting) · lines 831–860
22
+ - [Fields only a script writes](#fields-only-a-script-writes) · lines 862–883
22
23
 
23
24
  <!-- /index -->
24
25
 
@@ -621,6 +622,9 @@ is a filled-in example while the Widgets demo is still here).
621
622
  belongs to a module already in the nav (its categories, its channels)
622
623
  goes in that module's entry's `children`, not a new top-level entry:
623
624
  it shows under the module while you're in it.
625
+ If the plan says records **open over the list** (a board, a list read
626
+ one record at a time), the view's route is the list route's child
627
+ instead: see [Opening a record over its list](#opening-a-record-over-its-list).
624
628
  8. **Add Playwright specs for all three screens**, one test per state
625
629
  (`loading`, `empty`, `error`, `validation`, `success`), forced through
626
630
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
@@ -719,6 +723,64 @@ is a filled-in example while the Widgets demo is still here).
719
723
  anything beyond what this list covers; note ideas in the app's
720
724
  backlog instead.
721
725
 
726
+ ## Opening a record over its list
727
+
728
+ By default a record opens on its own page, `/<entity>/:id`. A list read
729
+ one record at a time (a board's cards, a backlog) can open it in a
730
+ centered dialog over the list instead (4.58): the address is still the
731
+ record's, so it can be copied and shared, but the list stays underneath
732
+ as it was. Only when the plan asks for it; the full page stays the
733
+ default.
734
+
735
+ ```tsx
736
+ // src/App.tsx: the list route wraps its element, and the view becomes its child.
737
+ {
738
+ path: '<entity>',
739
+ element: <RecordDialogRoute list={<<Entity>sTableRoute />} label="<Entity>" />,
740
+ errorElement: <RouteErrorBoundary />,
741
+ children: [{ path: ':id', element: <<Entity>ViewRoute />, errorElement: <RouteErrorBoundary /> }],
742
+ },
743
+ { path: '<entity>/new', element: <<Entity>FormRoute /> }, // unchanged siblings
744
+ { path: '<entity>/:id/edit', element: <<Entity>FormRoute /> },
745
+
746
+ // The list: a row click, or a link on a card, opens the record over it.
747
+ const openRecord = useOpenRecord()
748
+ <DataTable onRowClick={(row) => openRecord(`/<entity>/${row.id}`)} … />
749
+ <Board renderCard={(item) => <RecordLink to={`/<entity>/${item.id}`}>{item.name}</RecordLink>} … />
750
+ ```
751
+
752
+ - **The history entry decides.** `useOpenRecord`, `<RecordLink>` (a
753
+ `<Link>`) and `recordDialogState(location)` (for a `<Link>`'s own
754
+ `state`) put the list's address in the entry's state; the record's
755
+ route then draws as a dialog over the list. **A deep link** (pasted,
756
+ bookmarked, reloaded in a new tab) has no such state and **is the full
757
+ page**, back link and all, as is a record opened from a page other
758
+ than its list (a link on the home page).
759
+ - **The list stays as it was**: mounted underneath, reading the address
760
+ it was opened from, so its search, filters, sort, page and scroll are
761
+ unchanged when the dialog closes.
762
+ - **Closing**: Back, Esc, the backdrop and the dialog's Close all go
763
+ back to the list's entry (over every record opened inside the dialog,
764
+ a linked one included). Focus goes back to the row or link that
765
+ opened it.
766
+ - **Open full page**, in the dialog's bar, swaps the entry for the same
767
+ address as the full page; Back from there is the list.
768
+ - **The view is unchanged.** `EntityView` sees the dialog: its title
769
+ names the dialog (`label` names it while loading or on an error), the
770
+ back link gives way to the dialog's bar, and not found's "Back to …"
771
+ closes the dialog. Loading, error with Try again, and the plain 404
772
+ show inside it. A screen that isn't an `EntityView` can read
773
+ `useRecordDialog()` (null on the full page) for its `close`, as a
774
+ Delete should, rather than navigating to the list:
775
+ `onDeleted={() => (dialog ? dialog.close() : navigate('/<entity>'))}`.
776
+ - **Edit** still goes to `/<entity>/:id/edit`, the form's full page.
777
+ - `DataTable`'s `onRowClick` alone makes rows open: a pointer, Enter,
778
+ ↑/↓ between rows (4.58; it used to need a `detailPanel`). A link or
779
+ control in a cell keeps its own click.
780
+ - Specs: the package's own (`e2e/record-dialog.spec.ts`) cover the
781
+ dialog. An app's spec opens a record from the list, checks the
782
+ address, closes it with Escape, and checks the list's filter survived.
783
+
722
784
  ## What not to copy
723
785
 
724
786
  - `widget-quick-actions.tsx`, its `quick action: …` specs and
@@ -34,7 +34,7 @@
34
34
  - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1288–1352
35
35
  - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1354–1422
36
36
  - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1424–1452
37
- - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1454–1528
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1454–1530
38
38
 
39
39
  <!-- /index -->
40
40
 
@@ -1523,6 +1523,8 @@ const { grouping, setGrouping, ...table } = useTableUrlState(FILTERS, MULTI, VIE
1523
1523
  short notes preview (a few lines of the record's notes, not the whole
1524
1524
  editor), and an "Open page" link (a `<Link>` in `text-link`, so it's
1525
1525
  announced as the link it is). Without `detailPanel` rows behave as they
1526
- always have: the title link opens the record's page.
1526
+ always have: the title link opens the record's page, or, with
1527
+ `onRowClick`, the row opens the record (a dialog over the list:
1528
+ add-an-entity.md "Opening a record over its list").
1527
1529
  - **The table layout's alone:** a card layout's cards don't open the
1528
1530
  panel.
@@ -118,10 +118,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
118
118
  */
119
119
  detailPanel?: (row: TData) => ReactNode;
120
120
  /**
121
- * What a click on a row (or Enter on it) does instead of picking it for
122
- * the `detailPanel`: open its page, say. A link or control inside the row
123
- * keeps its own click, and the panel is then opened from the app's own
124
- * control through `detailRow`. Left out, a row click picks the row.
121
+ * What a click on a row (or Enter on it) does: open its page, or open it
122
+ * in a dialog over the list (`useOpenRecord`, add-an-entity.md "Opening a
123
+ * record over its list"). The rows then take a pointer and the keyboard
124
+ * (↑/↓ between rows, Enter). A link or control inside the row keeps its
125
+ * own click. With a `detailPanel` it replaces picking the row, and the
126
+ * panel is then opened from the app's own control through `detailRow`.
127
+ * Left out, a row click picks the row for the panel, if there is one.
125
128
  */
126
129
  onRowClick?: (row: TData) => void;
127
130
  /**
@@ -395,6 +395,9 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
395
395
  const places = runs
396
396
  ? runs.flatMap((run) => folded.has(run.key) ? [] : run.rows.map((row) => ({ key: placeKey(run.key, row.id), rowId: row.id })))
397
397
  : rows.map((row) => ({ key: row.id, rowId: row.id }));
398
+ // Rows that open on a click or Enter: picked for the panel, or the app's
399
+ // own `onRowClick` (a record's page, or a dialog over the list).
400
+ const rowsOpen = Boolean(detailPanel || onRowClickProp);
398
401
  const selectedRow = detailPanel && selection ? rows.find((row) => row.id === selection.rowId) : undefined;
399
402
  // The place marked picked: where it was picked, or (regrouped since) the
400
403
  // row's first place. None while its group is folded.
@@ -447,7 +450,9 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
447
450
  return;
448
451
  event.preventDefault();
449
452
  const down = event.key === 'ArrowDown';
450
- const index = places.findIndex((place) => place.key === selectedKey);
453
+ // From the picked row, or (no panel: rows that only open) the focused one.
454
+ const fromKey = selectedKey ?? target.closest('tr[data-row-key]')?.dataset.rowKey;
455
+ const index = places.findIndex((place) => place.key === fromKey);
451
456
  const nextIndex = index === -1 ? (down ? 0 : places.length - 1) : Math.min(places.length - 1, Math.max(0, index + (down ? 1 : -1)));
452
457
  const next = places[nextIndex];
453
458
  if (!next)
@@ -475,16 +480,15 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
475
480
  };
476
481
  // One body row, the same in a plain table and a grouped one.
477
482
  const renderRow = (row, key = row.id) => {
478
- const isSelected = key === selectedKey;
479
- return (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, ...(detailPanel
483
+ const isSelected = Boolean(detailPanel) && key === selectedKey;
484
+ return (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, ...(rowsOpen
480
485
  ? {
481
486
  'data-row-key': key,
482
- 'data-selected': isSelected || undefined,
483
- 'aria-selected': isSelected,
487
+ ...(detailPanel ? { 'data-selected': isSelected || undefined, 'aria-selected': isSelected } : {}),
484
488
  tabIndex: key === tabStopKey ? 0 : -1,
485
489
  onClickCapture: (event) => onRowClick(event, { key, rowId: row.id }),
486
490
  }
487
- : {}), className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS, detailPanel && SELECTABLE_ROW_CLASS, isSelected && SELECTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, key));
491
+ : {}), className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS, rowsOpen && SELECTABLE_ROW_CLASS, isSelected && SELECTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, key));
488
492
  };
489
493
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, "data-layout": layouts?.length ? currentLayout : undefined, style: width === 'content' && !isSuccessView && lastWidth !== null
490
494
  ? { '--data-table-last-width': `${lastWidth}px` }
@@ -497,7 +501,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
497
501
  return;
498
502
  event.preventDefault();
499
503
  closeDetail();
500
- }, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h2", { className: "type-label text-muted-foreground", children: detailLabel }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Close", onClick: closeDetail, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }), detailPanel(selectedRow.original)] })) : null, children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, runs: runs, rows: rows, folded: folded, onToggleFold: toggleFold, groupSummary: groupSummary, groupIcons: groupIcons })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef, onKeyDown: detailPanel ? onListKeyDown : undefined,
504
+ }, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h2", { className: "type-label text-muted-foreground", children: detailLabel }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Close", onClick: closeDetail, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }), detailPanel(selectedRow.original)] })) : null, children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, runs: runs, rows: rows, folded: folded, onToggleFold: toggleFold, groupSummary: groupSummary, groupIcons: groupIcons })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef, onKeyDown: rowsOpen ? onListKeyDown : undefined,
501
505
  // With the sticky bar below as the visible scrollbar, hide the
502
506
  // container's own. It stays overflow-x-auto and fully
503
507
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -35,6 +35,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
35
35
  // <KeyStats>) under that, the record's key numbers; `footer`
36
36
  // (a <SourceCredit>) after them all, for crediting the data's source.
37
37
  //
38
+ // Opened in a dialog over its list (RecordDialogRoute, record-dialog.tsx),
39
+ // the view is the same: its <h1> names the dialog, the back link gives way
40
+ // to the dialog's bar (Open full page, Close), and not found's way out
41
+ // closes the dialog.
42
+ //
38
43
  // Two layouts. `column` (the default): every section in one column capped
39
44
  // at max-w-4xl. `rail`, for a record with long content: the page takes the
40
45
  // content area's whole width, sections placed `rail` go in a narrow column
@@ -59,6 +64,7 @@ import { EditInPlaceContext } from './edit-in-place.js';
59
64
  import { EditInPlaceStore } from './edit-in-place-store.js';
60
65
  import { EditableList } from './editable-list.js';
61
66
  import { EditableValue, LeaveGuard } from './editable-value.js';
67
+ import { useRecordDialog } from './record-dialog-context.js';
62
68
  const DEFAULT_EMPTY_LABEL = 'Not set';
63
69
  function isEmptyValue(value) {
64
70
  return (value === null ||
@@ -151,12 +157,15 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
151
157
  // Editing in place: one store per view, and the leave-page prompt only
152
158
  // while a field is open (or one is always editable).
153
159
  const [store] = useState(() => new EditInPlaceStore());
160
+ // Opened over its list (RecordDialogRoute): the heading names the dialog,
161
+ // and the dialog's own bar replaces the back link.
162
+ const dialog = useRecordDialog();
154
163
  const editing = useSyncExternalStore(store.subscribe, () => store.editing, () => store.editing);
155
164
  useDocumentTitle(isLoading || error || documentTitle === false ? undefined : (documentTitle ?? title));
156
165
  if (error?.kind === 'notfound') {
157
166
  // A record that doesn't exist and one that belongs to another user are
158
167
  // the same plain 404, so this never says which. Retrying can't help.
159
- return (_jsxs(Empty, { "data-state": "notfound", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(SearchXIcon, {}) }), _jsx(EmptyTitle, { children: "Not found" }), _jsx(EmptyDescription, { children: error.message })] }), _jsx(EmptyContent, { children: _jsxs(Button, { size: "sm", variant: "outline", nativeButton: false, render: _jsx(Link, { to: back.to }), children: ["Back to ", back.label] }) })] }));
168
+ return (_jsxs(Empty, { "data-state": "notfound", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(SearchXIcon, {}) }), _jsx(EmptyTitle, { children: "Not found" }), _jsx(EmptyDescription, { children: error.message })] }), _jsx(EmptyContent, { children: dialog ? (_jsxs(Button, { size: "sm", variant: "outline", onClick: dialog.close, children: ["Back to ", back.label] })) : (_jsxs(Button, { size: "sm", variant: "outline", nativeButton: false, render: _jsx(Link, { to: back.to }), children: ["Back to ", back.label] })) })] }));
160
169
  }
161
170
  if (error)
162
171
  return _jsx(ErrorState, { error: error, onRetry: onRetry });
@@ -165,5 +174,5 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
165
174
  const railSections = sections.filter((section) => section.placement === 'rail');
166
175
  const mainSections = sections.filter((section) => section.placement !== 'rail');
167
176
  const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
168
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
177
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), !dialog && _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { id: dialog?.titleId, className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
169
178
  }
@@ -26,6 +26,13 @@ export type MarkdownProps = {
26
26
  * text is never changed.
27
27
  */
28
28
  foldKey?: string;
29
+ /**
30
+ * Set `false` to keep headings plain, with no fold buttons, even when a
31
+ * `foldKey` is given: a page-level switch (a print view, a short note)
32
+ * that leaves the key in place. Nothing is folded while it's off, and
33
+ * what was folded is remembered for when it's on again. Default `true`.
34
+ */
35
+ foldable?: boolean;
29
36
  className?: string;
30
37
  } & Omit<ComponentProps<'div'>, 'children'>;
31
- export declare function Markdown({ children, headingLevel, headingFrom, foldKey, className, ...rest }: MarkdownProps): import("react").JSX.Element;
38
+ export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -184,7 +184,8 @@ function componentsFor(top, textTop) {
184
184
  }
185
185
  return components;
186
186
  }
187
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, className, ...rest }) {
187
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, className, ...rest }) {
188
+ const foldKey = foldable ? foldKeyProp : undefined;
188
189
  const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
189
190
  const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
190
191
  const [folded, setFolded] = useFolds(foldKey);
@@ -0,0 +1,39 @@
1
+ import { type Location } from 'react-router';
2
+ /**
3
+ * Where a record dialog was opened from: the list's address, and how many
4
+ * history entries the dialog has pushed since (1, or more after a linked
5
+ * record opened inside it), which Close goes back over.
6
+ */
7
+ export type RecordDialogBackground = Pick<Location, 'pathname' | 'search' | 'hash'> & {
8
+ depth: number;
9
+ };
10
+ export type RecordDialogState = {
11
+ recordDialog: RecordDialogBackground;
12
+ };
13
+ export declare function backgroundOf(state: unknown): RecordDialogBackground | null;
14
+ /**
15
+ * The history state that opens a record over the list it's on: pass it as
16
+ * a `<Link>`'s `state` (or use `<RecordLink>`, or `useOpenRecord`). Opened
17
+ * from inside a dialog (a linked record), the new one opens over the same
18
+ * list.
19
+ */
20
+ export declare function recordDialogState(location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>): RecordDialogState;
21
+ /**
22
+ * Opens a record over the current list: `open('/widgets/3')` for a
23
+ * DataTable's `onRowClick`, say. The record's address goes in the address
24
+ * bar and history, so Back closes it.
25
+ */
26
+ export declare function useOpenRecord(): (to: string) => void;
27
+ export type RecordDialogContextValue = {
28
+ /** The dialog's title id: EntityView puts it on its heading. */
29
+ titleId: string;
30
+ /** Closes the dialog: back to the list, as Back does. */
31
+ close: () => void;
32
+ };
33
+ export declare const RecordDialogContext: import("react").Context<RecordDialogContextValue | null>;
34
+ /**
35
+ * Inside a record dialog, its `close` (a Delete that has just removed the
36
+ * record closes the dialog rather than going to the list's page); `null`
37
+ * on the record's full page.
38
+ */
39
+ export declare function useRecordDialog(): RecordDialogContextValue | null;
@@ -0,0 +1,48 @@
1
+ // The record dialog's state and hooks (record-dialog.tsx has the parts).
2
+ // A record opened over its list is the record's own address with the list's
3
+ // address in the history entry's state: `{ recordDialog: <the list's
4
+ // location> }`. RecordDialogRoute reads it to draw the list under the
5
+ // dialog, at the list's address (its filters, sort and page as they were),
6
+ // and an address with no such state (a link pasted, a reload in a new tab,
7
+ // "Open full page") is the record's full page.
8
+ import { createContext, useCallback, useContext } from 'react';
9
+ import { useLocation, useNavigate } from 'react-router';
10
+ export function backgroundOf(state) {
11
+ if (typeof state !== 'object' || state === null || !('recordDialog' in state))
12
+ return null;
13
+ const background = state.recordDialog;
14
+ return typeof background?.pathname === 'string' && typeof background.depth === 'number' ? background : null;
15
+ }
16
+ /**
17
+ * The history state that opens a record over the list it's on: pass it as
18
+ * a `<Link>`'s `state` (or use `<RecordLink>`, or `useOpenRecord`). Opened
19
+ * from inside a dialog (a linked record), the new one opens over the same
20
+ * list.
21
+ */
22
+ export function recordDialogState(location) {
23
+ const open = backgroundOf(location.state);
24
+ return {
25
+ recordDialog: open
26
+ ? { ...open, depth: open.depth + 1 }
27
+ : { pathname: location.pathname, search: location.search, hash: location.hash, depth: 1 },
28
+ };
29
+ }
30
+ /**
31
+ * Opens a record over the current list: `open('/widgets/3')` for a
32
+ * DataTable's `onRowClick`, say. The record's address goes in the address
33
+ * bar and history, so Back closes it.
34
+ */
35
+ export function useOpenRecord() {
36
+ const navigate = useNavigate();
37
+ const location = useLocation();
38
+ return useCallback((to) => void navigate(to, { state: recordDialogState(location) }), [navigate, location]);
39
+ }
40
+ export const RecordDialogContext = createContext(null);
41
+ /**
42
+ * Inside a record dialog, its `close` (a Delete that has just removed the
43
+ * record closes the dialog rather than going to the list's page); `null`
44
+ * on the record's full page.
45
+ */
46
+ export function useRecordDialog() {
47
+ return useContext(RecordDialogContext);
48
+ }
@@ -0,0 +1,21 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import { Link } from 'react-router';
3
+ export type RecordDialogRouteProps = {
4
+ /** The list's own element (the table route), drawn under an open record. */
5
+ list: ReactNode;
6
+ /**
7
+ * What one record is ("Widget"): the dialog's name while the record
8
+ * loads, fails or isn't found. Once it loads, its title names the dialog.
9
+ */
10
+ label: string;
11
+ /** Extra classes on the dialog's popup (its width, say: `sm:max-w-5xl`). */
12
+ className?: string;
13
+ };
14
+ export declare function RecordDialogRoute({ list, label, className }: RecordDialogRouteProps): import("react").JSX.Element;
15
+ export type RecordLinkProps = Omit<ComponentProps<typeof Link>, 'state'>;
16
+ /**
17
+ * A link to a record that opens it over the current list (a board's card,
18
+ * a name in a cell). Ctrl/Cmd+click and a new tab still get the full page:
19
+ * the state only travels with an in-app navigation.
20
+ */
21
+ export declare function RecordLink(props: RecordLinkProps): import("react").JSX.Element;
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // A record opened in a centered dialog over its list, instead of going to
3
+ // the record's page (add-an-entity.md "Opening a record over its list").
4
+ //
5
+ // RecordDialogRoute is the list route's element, and the record's route is
6
+ // its child:
7
+ //
8
+ // { path: 'widgets', element: <RecordDialogRoute list={<WidgetsTableRoute />} label="Widget" />,
9
+ // children: [{ path: ':id', element: <WidgetViewRoute /> }] }
10
+ //
11
+ // The address is the record's own (`/widgets/3`) either way; what decides
12
+ // dialog or page is the history entry's state, which `useOpenRecord`,
13
+ // `<RecordLink>` and `recordDialogState` put there
14
+ // (record-dialog-context.ts). So:
15
+ //
16
+ // - opened from the list, the record is a dialog over the list, and the
17
+ // list stays mounted underneath at its own address (filters, sort, page
18
+ // and scroll as they were);
19
+ // - Back, Esc, the backdrop and Close all go back to the list's entry;
20
+ // - "Open full page" swaps the entry for the same address with no state;
21
+ // - a deep link (pasted, bookmarked, a new tab) has no state and is the
22
+ // full page, as is a record opened over a list other than its own
23
+ // (a link on the home page).
24
+ //
25
+ // Inside, the record's route renders as it always does. EntityView sees
26
+ // the dialog (useRecordDialog): its heading names the dialog, its back link
27
+ // gives way to the dialog's own bar, and not found's way out closes the
28
+ // dialog. Its loading, error and not-found states are the dialog's.
29
+ import { useCallback, useId, useMemo } from 'react';
30
+ import { Link, UNSAFE_LocationContext as LocationContext, useLocation, useNavigate, useNavigationType, useOutlet, useResolvedPath, } from 'react-router';
31
+ import { Maximize2Icon, XIcon } from 'lucide-react';
32
+ import { cn } from 'cn';
33
+ import { Button, buttonVariants } from '../ui/button.js';
34
+ import { Dialog, DialogClose, DialogContent } from '../ui/dialog.js';
35
+ import { escapePassedThrough } from './escape-passthrough.js';
36
+ import { backgroundOf, recordDialogState, RecordDialogContext } from './record-dialog-context.js';
37
+ export function RecordDialogRoute({ list, label, className }) {
38
+ const location = useLocation();
39
+ const navigationType = useNavigationType();
40
+ const navigate = useNavigate();
41
+ const outlet = useOutlet();
42
+ const titleId = useId();
43
+ const listPath = useResolvedPath('.').pathname;
44
+ const opened = backgroundOf(location.state);
45
+ // Over its own list only: opened from anywhere else, the list under it
46
+ // would be a page the user wasn't on.
47
+ const background = opened && opened.pathname === listPath ? opened : null;
48
+ const open = outlet !== null && background !== null;
49
+ // The list sees the address it was opened from, not the record's, so
50
+ // its URL state (filters, sort, page) stays as it was under the dialog.
51
+ const { pathname, search, hash } = background ?? location;
52
+ const listLocation = useMemo(() => ({ pathname, search, hash, state: null, key: open ? 'record-dialog-list' : location.key }), [pathname, search, hash, open, location.key]);
53
+ const listContext = useMemo(() => ({ location: listLocation, navigationType }), [listLocation, navigationType]);
54
+ const depth = background?.depth ?? 0;
55
+ const close = useCallback(() => {
56
+ if (location.key === 'default' || depth === 0) {
57
+ // The first entry in this tab (a restored session): nothing to go
58
+ // back over, so the list's address replaces the record's.
59
+ void navigate({ pathname, search, hash }, { replace: true });
60
+ }
61
+ else
62
+ void navigate(-depth);
63
+ }, [navigate, location.key, depth, pathname, search, hash]);
64
+ const dialog = useMemo(() => ({ titleId, close }), [titleId, close]);
65
+ // The record's full page: a deep link, or "Open full page".
66
+ if (outlet !== null && !open)
67
+ return outlet;
68
+ return (_jsxs(_Fragment, { children: [_jsx(LocationContext.Provider, { value: listContext, children: list }), _jsx(Dialog, { open: open, onOpenChange: (next, details) => {
69
+ if (next)
70
+ return;
71
+ // An Esc something inside already used (a field edited in place
72
+ // giving up, a picker closing its list) isn't the dialog's. The
73
+ // rich-text editor marks every Esc used, and passes on the ones
74
+ // its menus didn't take.
75
+ const native = details.event;
76
+ if (details.reason === 'escape-key' && native.defaultPrevented && !escapePassedThrough(native)) {
77
+ details.cancel();
78
+ return;
79
+ }
80
+ close();
81
+ }, children: _jsxs(DialogContent, { showCloseButton: false, "aria-labelledby": titleId, "aria-label": label, "data-slot": "record-dialog", className: cn('flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl data-closed:hidden', className), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b px-2 py-2", children: [_jsxs(Link, { to: { pathname: location.pathname, search: location.search, hash: location.hash }, replace: true, className: buttonVariants({ variant: 'ghost', size: 'sm' }), children: [_jsx(Maximize2Icon, { "aria-hidden": "true" }), "Open full page"] }), _jsxs(DialogClose, { render: _jsx(Button, { variant: "ghost", size: "icon-sm" }), children: [_jsx(XIcon, { "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Close" })] })] }), _jsx("div", { "data-slot": "record-dialog-body", className: "min-h-0 overflow-y-auto p-4 sm:p-6", children: _jsx(RecordDialogContext.Provider, { value: dialog, children: open && outlet }) })] }) })] }));
82
+ }
83
+ /**
84
+ * A link to a record that opens it over the current list (a board's card,
85
+ * a name in a cell). Ctrl/Cmd+click and a new tab still get the full page:
86
+ * the state only travels with an in-app navigation.
87
+ */
88
+ export function RecordLink(props) {
89
+ const location = useLocation();
90
+ return _jsx(Link, { ...props, state: recordDialogState(location) });
91
+ }
@@ -80,6 +80,12 @@ export type RichTextEditorProps = {
80
80
  * when it mounts.
81
81
  */
82
82
  foldKey?: string;
83
+ /**
84
+ * `false` keeps headings plain, with no fold buttons or shortcuts, even
85
+ * with a `foldKey`, as on `<Markdown>`. Read once, when it mounts.
86
+ * Default `true`.
87
+ */
88
+ foldable?: boolean;
83
89
  className?: string;
84
90
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
85
91
  'aria-label'?: string;
@@ -34,5 +34,5 @@ import { cn } from 'cn';
34
34
  import { Skeleton } from '../ui/skeleton.js';
35
35
  const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
36
36
  export function RichTextEditor(props) {
37
- return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
37
+ return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props, foldKey: props.foldable === false ? undefined : props.foldKey }) }));
38
38
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export { byDay, type ByDayOptions } from './lib/group-by-day.js';
13
13
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
14
14
  export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
15
15
  export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewMobileOrder, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
16
+ export { RecordDialogRoute, RecordLink, type RecordDialogRouteProps, type RecordLinkProps, } from './components/app/record-dialog.js';
17
+ export { recordDialogState, useOpenRecord, useRecordDialog, type RecordDialogBackground, type RecordDialogContextValue, type RecordDialogState, } from './components/app/record-dialog-context.js';
16
18
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
17
19
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
18
20
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ export { byDay } from './lib/group-by-day.js';
17
17
  export { EntityForm } from './components/app/entity-form.js';
18
18
  export { KeyStats } from './components/app/key-stats.js';
19
19
  export { EntityView, } from './components/app/entity-view.js';
20
+ export { RecordDialogRoute, RecordLink, } from './components/app/record-dialog.js';
21
+ export { recordDialogState, useOpenRecord, useRecordDialog, } from './components/app/record-dialog-context.js';
20
22
  export { editInPlace, } from './components/app/edit-in-place.js';
21
23
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
22
24
  export { EditableValue } from './components/app/editable-value.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.57.0",
3
+ "version": "4.59.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {