@tristan2828/ui-foundation 4.58.0 → 4.60.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`), `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–731
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–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
23
23
 
24
24
  <!-- /index -->
25
25
 
@@ -488,6 +488,13 @@ 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).
491
498
  - **Quick actions: only the fields the plan's `Quick actions` line
492
499
  names**, each a control that saves on its own (cell pattern 17,
493
500
  `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`
@@ -26,6 +26,24 @@ export type MarkdownProps = {
26
26
  * text is never changed.
27
27
  */
28
28
  foldKey?: string;
29
+ /**
30
+ * Set `false` to keep headings plain, with no fold buttons, even when a
31
+ * `foldKey` is given: a page-level switch (a print view, a short note)
32
+ * that leaves the key in place. Nothing is folded while it's off, and
33
+ * what was folded is remembered for when it's on again. Default `true`.
34
+ */
35
+ foldable?: boolean;
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>;
29
47
  className?: string;
30
48
  } & Omit<ComponentProps<'div'>, 'children'>;
31
- export declare function Markdown({ children, headingLevel, headingFrom, foldKey, className, ...rest }: MarkdownProps): import("react").JSX.Element;
49
+ export declare function Markdown({ children, headingLevel, headingFrom, foldKey: foldKeyProp, foldable, onTasksChange, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -44,16 +44,25 @@ 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
+ // A task item (`- [ ] to do`, `- [x] done`) shows its box as a glyph
49
+ // with its state in words: read-only text has nothing to tick. With
50
+ // `onTasksChange`, each box is a real checkbox, named for its item's
51
+ // words, that ticks or unticks the item in the text and hands the whole
52
+ // new text over to save (src/lib/markdown-tasks.ts): only that item's
53
+ // `[ ]` or `[x]` changes, the rest stays exactly as written.
54
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
48
55
  import ReactMarkdown, {} from 'react-markdown';
49
56
  import remarkGfm from 'remark-gfm';
50
57
  import { cn } from 'cn';
51
58
  import { ChevronDownIcon, ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
59
+ import { Checkbox } from '../ui/checkbox.js';
52
60
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
53
61
  import { useFolds } from '../../hooks/fold-store.js';
54
62
  import { CALLOUT_KINDS, remarkCallouts } from '../../lib/markdown-callout.js';
55
63
  import { rehypeFold, toggleFold } from '../../lib/markdown-fold.js';
56
64
  import { isHighlightColor, remarkHighlights } from '../../lib/markdown-highlight.js';
65
+ import { toggleTaskAt } from '../../lib/markdown-tasks.js';
57
66
  import { textTopHeading } from '../../lib/markdown-top-heading.js';
58
67
  import { CALLOUT_BOX, CALLOUT_LOOK, DEFAULT_HEADING_LEVEL, FOLD_BUTTON, FOLD_HEADING, headingBand, headingClass, headingTag, PROSE, PROSE_INSET, } from './prose-look.js';
59
68
  import { headerTexts, TABLE_SCROLL_FOCUS, watchTableScroll } from './prose-table-scroll.js';
@@ -92,6 +101,34 @@ function NotesTable({ hidden, ...props }) {
92
101
  // Folded away, the box goes with the table.
93
102
  containerClassName: cn('rounded-lg border border-border', TABLE_SCROLL_FOCUS, hidden && 'hidden') }));
94
103
  }
104
+ // Ticks a task item by where it starts in the text: given only with
105
+ // `onTasksChange`. Each task item hands its box its own start and words.
106
+ const TaskToggleContext = createContext(null);
107
+ const TaskItemContext = createContext(null);
108
+ // An item's own words, for its checkbox's name: its nested lists left
109
+ // out, so a parent is named for itself and not its children too.
110
+ function itemText(node) {
111
+ if (!node)
112
+ return '';
113
+ if (node.type === 'text')
114
+ return node.value ?? '';
115
+ if (node.tagName === 'ul' || node.tagName === 'ol')
116
+ return '';
117
+ return (node.children ?? []).map(itemText).join('');
118
+ }
119
+ // A task item's box: a real checkbox when ticking is on (named for its
120
+ // item, so a screen reader hears "Buy milk, checkbox, checked"), the
121
+ // glyph otherwise.
122
+ function TaskBox({ checked }) {
123
+ const toggle = useContext(TaskToggleContext);
124
+ const item = useContext(TaskItemContext);
125
+ if (toggle && item)
126
+ return (_jsx(Checkbox, { checked: checked, onCheckedChange: () => toggle(item.offset),
127
+ // Ticking is all it does: a click never reaches whatever the text
128
+ // sits in (a card that opens the record, a field that edits).
129
+ onClick: (event) => event.stopPropagation(), "aria-label": item.name || (checked ? 'Done' : 'Not done'), "data-slot": "task-box", className: "mr-2 inline-flex align-[-0.1875rem]" }));
130
+ 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" }));
131
+ }
95
132
  // Folds a section by its heading's id: given only with `foldKey`.
96
133
  const FoldContext = createContext(null);
97
134
  // A heading by its Markdown level: the element the page's outline gives
@@ -129,11 +166,22 @@ const COMPONENTS = {
129
166
  ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
130
167
  li: (props) => {
131
168
  const { className, ...rest } = omitNode(props);
132
- return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && '-ml-6 list-none') });
169
+ const task = className?.includes('task-list-item');
170
+ // A task item's box hangs where a bullet would; a list under it starts
171
+ // at its words, and so does a loose item's next paragraph.
172
+ const item = (_jsx("li", { ...rest, className: cn(task && '-ml-6 list-none [&>ol]:pl-12 [&>p:not(:first-child)]:pl-6 [&>ul]:pl-12') }));
173
+ const node = props.node;
174
+ const offset = node?.position?.start.offset;
175
+ if (!task || offset === undefined)
176
+ return item;
177
+ // A loose item's words are its first paragraph; what's under it isn't its name.
178
+ const first = node?.children?.find((child) => child.tagName === 'p');
179
+ const name = itemText(first ?? node).replace(/\s+/g, ' ').trim();
180
+ return _jsx(TaskItemContext.Provider, { value: { offset, name }, children: item });
133
181
  },
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" })),
182
+ // A GitHub task-list box (`- [x] done`): a glyph, or a checkbox with
183
+ // `onTasksChange` (TaskBox).
184
+ input: ({ checked }) => _jsx(TaskBox, { checked: Boolean(checked) }),
137
185
  blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
138
186
  hr: ({ hidden }) => _jsx("hr", { hidden: hidden, className: PROSE.rule }),
139
187
  // Highlighted words (remarkHighlights marks them): the colour's wash.
@@ -184,11 +232,26 @@ function componentsFor(top, textTop) {
184
232
  }
185
233
  return components;
186
234
  }
187
- export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey, className, ...rest }) {
235
+ export function Markdown({ children, headingLevel = DEFAULT_HEADING_LEVEL, headingFrom = 'hash', foldKey: foldKeyProp, foldable = true, onTasksChange, className, ...rest }) {
236
+ const foldKey = foldable ? foldKeyProp : undefined;
188
237
  const textTop = useMemo(() => (headingFrom === 'text' ? textTopHeading(children) : 1), [headingFrom, children]);
189
238
  const components = useMemo(() => componentsFor(headingLevel, textTop), [headingLevel, textTop]);
190
239
  const [folded, setFolded] = useFolds(foldKey);
191
240
  const rehypePlugins = useMemo(() => (foldKey === undefined ? [] : [[rehypeFold, { folded }]]), [foldKey, folded]);
192
241
  const toggle = useCallback((id) => setFolded(toggleFold(folded, id)), [folded, setFolded]);
193
- 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 }) }) }));
242
+ // Ticks not yet back as `children`: shown, and built on by the next
243
+ // tick, so two quick ticks on a slow save both land. Kept while
244
+ // `children` is the text they started from or one of their own saves
245
+ // coming back; dropped when it's anything else, or a save fails.
246
+ const [pending, setPending] = useState(null);
247
+ const live = pending && (pending.base === children || pending.sent.includes(children)) ? pending : null;
248
+ const shown = live ? live.text : children;
249
+ const tickTask = useCallback((offset) => {
250
+ const next = toggleTaskAt(shown, offset);
251
+ setPending({ base: live ? live.base : children, text: next, sent: [...(live ? live.sent : []), next] });
252
+ const saving = onTasksChange?.(next);
253
+ if (saving && typeof saving.then === 'function')
254
+ saving.then(undefined, () => setPending((current) => (current?.sent.includes(next) ? null : current)));
255
+ }, [onTasksChange, children, shown, live]);
256
+ 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 }) }) }) }));
194
257
  }
@@ -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)
@@ -80,6 +80,12 @@ export type RichTextEditorProps = {
80
80
  * when it mounts.
81
81
  */
82
82
  foldKey?: string;
83
+ /**
84
+ * `false` keeps headings plain, with no fold buttons or shortcuts, even
85
+ * with a `foldKey`, as on `<Markdown>`. Read once, when it mounts.
86
+ * Default `true`.
87
+ */
88
+ foldable?: boolean;
83
89
  className?: string;
84
90
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
85
91
  'aria-label'?: string;
@@ -34,5 +34,5 @@ import { cn } from 'cn';
34
34
  import { Skeleton } from '../ui/skeleton.js';
35
35
  const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
36
36
  export function RichTextEditor(props) {
37
- return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props }) }));
37
+ return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col', !props.inline && 'rounded-lg border border-input', props.className), children: [(props.toolbar ?? true) === true && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: cn('flex flex-col gap-2', props.inline ? 'py-0.5' : 'min-h-24 px-2.5 py-2'), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] })] }), children: _jsx(MilkdownEditor, { ...props, foldKey: props.foldable === false ? undefined : props.foldKey }) }));
38
38
  }
@@ -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
@@ -22,6 +22,7 @@ export { ErrorState } from './components/app/error-state.js';
22
22
  export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
23
23
  export { FoundationProviders } from './components/app/foundation-providers.js';
24
24
  export { Markdown, type MarkdownProps } from './components/app/markdown.js';
25
+ export { countTasks, toggleTaskAt, type TaskCount } from './lib/markdown-tasks.js';
25
26
  export { NotesOutline, type NotesOutlineProps } from './components/app/notes-outline.js';
26
27
  export type { HeadingFrom, HeadingLevel } from './components/app/prose-look.js';
27
28
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { ErrorState } from './components/app/error-state.js';
26
26
  export { InlineCreate } from './components/app/inline-create.js';
27
27
  export { FoundationProviders } from './components/app/foundation-providers.js';
28
28
  export { Markdown } from './components/app/markdown.js';
29
+ export { countTasks, toggleTaskAt } from './lib/markdown-tasks.js';
29
30
  export { NotesOutline } from './components/app/notes-outline.js';
30
31
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
31
32
  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.58.0",
3
+ "version": "4.60.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": {