@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 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–731
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–620
14
- - Step 7: Register routes · lines 621–634
15
- - Step 8: Add Playwright specs for all three screens · lines 635–713
16
- - Step 9: Register the nav entry and route names · lines 714–728
17
- - Step 10: `npm run verify`. · lines 729–731
18
- - [Opening a record over its list](#opening-a-record-over-its-list) · lines 733–789
19
- - [What not to copy](#what-not-to-copy) · lines 791–817
20
- - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 819–836
21
- - [Deleting](#deleting) · lines 838–867
22
- - [Fields only a script writes](#fields-only-a-script-writes) · lines 869–890
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 && _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 }))] }) }));
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
- const foldKey = foldable ? foldKeyProp : undefined;
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: later changes to defaultValue are ignored, as
818
- // with any uncontrolled input. Attributes and read-only follow below.
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,3 @@
1
+ export declare const PrintingContext: import("react").Context<boolean>;
2
+ /** True inside a printed copy of a record's notes. */
3
+ export declare function usePrinting(): boolean;
@@ -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
- /** The Markdown it opens with. Read once, when it mounts (uncontrolled). */
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
- 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 }) }));
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.60.0",
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
+ }