@tristan2828/ui-foundation 4.60.0 → 4.61.1
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 +23 -11
- package/dist/components/app/entity-view-print.d.ts +24 -0
- package/dist/components/app/entity-view-print.js +51 -0
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +16 -2
- package/dist/components/app/markdown.d.ts +1 -1
- package/dist/components/app/markdown.js +10 -2
- package/dist/components/app/milkdown-editor.js +3 -2
- package/dist/components/app/print-context.d.ts +3 -0
- package/dist/components/app/print-context.js +10 -0
- package/dist/components/app/rich-text-editor.d.ts +5 -1
- package/dist/components/app/rich-text-editor.js +30 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/styles/index.css +81 -0
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` (with `countTasks`, `toggleTaskAt`), `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` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`, printing its notes and `usePrinting`), `EntityForm`, `Markdown` (with `countTasks`, `toggleTaskAt`), `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` |
|
|
@@ -4,22 +4,22 @@
|
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
6
|
- [The reference files](#the-reference-files) · lines 38–60
|
|
7
|
-
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 62–
|
|
7
|
+
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 62–743
|
|
8
8
|
- Step 1: Add `<Entity>` to `openapi.yaml` · lines 100–107
|
|
9
9
|
- Step 2: `npm run gen:api` · lines 108–109
|
|
10
10
|
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 110–121
|
|
11
11
|
- Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 122–131
|
|
12
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–
|
|
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
|
-
- [Opening a record over its list](#opening-a-record-over-its-list) · lines
|
|
19
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
20
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
21
|
-
- [Deleting](#deleting) · lines
|
|
22
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
13
|
+
- Step 6: Copy the reference files, one for one, not just the two screens · lines 137–632
|
|
14
|
+
- Step 7: Register routes · lines 633–646
|
|
15
|
+
- Step 8: Add Playwright specs for all three screens · lines 647–725
|
|
16
|
+
- Step 9: Register the nav entry and route names · lines 726–740
|
|
17
|
+
- Step 10: `npm run verify`. · lines 741–743
|
|
18
|
+
- [Opening a record over its list](#opening-a-record-over-its-list) · lines 745–801
|
|
19
|
+
- [What not to copy](#what-not-to-copy) · lines 803–829
|
|
20
|
+
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 831–848
|
|
21
|
+
- [Deleting](#deleting) · lines 850–879
|
|
22
|
+
- [Fields only a script writes](#fields-only-a-script-writes) · lines 881–902
|
|
23
23
|
|
|
24
24
|
<!-- /index -->
|
|
25
25
|
|
|
@@ -495,6 +495,18 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
495
495
|
(`useEdit<Entity>Field`). Without the prop the boxes are pictures
|
|
496
496
|
and the text stays read-only. A list's "2/5" column counts them
|
|
497
497
|
with `countTasks(text)` (`{ done, total }`; `total` 0 for none).
|
|
498
|
+
- **Notes worth taking on paper** (a visit's prep and questions, a
|
|
499
|
+
guide, a checklist to tick by hand): mark each of those sections
|
|
500
|
+
`print: true`. The header gets a Print notes button (`printLabel`
|
|
501
|
+
renames it) that prints the marked sections alone, under the title
|
|
502
|
+
and subtitle: no rail, no shell, no buttons, light paper in either
|
|
503
|
+
theme, headings and bullets as written, nothing folded, tables
|
|
504
|
+
ruled with room in their empty cells. `print: { answerSpace: true }`
|
|
505
|
+
also leaves space under each item of the section's numbered lists,
|
|
506
|
+
for questions answered by hand. A section's own content that should
|
|
507
|
+
print differently (a list of sub-items as plain text) reads
|
|
508
|
+
`usePrinting()`, true in the printed copy. The browser's own print
|
|
509
|
+
(Ctrl+P) still prints the whole page.
|
|
498
510
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
499
511
|
names**, each a control that saves on its own (cell pattern 17,
|
|
500
512
|
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** What a section marked to print gives the printed copy. */
|
|
3
|
+
export type EntityViewPrintSection = {
|
|
4
|
+
title: string;
|
|
5
|
+
/** The section's content, or its fields as label/value rows. */
|
|
6
|
+
content?: ReactNode;
|
|
7
|
+
fields?: {
|
|
8
|
+
label: string;
|
|
9
|
+
value: ReactNode;
|
|
10
|
+
}[];
|
|
11
|
+
emptyLabel?: string;
|
|
12
|
+
answerSpace: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The header's Print notes button and, while it prints, the printed copy.
|
|
16
|
+
* The copy mounts, then the browser's print dialog opens on it; it goes
|
|
17
|
+
* when the dialog closes (`afterprint`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function PrintNotes({ label, title, subtitle, sections, }: {
|
|
20
|
+
label: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
subtitle?: ReactNode;
|
|
23
|
+
sections: EntityViewPrintSection[];
|
|
24
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// A record's notes on paper: EntityView's Print notes button and the copy
|
|
3
|
+
// it prints. Sections marked `print` (the long text: details, prep,
|
|
4
|
+
// questions) are printed under the record's title and subtitle, and
|
|
5
|
+
// nothing else: no sidebar, no header bar, no rail, no buttons.
|
|
6
|
+
//
|
|
7
|
+
// The copy is rendered only while printing, into <body> (a portal, so it
|
|
8
|
+
// sits beside the app rather than inside its layout), and styles/index.css
|
|
9
|
+
// "Printed notes" hides everything else on paper and keeps it light even
|
|
10
|
+
// in the dark theme. On screen it never shows. Its content is each
|
|
11
|
+
// section's own markup, read-only (PrintingContext): <Markdown> prints
|
|
12
|
+
// whole, unfolded, with tables ruled and room in their empty cells to
|
|
13
|
+
// write. A section marked `print: { answerSpace: true }` leaves space
|
|
14
|
+
// under each item of its numbered lists, for questions answered by hand.
|
|
15
|
+
//
|
|
16
|
+
// The browser's own print (Ctrl+P) still prints the page as it is; only
|
|
17
|
+
// the button prints the notes alone.
|
|
18
|
+
import { useEffect, useState } from 'react';
|
|
19
|
+
import { createPortal } from 'react-dom';
|
|
20
|
+
import { PrinterIcon } from 'lucide-react';
|
|
21
|
+
import { Button } from '../ui/button.js';
|
|
22
|
+
import { PrintingContext } from './print-context.js';
|
|
23
|
+
function isEmpty(value) {
|
|
24
|
+
return value === null || value === undefined || value === false || value === '' || (Array.isArray(value) && value.length === 0);
|
|
25
|
+
}
|
|
26
|
+
function PrintedSection({ section }) {
|
|
27
|
+
return (_jsxs("section", { "data-answer-space": section.answerSpace ? '' : undefined, className: "flex flex-col gap-3", children: [_jsx("h2", { className: "border-b border-border pb-1 type-label tracking-[0.08em] text-muted-foreground uppercase", children: section.title }), section.fields ? (_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 type-body", children: section.fields.map((field) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 break-words text-foreground", children: isEmpty(field.value) ? '—' : field.value })] }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: isEmpty(section.content) ? _jsx("span", { className: "text-muted-foreground", children: section.emptyLabel ?? 'Not set' }) : section.content }))] }));
|
|
28
|
+
}
|
|
29
|
+
function PrintedNotes({ title, subtitle, sections }) {
|
|
30
|
+
return createPortal(_jsx(PrintingContext.Provider, { value: true, children: _jsxs("article", { "data-print-notes": "", "data-slot": "entity-view-print", className: "hidden flex-col gap-6 bg-background text-foreground print:flex", children: [_jsxs("header", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "type-heading-1 break-words", children: title }), subtitle && _jsx("div", { className: "type-body break-words text-muted-foreground", children: subtitle })] }), sections.map((section) => (_jsx(PrintedSection, { section: section }, section.title)))] }) }), document.body);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The header's Print notes button and, while it prints, the printed copy.
|
|
34
|
+
* The copy mounts, then the browser's print dialog opens on it; it goes
|
|
35
|
+
* when the dialog closes (`afterprint`).
|
|
36
|
+
*/
|
|
37
|
+
export function PrintNotes({ label, title, subtitle, sections, }) {
|
|
38
|
+
// How many times it's been pressed since the copy last went: a press
|
|
39
|
+
// while one is up (a browser that never said the dialog closed) prints
|
|
40
|
+
// it again rather than doing nothing.
|
|
41
|
+
const [printing, setPrinting] = useState(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (printing === 0)
|
|
44
|
+
return;
|
|
45
|
+
const done = () => setPrinting(0);
|
|
46
|
+
window.addEventListener('afterprint', done);
|
|
47
|
+
window.print();
|
|
48
|
+
return () => window.removeEventListener('afterprint', done);
|
|
49
|
+
}, [printing]);
|
|
50
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", onClick: () => setPrinting((count) => count + 1), children: [_jsx(PrinterIcon, {}), label] }), printing > 0 && _jsx(PrintedNotes, { title: title, subtitle: subtitle, sections: sections })] }));
|
|
51
|
+
}
|
|
@@ -44,6 +44,17 @@ export type EntityViewSection = ({
|
|
|
44
44
|
* long text and long lists. The `column` layout ignores it.
|
|
45
45
|
*/
|
|
46
46
|
placement?: EntityViewPlacement;
|
|
47
|
+
/**
|
|
48
|
+
* The section is part of the record's printed notes: marking any one
|
|
49
|
+
* adds a Print notes button to the header, which prints the marked
|
|
50
|
+
* sections alone (their content as shown, read-only, nothing folded)
|
|
51
|
+
* under the title and subtitle. `{ answerSpace: true }` also leaves
|
|
52
|
+
* room under each item of the section's numbered lists, to write an
|
|
53
|
+
* answer by hand (a visit's questions).
|
|
54
|
+
*/
|
|
55
|
+
print?: boolean | {
|
|
56
|
+
answerSpace?: boolean;
|
|
57
|
+
};
|
|
47
58
|
};
|
|
48
59
|
export type EntityViewPlacement = 'main' | 'rail';
|
|
49
60
|
/**
|
|
@@ -115,6 +126,8 @@ export type EntityViewProps = {
|
|
|
115
126
|
* ignores it.
|
|
116
127
|
*/
|
|
117
128
|
mobileOrder?: EntityViewMobileOrder;
|
|
129
|
+
/** The Print notes button's words, when a section is marked `print`. Defaults to "Print notes". */
|
|
130
|
+
printLabel?: string;
|
|
118
131
|
/** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
|
|
119
132
|
railLabel?: string;
|
|
120
133
|
isLoading: boolean;
|
|
@@ -127,4 +140,4 @@ export type EntityViewProps = {
|
|
|
127
140
|
label: string;
|
|
128
141
|
};
|
|
129
142
|
};
|
|
130
|
-
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
143
|
+
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, printLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -40,6 +40,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
40
40
|
// to the dialog's bar (Open full page, Close), and not found's way out
|
|
41
41
|
// closes the dialog.
|
|
42
42
|
//
|
|
43
|
+
// Printing the notes (entity-view-print.tsx): sections marked `print`
|
|
44
|
+
// (the long text: details, prep, questions) give the header a Print notes
|
|
45
|
+
// button, which prints those sections alone under the title and
|
|
46
|
+
// subtitle, with no rail, shell or buttons, on light paper.
|
|
47
|
+
//
|
|
43
48
|
// Two layouts. `column` (the default): every section in one column capped
|
|
44
49
|
// at max-w-4xl. `rail`, for a record with long content: the page takes the
|
|
45
50
|
// content area's whole width, sections placed `rail` go in a narrow column
|
|
@@ -60,6 +65,7 @@ import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
|
60
65
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
61
66
|
import { Skeleton } from '../ui/skeleton.js';
|
|
62
67
|
import { useDocumentTitle } from '../../hooks/use-document-title.js';
|
|
68
|
+
import { PrintNotes } from './entity-view-print.js';
|
|
63
69
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
64
70
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
65
71
|
import { EditableList } from './editable-list.js';
|
|
@@ -153,7 +159,7 @@ function Rail({ label, sections }) {
|
|
|
153
159
|
const [ref, fits] = useFitsWindow();
|
|
154
160
|
return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
|
|
155
161
|
}
|
|
156
|
-
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, isLoading, error, onRetry, back, }) {
|
|
162
|
+
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, printLabel = 'Print notes', isLoading, error, onRetry, back, }) {
|
|
157
163
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
158
164
|
// while a field is open (or one is always editable).
|
|
159
165
|
const [store] = useState(() => new EditInPlaceStore());
|
|
@@ -171,8 +177,16 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
|
|
|
171
177
|
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
172
178
|
if (isLoading)
|
|
173
179
|
return _jsx(EntityViewSkeleton, { layout: layout, mobileOrder: mobileOrder });
|
|
180
|
+
const printSections = sections
|
|
181
|
+
.filter((section) => section.print)
|
|
182
|
+
.map((section) => ({
|
|
183
|
+
title: section.title,
|
|
184
|
+
...('fields' in section ? { fields: section.fields } : { content: section.content, emptyLabel: section.emptyLabel }),
|
|
185
|
+
answerSpace: typeof section.print === 'object' && section.print.answerSpace === true,
|
|
186
|
+
}));
|
|
187
|
+
const printButton = printSections.length > 0 && (_jsx(PrintNotes, { label: printLabel, title: title, subtitle: subtitle, sections: printSections }));
|
|
174
188
|
const railSections = sections.filter((section) => section.placement === 'rail');
|
|
175
189
|
const mainSections = sections.filter((section) => section.placement !== 'rail');
|
|
176
190
|
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))) }));
|
|
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 &&
|
|
191
|
+
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 || printButton) && (_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: [printButton, 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 }))] }) }));
|
|
178
192
|
}
|
|
@@ -46,4 +46,4 @@ export type MarkdownProps = {
|
|
|
46
46
|
onTasksChange?: (markdown: string) => void | Promise<unknown>;
|
|
47
47
|
className?: string;
|
|
48
48
|
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
49
|
-
export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
49
|
+
export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange: onTasksChangeProp, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -45,6 +45,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
45
45
|
// this device under the key and shared with an editor or NotesOutline
|
|
46
46
|
// given the same one. The text itself is never changed.
|
|
47
47
|
//
|
|
48
|
+
// Printed (EntityView's Print notes, PrintingContext), it's the whole
|
|
49
|
+
// text: nothing folded, no fold buttons, task boxes as glyphs.
|
|
50
|
+
//
|
|
48
51
|
// A task item (`- [ ] to do`, `- [x] done`) shows its box as a glyph
|
|
49
52
|
// with its state in words: read-only text has nothing to tick. With
|
|
50
53
|
// `onTasksChange`, each box is a real checkbox, named for its item's
|
|
@@ -65,6 +68,7 @@ import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight
|
|
|
65
68
|
import { toggleTaskAt } from '../../lib/markdown-tasks.js';
|
|
66
69
|
import { textTopHeading } from '../../lib/markdown-top-heading.js';
|
|
67
70
|
import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
|
|
71
|
+
import { usePrinting } from './print-context.js';
|
|
68
72
|
import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
|
|
69
73
|
const REMARK_PLUGINS = [remarkGfm, remarkCallouts, remarkHighlights];
|
|
70
74
|
const HIGHLIGHT_WASH = {
|
|
@@ -232,8 +236,12 @@ function componentsFor(top, textTop) {
|
|
|
232
236
|
}
|
|
233
237
|
return components;
|
|
234
238
|
}
|
|
235
|
-
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange, className, ...rest }) {
|
|
236
|
-
|
|
239
|
+
export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange: onTasksChangeProp, className, ...rest }) {
|
|
240
|
+
// On paper (EntityView's Print notes): every heading plain and open,
|
|
241
|
+
// every task box a picture.
|
|
242
|
+
const printing = usePrinting();
|
|
243
|
+
const onTasksChange = printing ? undefined : onTasksChangeProp;
|
|
244
|
+
const foldKey = foldable && !printing ? foldKeyProp : undefined;
|
|
237
245
|
const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
|
|
238
246
|
const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
|
|
239
247
|
const [folded, setFolded] = useFolds(foldKey);
|
|
@@ -814,8 +814,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
814
814
|
void editor.destroy();
|
|
815
815
|
root.replaceChildren();
|
|
816
816
|
};
|
|
817
|
-
// Made once per mount
|
|
818
|
-
//
|
|
817
|
+
// Made once per mount. A new defaultValue the editor didn't report
|
|
818
|
+
// remounts it (RichTextEditor's key), so it isn't read here.
|
|
819
|
+
// Attributes and read-only follow below.
|
|
819
820
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
820
821
|
}, []);
|
|
821
822
|
// Read-only and the ARIA attributes follow their props.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Whether what's rendering is a printed copy (EntityView's Print notes,
|
|
2
|
+
// entity-view-print.tsx). Content that has a screen-only side reads it:
|
|
3
|
+
// <Markdown> keeps its headings plain and unfolded and its task boxes as
|
|
4
|
+
// pictures, since paper has nothing to click.
|
|
5
|
+
import { createContext, useContext } from 'react';
|
|
6
|
+
export const PrintingContext = createContext(false);
|
|
7
|
+
/** True inside a printed copy of a record's notes. */
|
|
8
|
+
export function usePrinting() {
|
|
9
|
+
return useContext(PrintingContext);
|
|
10
|
+
}
|
|
@@ -3,7 +3,11 @@ import type { HeadingFrom, HeadingLevel } from './prose-look.js';
|
|
|
3
3
|
export type RichTextEditorProps = {
|
|
4
4
|
/** The editable element's id, for a label's `htmlFor` or a test. */
|
|
5
5
|
id?: string;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The Markdown it opens with. A later value the editor didn't report
|
|
8
|
+
* (a record loaded after it mounted, a reset) replaces the text; the
|
|
9
|
+
* value it reported, passed back, changes nothing.
|
|
10
|
+
*/
|
|
7
11
|
defaultValue: string;
|
|
8
12
|
/** The Markdown to save, after every edit: untouched blocks as originally written. */
|
|
9
13
|
onChange: (markdown: string) => void;
|
|
@@ -26,13 +26,41 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
26
26
|
// With `foldKey`, the text folds by its headings (NotesOutline lists them
|
|
27
27
|
// beside it), the Markdown untouched.
|
|
28
28
|
//
|
|
29
|
+
// `defaultValue` is read when it mounts, then followed: a new value the
|
|
30
|
+
// editor didn't report itself (a form's loaded record arriving after the
|
|
31
|
+
// editor, a reset) replaces the text, as if it opened with it. Its own
|
|
32
|
+
// edits, coming back as the value, change nothing, so the caret stays
|
|
33
|
+
// where it is. A caller that never feeds `onChange` back keeps its text.
|
|
34
|
+
//
|
|
29
35
|
// The editor (Milkdown) is loaded on first use, so a page that only reads
|
|
30
36
|
// Markdown never downloads it. Until it arrives, a placeholder the size of
|
|
31
37
|
// the field shows.
|
|
32
|
-
import { lazy, Suspense } from 'react';
|
|
38
|
+
import { lazy, Suspense, useState } from 'react';
|
|
33
39
|
import { cn } from 'cn';
|
|
34
40
|
import { Skeleton } from '../ui/skeleton.js';
|
|
35
41
|
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
42
|
+
// When the editor opens again: each time `defaultValue` changes to
|
|
43
|
+
// Markdown it didn't report. Adjusted while rendering (React's "storing
|
|
44
|
+
// information from previous renders"), so the new text never shows a
|
|
45
|
+
// frame late.
|
|
46
|
+
function useOpenings(defaultValue) {
|
|
47
|
+
const [seen, setSeen] = useState(defaultValue);
|
|
48
|
+
const [reported, setReported] = useState(defaultValue);
|
|
49
|
+
const [opening, setOpening] = useState(0);
|
|
50
|
+
if (defaultValue !== seen) {
|
|
51
|
+
setSeen(defaultValue);
|
|
52
|
+
if (defaultValue !== reported) {
|
|
53
|
+
setReported(defaultValue);
|
|
54
|
+
setOpening(opening + 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { opening, report: setReported };
|
|
58
|
+
}
|
|
36
59
|
export function RichTextEditor(props) {
|
|
37
|
-
|
|
60
|
+
const { opening, report } = useOpenings(props.defaultValue);
|
|
61
|
+
const { onChange } = props;
|
|
62
|
+
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, onChange: (markdown) => {
|
|
63
|
+
report(markdown);
|
|
64
|
+
onChange(markdown);
|
|
65
|
+
}, foldKey: props.foldable === false ? undefined : props.foldKey }, opening) }));
|
|
38
66
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ 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 { usePrinting } from './components/app/print-context.js';
|
|
16
17
|
export { RecordDialogRoute, RecordLink, type RecordDialogRouteProps, type RecordLinkProps, } from './components/app/record-dialog.js';
|
|
17
18
|
export { recordDialogState, useOpenRecord, useRecordDialog, type RecordDialogBackground, type RecordDialogContextValue, type RecordDialogState, } from './components/app/record-dialog-context.js';
|
|
18
19
|
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';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ 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 { usePrinting } from './components/app/print-context.js';
|
|
20
21
|
export { RecordDialogRoute, RecordLink, } from './components/app/record-dialog.js';
|
|
21
22
|
export { recordDialogState, useOpenRecord, useRecordDialog, } from './components/app/record-dialog-context.js';
|
|
22
23
|
export { editInPlace, } from './components/app/edit-in-place.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.61.1",
|
|
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": {
|
package/styles/index.css
CHANGED
|
@@ -158,3 +158,84 @@
|
|
|
158
158
|
@apply font-sans;
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/* Printed notes (EntityView's Print notes, src/components/app/entity-view-print.tsx).
|
|
163
|
+
While its copy is in <body>, paper shows that copy alone: the app beside
|
|
164
|
+
it (shell, header, rail, dialogs) is left off, and the copy is in the
|
|
165
|
+
light theme's colours even when the screen is dark. Its buttons (edit
|
|
166
|
+
pencils, move and remove) are left off too: paper has nothing to press.
|
|
167
|
+
Tables are ruled all round, never cut off at the scroll box's edge, and
|
|
168
|
+
their cells tall enough to write in; a heading stays with what follows
|
|
169
|
+
it; a section marked answerSpace leaves room under each numbered item. */
|
|
170
|
+
@media print {
|
|
171
|
+
body:has(> [data-print-notes]) > :not([data-print-notes]) {
|
|
172
|
+
display: none !important;
|
|
173
|
+
}
|
|
174
|
+
:root:has(> body > [data-print-notes]) {
|
|
175
|
+
--background: var(--white);
|
|
176
|
+
--foreground: var(--black);
|
|
177
|
+
--card: var(--white);
|
|
178
|
+
--card-foreground: var(--black);
|
|
179
|
+
--popover: var(--white);
|
|
180
|
+
--popover-foreground: var(--black);
|
|
181
|
+
--primary: var(--gray-950);
|
|
182
|
+
--primary-foreground: var(--gray-50);
|
|
183
|
+
--secondary: var(--gray-100);
|
|
184
|
+
--secondary-foreground: var(--gray-950);
|
|
185
|
+
--muted: var(--gray-100);
|
|
186
|
+
--muted-foreground: var(--gray-500);
|
|
187
|
+
--accent: var(--gray-100);
|
|
188
|
+
--accent-foreground: var(--gray-950);
|
|
189
|
+
--destructive-text: var(--red-700);
|
|
190
|
+
--success-text: var(--green-800);
|
|
191
|
+
--warning-text: var(--amber-800);
|
|
192
|
+
--info-text: var(--blue-800);
|
|
193
|
+
--link: var(--blue-700);
|
|
194
|
+
--highlight-amber: oklch(0.9 0.1 75);
|
|
195
|
+
--highlight-yellow: oklch(0.94 0.13 100);
|
|
196
|
+
--highlight-green: oklch(0.9 0.1 150);
|
|
197
|
+
--highlight-red: oklch(0.89 0.07 25);
|
|
198
|
+
--rule: var(--gray-400);
|
|
199
|
+
--heading-band: var(--gray-200);
|
|
200
|
+
--heading-bar: var(--blue-700);
|
|
201
|
+
--table-stripe: var(--white);
|
|
202
|
+
--border: var(--gray-400);
|
|
203
|
+
--input: var(--gray-200);
|
|
204
|
+
color-scheme: light;
|
|
205
|
+
background: var(--white);
|
|
206
|
+
}
|
|
207
|
+
[data-print-notes] button {
|
|
208
|
+
display: none !important;
|
|
209
|
+
}
|
|
210
|
+
[data-print-notes] :is(h1, h2, h3, h4, h5, h6) {
|
|
211
|
+
break-after: avoid;
|
|
212
|
+
}
|
|
213
|
+
[data-print-notes] [data-slot="table-container"] {
|
|
214
|
+
/* Not w-full: the text's inset margins would push its right edge
|
|
215
|
+
off the paper. */
|
|
216
|
+
width: auto;
|
|
217
|
+
overflow: visible;
|
|
218
|
+
border: 0;
|
|
219
|
+
border-radius: 0;
|
|
220
|
+
}
|
|
221
|
+
[data-print-notes] table {
|
|
222
|
+
border-collapse: collapse;
|
|
223
|
+
}
|
|
224
|
+
[data-print-notes] tr {
|
|
225
|
+
break-inside: avoid;
|
|
226
|
+
}
|
|
227
|
+
[data-print-notes] :is(th, td) {
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
background: var(--white);
|
|
230
|
+
}
|
|
231
|
+
[data-print-notes] td {
|
|
232
|
+
height: 2.5rem;
|
|
233
|
+
}
|
|
234
|
+
[data-print-notes] [data-answer-space] ol > li {
|
|
235
|
+
padding-bottom: 5rem;
|
|
236
|
+
break-inside: avoid;
|
|
237
|
+
}
|
|
238
|
+
[data-print-notes] [data-answer-space] li ol > li {
|
|
239
|
+
padding-bottom: 0;
|
|
240
|
+
}
|
|
241
|
+
}
|