@tristan2828/ui-foundation 4.59.0 → 4.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission`. 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–724
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–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
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
 
@@ -488,6 +488,25 @@ is a filled-in example while the Widgets demo is still here).
488
488
  `headingLevel={2}` for a `RichTextEditor` on a form straight under
489
489
  the page's `<h1>`. Text that's imported or pasted from another
490
490
  tool (often starting at `##`) also gets `headingFrom="text"`.
491
+ - **A checklist kept in the text** (`- [ ] item`, `- [x] done`) is
492
+ ticked where it's read with `<Markdown onTasksChange={save}>`: each
493
+ box a checkbox named for its item, and a tick hands over the whole
494
+ text with only that box flipped, for the field's save
495
+ (`useEdit<Entity>Field`). Without the prop the boxes are pictures
496
+ and the text stays read-only. A list's "2/5" column counts them
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.
491
510
  - **Quick actions: only the fields the plan's `Quick actions` line
492
511
  names**, each a control that saves on its own (cell pattern 17,
493
512
  `widget-quick-actions.tsx`). A `yes/no` is a
@@ -26,15 +26,15 @@
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 565–648
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 650–685
28
28
  - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 687–760
29
- - [18. Editable value](#18-editable-value) · lines 762–1218
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1034–1119
31
- - [In a table's rows](#in-a-tables-rows) · lines 1121–1206
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1208–1218
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1220–1286
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1288–1352
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1354–1422
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–1530
29
+ - [18. Editable value](#18-editable-value) · lines 762–1221
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1037–1122
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1124–1209
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1211–1221
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1223–1289
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1291–1355
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1357–1425
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1427–1455
37
+ - [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines 1457–1533
38
38
 
39
39
  <!-- /index -->
40
40
 
@@ -1013,7 +1013,10 @@ const price = editInPlace({
1013
1013
  and Outdent for list items, where there is no Tab. A field with only the
1014
1014
  floating toolbar (`inline` notes) gets the same two in a small "Editor
1015
1015
  tools" strip over its corner while it has focus. A new checklist item
1016
- shows a "To-do" placeholder and the caret until you type.
1016
+ shows a "To-do" placeholder and the caret until you type. `[] ` (or
1017
+ `[x] `) at a line's start makes it a checklist item, a bulleted item
1018
+ too; Enter in one makes the next, its box unticked, and Enter in an
1019
+ empty one ends the list, as in a bulleted list.
1017
1020
  `toolbar={false}` leaves out the toolbars and the slash menu where a
1018
1021
  value is short and the shortcuts are enough; `toolbar="floating"` keeps
1019
1022
  the floating one and the slash menu without the bar above (an `inline`
@@ -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
  }
@@ -33,6 +33,17 @@ export type MarkdownProps = {
33
33
  * what was folded is remembered for when it's on again. Default `true`.
34
34
  */
35
35
  foldable?: boolean;
36
+ /**
37
+ * Lets the text's task items be ticked where they're read: each
38
+ * `- [ ]` and `- [x]` box becomes a checkbox, named for its item, and
39
+ * ticking one calls this with the whole text, that item's box flipped
40
+ * and nothing else changed, for the app to save. The tick shows at
41
+ * once and a second one builds on it, until `children` changes (pass
42
+ * the saved text back in). Return the save's promise: if it rejects,
43
+ * the boxes go back to `children`. Without it (the default) the boxes
44
+ * are pictures and the text stays read-only.
45
+ */
46
+ onTasksChange?: (markdown: string) => void | Promise<unknown>;
36
47
  className?: string;
37
48
  } & Omit<ComponentProps<'div'>, 'children'>;
38
- export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, 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;
@@ -44,18 +44,31 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
44
44
  // it that folds its section away (src/lib/markdown-fold.ts), remembered on
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
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
47
+ //
48
+ // Printed (EntityView's Print notes, PrintingContext), it's the whole
49
+ // text: nothing folded, no fold buttons, task boxes as glyphs.
50
+ //
51
+ // A task item (`- [ ] to do`, `- [x] done`) shows its box as a glyph
52
+ // with its state in words: read-only text has nothing to tick. With
53
+ // `onTasksChange`, each box is a real checkbox, named for its item's
54
+ // words, that ticks or unticks the item in the text and hands the whole
55
+ // new text over to save (src/lib/markdown-tasks.ts): only that item's
56
+ // `[ ]` or `[x]` changes, the rest stays exactly as written.
57
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
48
58
  import ReactMarkdown, {} from 'react-markdown';
49
59
  import remarkGfm from 'remark-gfm';
50
60
  import { cn } from 'cn';
51
61
  import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
62
+ import { Checkbox } from '../ui/checkbox.js';
52
63
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
53
64
  import { useFolds } from '../../hooks/fold-store.js';
54
65
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
55
66
  import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
56
67
  import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
68
+ import { toggleTaskAt } from '../../lib/markdown-tasks.js';
57
69
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
58
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';
59
72
  import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
60
73
  const REMARK_PLUGINS = [remarkGfm, remarkCallouts, remarkHighlights];
61
74
  const HIGHLIGHT_WASH = {
@@ -92,6 +105,34 @@ function NotesTable({ hidden, ...props }) {
92
105
  // Folded away, the box goes with the table.
93
106
  containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
94
107
  }
108
+ // Ticks a task item by where it starts in the text: given only with
109
+ // `onTasksChange`. Each task item hands its box its own start and words.
110
+ const TaskToggleContext = createContext(null);
111
+ const TaskItemContext = createContext(null);
112
+ // An item's own words, for its checkbox's name: its nested lists left
113
+ // out, so a parent is named for itself and not its children too.
114
+ function itemText(node) {
115
+ if (!node)
116
+ return '';
117
+ if (node.type === 'text')
118
+ return node.value ?? '';
119
+ if (node.tagName === 'ul' || node.tagName === 'ol')
120
+ return '';
121
+ return (node.children ?? []).map(itemText).join('');
122
+ }
123
+ // A task item's box: a real checkbox when ticking is on (named for its
124
+ // item, so a screen reader hears "Buy milk, checkbox, checked"), the
125
+ // glyph otherwise.
126
+ function TaskBox({ checked }) {
127
+ const toggle = useContext(TaskToggleContext);
128
+ const item = useContext(TaskItemContext);
129
+ if (toggle && item)
130
+ return (_jsx(Checkbox, { checked: checked, onCheckedChange: () => toggle(item.offset),
131
+ // Ticking is all it does: a click never reaches whatever the text
132
+ // sits in (a card that opens the record, a field that edits).
133
+ onClick: (event) => event.stopPropagation(), "aria-label": item.name || (checked ? 'Done' : 'Not done'), "data-slot": "task-box", className: "mr-2 inline-flex align-[-0.1875rem]" }));
134
+ return checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-muted-foreground" }));
135
+ }
95
136
  // Folds a section by its heading's id: given only with `foldKey`.
96
137
  const FoldContext = createContext(null);
97
138
  // A heading by its Markdown level: the element the page's outline gives
@@ -129,11 +170,22 @@ const COMPONENTS = {
129
170
  ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
130
171
  li: (props) => {
131
172
  const { className, ...rest } = omitNode(props);
132
- return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && '-ml-6 list-none') });
173
+ const task = className?.includes('task-list-item');
174
+ // A task item's box hangs where a bullet would; a list under it starts
175
+ // at its words, and so does a loose item's next paragraph.
176
+ const item = (_jsx("li", { ...rest, className: cn(task && '-ml-6 list-none [&>ol]:pl-12 [&>p:not(:first-child)]:pl-6 [&>ul]:pl-12') }));
177
+ const node = props.node;
178
+ const offset = node?.position?.start.offset;
179
+ if (!task || offset === undefined)
180
+ return item;
181
+ // A loose item's words are its first paragraph; what's under it isn't its name.
182
+ const first = node?.children?.find((child) => child.tagName === 'p');
183
+ const name = itemText(first ?? node).replace(/\s+/g, ' ').trim();
184
+ return _jsx(TaskItemContext.Provider, { value: { offset, name }, children: item });
133
185
  },
134
- // A GitHub task-list box (`- [x] done`). Read-only, so a glyph with its
135
- // state in words, never a checkbox someone will try to tick.
136
- input: ({ checked }) => checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mr-2 inline-block size-4 align-[-0.1875rem] text-muted-foreground" })),
186
+ // A GitHub task-list box (`- [x] done`): a glyph, or a checkbox with
187
+ // `onTasksChange` (TaskBox).
188
+ input: ({ checked }) => _jsx(TaskBox, { checked: Boolean(checked) }),
137
189
  blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
138
190
  hr: ({ hidden }) => _jsx("hr", { hidden: hidden, className: PROSE.rule }),
139
191
  // Highlighted words (remarkHighlights marks them): the colour's wash.
@@ -184,12 +236,30 @@ function componentsFor(top, textTop) {
184
236
  }
185
237
  return components;
186
238
  }
187
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, className, ...rest }) {
188
- 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;
189
245
  const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
190
246
  const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
191
247
  const [folded, setFolded] = useFolds(foldKey);
192
248
  const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
193
249
  const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
194
- return (_jsx("div", { "data-slot": "markdown", "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: children }) }) }));
250
+ // Ticks not yet back as `children`: shown, and built on by the next
251
+ // tick, so two quick ticks on a slow save both land. Kept while
252
+ // `children` is the text they started from or one of their own saves
253
+ // coming back; dropped when it's anything else, or a save fails.
254
+ const [pending, setPending] = useState(null);
255
+ const live = pending && (pending.base === children || pending.sent.includes(children)) ? pending : null;
256
+ const shown = live ? live.text : children;
257
+ const tickTask = useCallback((offset) => {
258
+ const next = toggleTaskAt(shown, offset);
259
+ setPending({ base: live ? live.base : children, text: next, sent: [...(live ? live.sent : []), next] });
260
+ const saving = onTasksChange?.(next);
261
+ if (saving && typeof saving.then === 'function')
262
+ saving.then(undefined, () => setPending((current) => (current?.sent.includes(next) ? null : current)));
263
+ }, [onTasksChange, children, shown, live]);
264
+ return (_jsx("div", { "data-slot": "markdown", "data-fold-scope": foldKey, ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', PROSE_INSET, className), children: _jsx(FoldContext.Provider, { value: foldKey === undefined ? null : toggle, children: _jsx(TaskToggleContext.Provider, { value: onTasksChange ? tickTask : null, children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, rehypePlugins: rehypePlugins, components: components, children: onTasksChange ? shown : children }) }) }) }));
195
265
  }
@@ -30,9 +30,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
30
30
  // copy out of the editor is Markdown.
31
31
  // - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
32
32
  // addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
33
- // in. Ctrl/Cmd+Enter stays the field's save, in a table too. Notion's
34
- // block keys (Ctrl+Shift+1 for a heading, +5 for bullets, …:
35
- // rich-text-shortcuts.ts).
33
+ // in. `[] ` at a line's start makes a task item, and Enter in one the
34
+ // next, unticked (rich-text-tasks.ts). Ctrl/Cmd+Enter stays the field's
35
+ // save, in a table too. Notion's block keys (Ctrl+Shift+1 for a
36
+ // heading, +5 for bullets, …: rich-text-shortcuts.ts).
36
37
  // - A placeholder in an empty document, and a count of the Markdown's
37
38
  // length as it nears `maxLength`.
38
39
  // - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
@@ -84,6 +85,7 @@ import { tableAt, toggleFormat } from './rich-text-formats.js';
84
85
  import { calloutKind, calloutView, tableView } from './rich-text-node-views.js';
85
86
  import { droppedFiles, imageUploadPlugin, pastedImages, uploadImages, uploadPlaceholders } from './rich-text-image-upload.js';
86
87
  import { handleShortcut } from './rich-text-shortcuts.js';
88
+ import { splitTaskItem, taskInputRule } from './rich-text-tasks.js';
87
89
  import { addRowAtEnd, handleTableKey, runTableAction } from './rich-text-table.js';
88
90
  import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
89
91
  import { RichTextSlashMenu } from './rich-text-slash-menu.js';
@@ -751,6 +753,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
751
753
  return true;
752
754
  if (handleTableKey(view.state, view.dispatch, event))
753
755
  return true;
756
+ // Enter in a task item: the next one, unticked (rich-text-tasks.ts).
757
+ if (event.key === 'Enter' && plain && !event.isComposing && splitTaskItem(view.state, view.dispatch))
758
+ return true;
754
759
  // Ctrl/Cmd+Enter in a table is the field's (a save), not
755
760
  // Milkdown's "leave the table": taken here, so its keymap never
756
761
  // sees it, and left to reach the field.
@@ -768,6 +773,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
768
773
  })
769
774
  .use(COMMONMARK)
770
775
  .use(gfm)
776
+ .use(taskInputRule)
771
777
  .use(calloutRemark)
772
778
  .use(calloutSchema)
773
779
  .use(highlightRemark)
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
2
+ export declare const taskInputRule: import("@milkdown/utils").$InputRule;
3
+ /**
4
+ * Enter in a task item with words in it: the item split there, and the
5
+ * new one's box unticked. False anywhere else (an empty task item
6
+ * included), for the editor's own Enter.
7
+ */
8
+ export declare function splitTaskItem(state: EditorState, dispatch: (tr: Transaction) => void): boolean;
@@ -0,0 +1,72 @@
1
+ // Task lists in the rich-text editor, typed rather than picked:
2
+ // - `[] ` (or `[ ] `, `[x] `) at a line's start makes it a task item: a
3
+ // plain line becomes a new task list, a list item becomes a task item.
4
+ // GFM's own rule only knows `[ ] ` already inside a list item.
5
+ // - Enter in a task item makes the next one, its box unticked, whatever
6
+ // the item it came from. (ProseMirror's split copies the item, ticked
7
+ // box and all.) Enter in an empty one ends the list, as in a bulleted
8
+ // one: that's ProseMirror's own, left as it is.
9
+ import { InputRule } from '@milkdown/kit/prose/inputrules';
10
+ import { findWrapping } from '@milkdown/kit/prose/transform';
11
+ import { splitListItem } from '@milkdown/kit/prose/schema-list';
12
+ import { $inputRule } from '@milkdown/kit/utils';
13
+ const TASK_START = /^\[([ xX]?)\]\s$/;
14
+ export const taskInputRule = $inputRule(() => new InputRule(TASK_START, (state, match, start, end) => {
15
+ const { list_item: item, bullet_list: list } = state.schema.nodes;
16
+ const $start = state.doc.resolve(start);
17
+ const paragraph = $start.parent;
18
+ if (paragraph.type.name !== 'paragraph')
19
+ return null;
20
+ const checked = match[1].toLowerCase() === 'x';
21
+ // A list item's first line: the item becomes a task item.
22
+ if ($start.depth >= 2 && $start.node(-1).type === item && $start.index(-1) === 0) {
23
+ const node = $start.node(-1);
24
+ if (node.attrs.checked != null)
25
+ return null;
26
+ return state.tr.delete(start, end).setNodeMarkup($start.before(-1), undefined, { ...node.attrs, checked });
27
+ }
28
+ // A plain line: wrapped in a new list, its item a task item.
29
+ const tr = state.tr.delete(start, end);
30
+ const range = tr.doc.resolve(start).blockRange();
31
+ const wrapping = range && findWrapping(range, list);
32
+ if (!range || !wrapping)
33
+ return null;
34
+ tr.wrap(range, wrapping);
35
+ const itemPos = range.start + 1;
36
+ const made = tr.doc.nodeAt(itemPos);
37
+ if (made?.type !== item)
38
+ return null;
39
+ return tr.setNodeMarkup(itemPos, undefined, { ...made.attrs, checked });
40
+ }));
41
+ /**
42
+ * Enter in a task item with words in it: the item split there, and the
43
+ * new one's box unticked. False anywhere else (an empty task item
44
+ * included), for the editor's own Enter.
45
+ */
46
+ export function splitTaskItem(state, dispatch) {
47
+ const { $from, empty } = state.selection;
48
+ if (!empty || $from.depth < 2 || $from.parent.type.name !== 'paragraph')
49
+ return false;
50
+ const node = $from.node(-1);
51
+ if (node.type.name !== 'list_item' || node.attrs.checked == null)
52
+ return false;
53
+ if ($from.parent.content.size === 0)
54
+ return false;
55
+ let split = null;
56
+ if (!splitListItem(node.type)(state, (tr) => (split = tr)))
57
+ return false;
58
+ const tr = split;
59
+ if (!tr)
60
+ return false;
61
+ const $to = tr.selection.$from;
62
+ for (let depth = $to.depth; depth > 0; depth--) {
63
+ const made = $to.node(depth);
64
+ if (made.type.name !== 'list_item')
65
+ continue;
66
+ if (made.attrs.checked)
67
+ tr.setNodeMarkup($to.before(depth), undefined, { ...made.attrs, checked: false });
68
+ break;
69
+ }
70
+ dispatch(tr.scrollIntoView());
71
+ return true;
72
+ }
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';
@@ -22,6 +23,7 @@ export { ErrorState } from './components/app/error-state.js';
22
23
  export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
23
24
  export { FoundationProviders } from './components/app/foundation-providers.js';
24
25
  export { Markdown, type MarkdownProps } from './components/app/markdown.js';
26
+ export { countTasks, toggleTaskAt, type TaskCount } from './lib/markdown-tasks.js';
25
27
  export { NotesOutline, type NotesOutlineProps } from './components/app/notes-outline.js';
26
28
  export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
27
29
  export { MockModeBanner } from './components/app/mock-mode-banner.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';
@@ -26,6 +27,7 @@ export { ErrorState } from './components/app/error-state.js';
26
27
  export { InlineCreate } from './components/app/inline-create.js';
27
28
  export { FoundationProviders } from './components/app/foundation-providers.js';
28
29
  export { Markdown } from './components/app/markdown.js';
30
+ export { countTasks, toggleTaskAt } from './lib/markdown-tasks.js';
29
31
  export { NotesOutline } from './components/app/notes-outline.js';
30
32
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
31
33
  export { MultiChoice } from './components/app/multi-choice.js';
@@ -0,0 +1,19 @@
1
+ export type TaskCount = {
2
+ /** Task items ticked (`- [x]`). */
3
+ done: number;
4
+ /** Every task item, ticked or not, nested ones included. */
5
+ total: number;
6
+ };
7
+ /**
8
+ * How many task items the text has, and how many are ticked: nested
9
+ * items count, plain list items don't. `{ done: 0, total: 0 }` for text
10
+ * with none (or none at all), so a column can show nothing.
11
+ */
12
+ export declare function countTasks(markdown: string | null | undefined): TaskCount;
13
+ /**
14
+ * The text with the task item that starts at `offset` (its list marker's
15
+ * place in the text, as the parser gives it) ticked when it wasn't and
16
+ * unticked when it was. Everything else is left exactly as written. The
17
+ * text unchanged when no task item starts there.
18
+ */
19
+ export declare function toggleTaskAt(markdown: string, offset: number): string;
@@ -0,0 +1,48 @@
1
+ // A Markdown text's task items (GitHub's `- [ ] to do`, `- [x] done`):
2
+ // counted, for a table's "2/5" column (countTasks), and one ticked or
3
+ // unticked in the source, for <Markdown>'s `onTasksChange` (toggleTaskAt).
4
+ // Parsed as <Markdown> and the rich-text editor parse it (remark, GFM), so
5
+ // a `- [ ]` inside a code block, or a `[ ]` that isn't at an item's start,
6
+ // is never a task.
7
+ import remarkGfm from 'remark-gfm';
8
+ import remarkParse from 'remark-parse';
9
+ import { unified } from 'unified';
10
+ const parser = unified().use(remarkParse).use(remarkGfm);
11
+ /**
12
+ * How many task items the text has, and how many are ticked: nested
13
+ * items count, plain list items don't. `{ done: 0, total: 0 }` for text
14
+ * with none (or none at all), so a column can show nothing.
15
+ */
16
+ export function countTasks(markdown) {
17
+ const count = { done: 0, total: 0 };
18
+ if (!markdown)
19
+ return count;
20
+ const visit = (node) => {
21
+ if (node.type === 'listItem' && typeof node.checked === 'boolean') {
22
+ count.total += 1;
23
+ if (node.checked)
24
+ count.done += 1;
25
+ }
26
+ for (const child of node.children ?? [])
27
+ visit(child);
28
+ };
29
+ visit(parser.parse(markdown));
30
+ return count;
31
+ }
32
+ // An item's marker and its box, from where the item starts: `- [ ]`,
33
+ // `1. [x]`, `* [X]`.
34
+ const TASK_MARKER = /^([*+-]|\d{1,9}[.)])([ \t]+)\[([ xX])\]/;
35
+ /**
36
+ * The text with the task item that starts at `offset` (its list marker's
37
+ * place in the text, as the parser gives it) ticked when it wasn't and
38
+ * unticked when it was. Everything else is left exactly as written. The
39
+ * text unchanged when no task item starts there.
40
+ */
41
+ export function toggleTaskAt(markdown, offset) {
42
+ const match = TASK_MARKER.exec(markdown.slice(offset));
43
+ if (!match)
44
+ return markdown;
45
+ const box = offset + match[1].length + match[2].length + 1;
46
+ const next = match[3] === ' ' ? 'x' : ' ';
47
+ return markdown.slice(0, box) + next + markdown.slice(box + 1);
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.59.0",
3
+ "version": "4.61.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": {
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
+ }