@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 +1 -1
- package/conventions/docs/add-an-entity.md +18 -11
- package/conventions/docs/cell-patterns.md +13 -10
- package/dist/components/app/markdown.d.ts +19 -1
- package/dist/components/app/markdown.js +70 -7
- package/dist/components/app/milkdown-editor.js +9 -3
- package/dist/components/app/rich-text-editor.d.ts +6 -0
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/components/app/rich-text-tasks.d.ts +8 -0
- package/dist/components/app/rich-text-tasks.js +72 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/markdown-tasks.d.ts +19 -0
- package/dist/lib/markdown-tasks.js +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `cancelCues`, `onCuesRecovered`, `cuesInSync`, `keepAudioAlive`, `say`, `hush`, `vibrate`, `canVibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission`. 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–
|
|
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–
|
|
14
|
-
- Step 7: Register routes · lines
|
|
15
|
-
- Step 8: Add Playwright specs for all three screens · lines
|
|
16
|
-
- Step 9: Register the nav entry and route names · lines
|
|
17
|
-
- Step 10: `npm run verify`. · lines
|
|
18
|
-
- [Opening a record over its list](#opening-a-record-over-its-list) · lines
|
|
19
|
-
- [What not to copy](#what-not-to-copy) · lines
|
|
20
|
-
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines
|
|
21
|
-
- [Deleting](#deleting) · lines
|
|
22
|
-
- [Fields only a script writes](#fields-only-a-script-writes) · lines
|
|
13
|
+
- Step 6: Copy the reference files, one for one, not just the two screens · lines 137–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–
|
|
30
|
-
- [A list of sub-items](#a-list-of-sub-items) · lines
|
|
31
|
-
- [In a table's rows](#in-a-tables-rows) · lines
|
|
32
|
-
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines
|
|
33
|
-
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines
|
|
34
|
-
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines
|
|
35
|
-
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines
|
|
36
|
-
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines
|
|
37
|
-
- [23. A Group menu, and a record read beside the list](#23-a-group-menu-and-a-record-read-beside-the-list) · lines
|
|
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
|
-
|
|
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
|
-
|
|
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`)
|
|
135
|
-
//
|
|
136
|
-
input: ({ checked }) =>
|
|
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
|
-
|
|
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.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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.
|
|
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": {
|