@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 +1 -1
- package/conventions/docs/add-an-entity.md +78 -16
- package/conventions/docs/cell-patterns.md +4 -2
- package/dist/components/app/data-table.d.ts +7 -4
- package/dist/components/app/data-table.js +11 -7
- package/dist/components/app/entity-view.js +11 -2
- package/dist/components/app/markdown.d.ts +8 -1
- package/dist/components/app/markdown.js +2 -1
- package/dist/components/app/record-dialog-context.d.ts +39 -0
- package/dist/components/app/record-dialog-context.js +48 -0
- package/dist/components/app/record-dialog.d.ts +21 -0
- package/dist/components/app/record-dialog.js +91 -0
- package/dist/components/app/rich-text-editor.d.ts +6 -0
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
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
|
|
7
|
-
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines
|
|
8
|
-
- Step 1: Add `<Entity>` to `openapi.yaml` · lines
|
|
9
|
-
- Step 2: `npm run gen:api` · lines
|
|
10
|
-
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines
|
|
11
|
-
- Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines
|
|
12
|
-
- Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines
|
|
13
|
-
- Step 6: Copy the reference files, one for one, not just the two screens · lines
|
|
14
|
-
- Step 7: Register routes · lines
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
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–
|
|
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
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
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, ...(
|
|
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,
|
|
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:
|
|
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.
|
|
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": {
|