@tristan2828/ui-foundation 3.12.0 → 3.14.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 +57 -12
- package/conventions/docs/cell-patterns.md +369 -17
- package/conventions/docs/design-language.md +23 -4
- package/conventions/docs/entity-plan-template.md +21 -9
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +10 -1
- package/dist/components/app/entity-view.js +20 -7
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/app/stage-circle.d.ts +20 -0
- package/dist/components/app/stage-circle.js +30 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/field.js +2 -2
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +196 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -16,9 +16,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
// that's where quick actions go (cell pattern 17), a value the plan marks
|
|
17
17
|
// as changed straight from this page and saved on its own through
|
|
18
18
|
// `useRecordUpdate`: a status picked in the header, a yes/no switch,
|
|
19
|
-
// checklist items ticked in place.
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// checklist items ticked in place.
|
|
20
|
+
//
|
|
21
|
+
// Editing in place (cell pattern 18): a field, a `content` section or the
|
|
22
|
+
// title marked `edit` turns into its form control where it's shown, and
|
|
23
|
+
// saves when you leave it. This component runs it (one field open at a
|
|
24
|
+
// time, the leave-page prompt); EditableValue is each value's side, and a
|
|
25
|
+
// header badge uses it directly. A field not marked renders exactly as
|
|
26
|
+
// before. Edit, in `actions`, stays for everything else.
|
|
27
|
+
import { useId, useState, useSyncExternalStore } from 'react';
|
|
22
28
|
import { Link } from 'react-router';
|
|
23
29
|
import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
|
|
24
30
|
import { ErrorState } from './error-state.js';
|
|
@@ -26,6 +32,9 @@ import { Button } from '../ui/button.js';
|
|
|
26
32
|
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
27
33
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
28
34
|
import { Skeleton } from '../ui/skeleton.js';
|
|
35
|
+
import { EditInPlaceContext } from './edit-in-place.js';
|
|
36
|
+
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
37
|
+
import { EditableValue, LeaveGuard } from './editable-value.js';
|
|
29
38
|
const DEFAULT_EMPTY_LABEL = 'Not set';
|
|
30
39
|
function isEmptyValue(value) {
|
|
31
40
|
return (value === null ||
|
|
@@ -43,16 +52,20 @@ function orEmptyLabel(value, emptyLabel) {
|
|
|
43
52
|
// Label beside value from `sm` up; below it (a phone) the label sits
|
|
44
53
|
// above its value, so nothing is squeezed into a narrow second column.
|
|
45
54
|
function FieldRow({ field }) {
|
|
46
|
-
return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(field.value, field.emptyLabel) })] }));
|
|
55
|
+
return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
|
|
47
56
|
}
|
|
48
57
|
function ViewSection({ section }) {
|
|
49
58
|
const headingId = useId();
|
|
50
|
-
return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(section.content, section.emptyLabel) })) })] }) }));
|
|
59
|
+
return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
|
|
51
60
|
}
|
|
52
61
|
function EntityViewSkeleton() {
|
|
53
62
|
return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
|
|
54
63
|
}
|
|
55
|
-
export function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }) {
|
|
64
|
+
export function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }) {
|
|
65
|
+
// Editing in place: one store per view, and the leave-page prompt only
|
|
66
|
+
// while a field is open.
|
|
67
|
+
const [store] = useState(() => new EditInPlaceStore());
|
|
68
|
+
const openField = useSyncExternalStore(store.subscribe, () => store.open, () => store.open);
|
|
56
69
|
if (error?.kind === 'notfound') {
|
|
57
70
|
// A record that doesn't exist and one that belongs to another user are
|
|
58
71
|
// the same plain 404, so this never says which. Retrying can't help.
|
|
@@ -62,5 +75,5 @@ export function EntityView({ title, badges, actions, sections, isLoading, error,
|
|
|
62
75
|
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
63
76
|
if (isLoading)
|
|
64
77
|
return _jsx(EntityViewSkeleton, {});
|
|
65
|
-
return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [_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-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: title }), 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 })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }));
|
|
78
|
+
return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [openField !== null && _jsx(LeaveGuard, { store: store }), _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", { 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) }), 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 })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }) }));
|
|
66
79
|
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The rich-text editor behind RichTextEditor, loaded only when one opens
|
|
3
|
+
// (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
|
|
4
|
+
// editor whose Markdown goes in and out through remark, the same parser
|
|
5
|
+
// <Markdown> renders with. Why Milkdown, and what it costs:
|
|
6
|
+
// docs/ARCHITECTURE.md "Decisions".
|
|
7
|
+
//
|
|
8
|
+
// What this file adds to Milkdown's own presets:
|
|
9
|
+
// - Saves go through mergeMarkdown: an untouched document, and every block
|
|
10
|
+
// the person didn't change, saves back as originally written.
|
|
11
|
+
// - No HTML in, ever. The preset's "preserve empty line" plugin is left
|
|
12
|
+
// out: it writes an empty paragraph as a raw `<br />`. Raw HTML already
|
|
13
|
+
// in the text shows as its characters (an uneditable chip, Milkdown's
|
|
14
|
+
// html node), and HTML pasted as such a chip becomes plain text.
|
|
15
|
+
// - Headings render from <h3>, like <Markdown>: the page owns <h1> and
|
|
16
|
+
// <h2>. A task-list item renders a real checkbox that toggles it.
|
|
17
|
+
// - Edited blocks are written in the document's own bullet and rule marks.
|
|
18
|
+
import { useEffect, useRef } from 'react';
|
|
19
|
+
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
|
|
20
|
+
import { history } from '@milkdown/kit/plugin/history';
|
|
21
|
+
import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
|
|
22
|
+
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
23
|
+
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
24
|
+
import { Plugin } from '@milkdown/kit/prose/state';
|
|
25
|
+
import { $prose, getMarkdown } from '@milkdown/kit/utils';
|
|
26
|
+
import { cn } from 'cn';
|
|
27
|
+
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
28
|
+
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
29
|
+
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
30
|
+
const LEFT_OUT = remarkPreserveEmptyLinePlugin;
|
|
31
|
+
const COMMONMARK = [
|
|
32
|
+
...schema,
|
|
33
|
+
...inputRules,
|
|
34
|
+
...markInputRules,
|
|
35
|
+
...commands,
|
|
36
|
+
...keymap,
|
|
37
|
+
...plugins.filter((plugin) => !LEFT_OUT.includes(plugin)),
|
|
38
|
+
];
|
|
39
|
+
// Raw HTML pasted in as Milkdown's html chip becomes the plain text it
|
|
40
|
+
// shows, so a paste can never add markup to the Markdown.
|
|
41
|
+
function htmlToText(fragment) {
|
|
42
|
+
const nodes = [];
|
|
43
|
+
fragment.forEach((node) => {
|
|
44
|
+
if (node.type.name === 'html') {
|
|
45
|
+
const value = String(node.attrs.value ?? '');
|
|
46
|
+
if (value)
|
|
47
|
+
nodes.push(node.type.schema.text(value));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
nodes.push(node.copy(htmlToText(node.content)));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return Fragment.fromArray(nodes);
|
|
54
|
+
}
|
|
55
|
+
function headingView(node) {
|
|
56
|
+
const dom = document.createElement(`h${Math.min(Number(node.attrs.level) + 2, 6)}`);
|
|
57
|
+
return {
|
|
58
|
+
dom,
|
|
59
|
+
contentDOM: dom,
|
|
60
|
+
update: (next) => next.type === node.type && next.attrs.level === node.attrs.level,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// A task-list item: its checkbox toggles `checked`. Any other list item
|
|
64
|
+
// renders as Milkdown's own.
|
|
65
|
+
function taskItemView(node, view, getPos) {
|
|
66
|
+
const dom = document.createElement('li');
|
|
67
|
+
const box = document.createElement('input');
|
|
68
|
+
box.type = 'checkbox';
|
|
69
|
+
box.contentEditable = 'false';
|
|
70
|
+
box.tabIndex = -1;
|
|
71
|
+
box.setAttribute('aria-label', 'Done');
|
|
72
|
+
const contentDOM = document.createElement('div');
|
|
73
|
+
dom.append(box, contentDOM);
|
|
74
|
+
dom.dataset.itemType = 'task';
|
|
75
|
+
let current = node;
|
|
76
|
+
const render = () => {
|
|
77
|
+
box.checked = Boolean(current.attrs.checked);
|
|
78
|
+
dom.dataset.checked = String(Boolean(current.attrs.checked));
|
|
79
|
+
};
|
|
80
|
+
render();
|
|
81
|
+
box.addEventListener('mousedown', (event) => event.preventDefault());
|
|
82
|
+
// The browser's own toggle is left to happen (cancelling a checkbox's
|
|
83
|
+
// click puts its old state back after this handler), and the document
|
|
84
|
+
// follows it.
|
|
85
|
+
box.addEventListener('click', () => {
|
|
86
|
+
const pos = getPos();
|
|
87
|
+
if (pos === undefined || !view.editable)
|
|
88
|
+
return;
|
|
89
|
+
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, checked: !current.attrs.checked }));
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
dom,
|
|
93
|
+
contentDOM,
|
|
94
|
+
update: (next) => {
|
|
95
|
+
if (next.type !== current.type || next.attrs.checked == null)
|
|
96
|
+
return false;
|
|
97
|
+
current = next;
|
|
98
|
+
render();
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
stopEvent: (event) => event.target === box,
|
|
102
|
+
ignoreMutation: (mutation) => mutation.target === box,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
106
|
+
const rootRef = useRef(null);
|
|
107
|
+
const editorRef = useRef(null);
|
|
108
|
+
// The latest props, for the editor's callbacks, which are set up once.
|
|
109
|
+
const latest = useRef({ onChange, onProblem, readOnly });
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
latest.current = { onChange, onProblem, readOnly };
|
|
112
|
+
});
|
|
113
|
+
const attributes = {
|
|
114
|
+
'aria-multiline': 'true',
|
|
115
|
+
'aria-readonly': String(readOnly),
|
|
116
|
+
class: 'outline-none',
|
|
117
|
+
};
|
|
118
|
+
if (id)
|
|
119
|
+
attributes.id = id;
|
|
120
|
+
if (ariaLabel)
|
|
121
|
+
attributes['aria-label'] = ariaLabel;
|
|
122
|
+
if (ariaLabelledBy)
|
|
123
|
+
attributes['aria-labelledby'] = ariaLabelledBy;
|
|
124
|
+
if (ariaDescribedBy)
|
|
125
|
+
attributes['aria-describedby'] = ariaDescribedBy;
|
|
126
|
+
if (ariaInvalid)
|
|
127
|
+
attributes['aria-invalid'] = 'true';
|
|
128
|
+
const attributesKey = JSON.stringify(attributes);
|
|
129
|
+
// The editor is made once, from the Markdown it opened with.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const root = rootRef.current;
|
|
132
|
+
if (!root)
|
|
133
|
+
return;
|
|
134
|
+
const original = defaultValue;
|
|
135
|
+
let baseline = null;
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
const emit = () => {
|
|
138
|
+
const editor = editorRef.current;
|
|
139
|
+
if (!editor || baseline === null)
|
|
140
|
+
return;
|
|
141
|
+
const current = editor.action(getMarkdown());
|
|
142
|
+
try {
|
|
143
|
+
const merged = mergeMarkdown(original, baseline, current);
|
|
144
|
+
latest.current.onProblem?.(null);
|
|
145
|
+
latest.current.onChange(merged);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (!(error instanceof MarkdownMergeError))
|
|
149
|
+
throw error;
|
|
150
|
+
latest.current.onProblem?.(error.message);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const changes = $prose(() => new Plugin({
|
|
154
|
+
view: () => ({
|
|
155
|
+
update: (view, previous) => {
|
|
156
|
+
if (!view.state.doc.eq(previous.doc))
|
|
157
|
+
emit();
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
props: {
|
|
161
|
+
transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
const make = Editor.make()
|
|
165
|
+
.config((ctx) => {
|
|
166
|
+
ctx.set(rootCtx, root);
|
|
167
|
+
ctx.set(defaultValueCtx, original);
|
|
168
|
+
ctx.update(remarkStringifyOptionsCtx, (options) => ({ ...options, ...markdownStyle(original) }));
|
|
169
|
+
ctx.update(editorViewOptionsCtx, (options) => ({
|
|
170
|
+
...options,
|
|
171
|
+
editable: () => !latest.current.readOnly,
|
|
172
|
+
attributes,
|
|
173
|
+
nodeViews: {
|
|
174
|
+
heading: headingView,
|
|
175
|
+
list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
})
|
|
179
|
+
.use(COMMONMARK)
|
|
180
|
+
.use(gfm)
|
|
181
|
+
.use(history)
|
|
182
|
+
.use(changes);
|
|
183
|
+
void make.create().then((editor) => {
|
|
184
|
+
if (cancelled) {
|
|
185
|
+
void editor.destroy();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
editorRef.current = editor;
|
|
189
|
+
baseline = editor.action(getMarkdown());
|
|
190
|
+
if (autoFocus)
|
|
191
|
+
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
192
|
+
});
|
|
193
|
+
return () => {
|
|
194
|
+
cancelled = true;
|
|
195
|
+
const editor = editorRef.current;
|
|
196
|
+
editorRef.current = null;
|
|
197
|
+
if (editor)
|
|
198
|
+
void editor.destroy();
|
|
199
|
+
root.replaceChildren();
|
|
200
|
+
};
|
|
201
|
+
// Made once per mount: later changes to defaultValue are ignored, as
|
|
202
|
+
// with any uncontrolled input. Attributes and read-only follow below.
|
|
203
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
+
}, []);
|
|
205
|
+
// Read-only and the ARIA attributes follow their props.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const editor = editorRef.current;
|
|
208
|
+
if (!editor)
|
|
209
|
+
return;
|
|
210
|
+
editor.action((ctx) => {
|
|
211
|
+
const view = ctx.get(editorViewCtx);
|
|
212
|
+
view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
|
|
213
|
+
});
|
|
214
|
+
}, [readOnly, attributesKey]);
|
|
215
|
+
return (_jsx("div", { ref: rootRef, "data-slot": "rich-text-editor", className: cn('min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 type-body text-foreground break-words transition-colors', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', 'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
216
|
+
// The same type roles <Markdown> uses, so text reads the same
|
|
217
|
+
// being edited as being read.
|
|
218
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
219
|
+
// Raw HTML from the text: shown as its characters, never as markup.
|
|
220
|
+
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground', className) }));
|
|
221
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
export declare function MultiReference<Id extends string | number>({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
|
|
2
|
+
export declare function MultiReference<Id extends string | number>({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, readOnly, className, }: {
|
|
3
3
|
/** The records the current search returned, in display order. */
|
|
4
4
|
options: readonly {
|
|
5
5
|
id: Id;
|
|
@@ -23,5 +23,9 @@ export declare function MultiReference<Id extends string | number>({ options, va
|
|
|
23
23
|
/** For use without a visible label (e.g. a table toolbar filter). */
|
|
24
24
|
'aria-label'?: string;
|
|
25
25
|
'aria-invalid'?: boolean;
|
|
26
|
+
/** The id of a line under the control (an error, a saving status). */
|
|
27
|
+
'aria-describedby'?: string;
|
|
28
|
+
/** Shown, not changeable: editing in place, while the save is in flight. */
|
|
29
|
+
readOnly?: boolean;
|
|
26
30
|
className?: string;
|
|
27
31
|
}): React.JSX.Element;
|
|
@@ -17,11 +17,11 @@ import * as React from 'react';
|
|
|
17
17
|
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
|
|
18
18
|
/** Shown on a chip whose name hasn't loaded yet, instead of a bare id. */
|
|
19
19
|
const PENDING_LABEL = '…';
|
|
20
|
-
export function MultiReference({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText = 'No matches.', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
|
|
20
|
+
export function MultiReference({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText = 'No matches.', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, readOnly = false, className, }) {
|
|
21
21
|
const anchor = useComboboxAnchor();
|
|
22
22
|
const label = (option) => getLabel(option) ?? PENDING_LABEL;
|
|
23
23
|
return (_jsxs(Combobox, { multiple: true, autoHighlight: true,
|
|
24
24
|
// Ids, not {id,label} objects: Base UI compares items to the value
|
|
25
25
|
// with Object.is, so the items must have the value's own shape.
|
|
26
|
-
items: options.map((option) => option.id), value: value, onValueChange: (next) => onValueChange(next), onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: emptyText }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: label(option) }, option)) })] })] }));
|
|
26
|
+
items: options.map((option) => option.id), value: value, onValueChange: (next) => onValueChange(next), onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, readOnly: readOnly, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: emptyText }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: label(option) }, option)) })] })] }));
|
|
27
27
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type RichTextEditorProps = {
|
|
2
|
+
/** The editable element's id, for a label's `htmlFor` or a test. */
|
|
3
|
+
id?: string;
|
|
4
|
+
/** The Markdown it opens with. Read once, when it mounts (uncontrolled). */
|
|
5
|
+
defaultValue: string;
|
|
6
|
+
/** The Markdown to save, after every edit: untouched blocks as originally written. */
|
|
7
|
+
onChange: (markdown: string) => void;
|
|
8
|
+
/** Why the edit can't be saved as it stands, or `null` once it can. */
|
|
9
|
+
onProblem?: (message: string | null) => void;
|
|
10
|
+
/** Shown but not editable (while a save is in flight). */
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
autoFocus?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
15
|
+
'aria-label'?: string;
|
|
16
|
+
'aria-labelledby'?: string;
|
|
17
|
+
'aria-describedby'?: string;
|
|
18
|
+
'aria-invalid'?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function RichTextEditor(props: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Long text written as Markdown, edited as formatted text where it's read
|
|
3
|
+
// (Notion-style), not as Markdown syntax in a textarea. The control for a
|
|
4
|
+
// `long text` field marked Markdown, on the form and in place on the view.
|
|
5
|
+
//
|
|
6
|
+
// It stays Markdown: it opens from Markdown and reports Markdown. Opening
|
|
7
|
+
// and saving round-trip exactly: an untouched document comes back byte for
|
|
8
|
+
// byte, and an edited one keeps every block the person didn't change as it
|
|
9
|
+
// was written (src/lib/markdown-merge.ts). If an edit can't be saved
|
|
10
|
+
// without rewriting another block, `onProblem` says so and `onChange`
|
|
11
|
+
// isn't called: the draft stays on screen, nothing is saved in its place.
|
|
12
|
+
//
|
|
13
|
+
// Raw HTML in the text shows as its characters, as <Markdown> shows it,
|
|
14
|
+
// and nothing pasted or typed can add markup.
|
|
15
|
+
//
|
|
16
|
+
// The editor (Milkdown) is loaded on first use, so a page that only reads
|
|
17
|
+
// Markdown never downloads it. Until it arrives, a placeholder the size of
|
|
18
|
+
// the field shows.
|
|
19
|
+
import { lazy, Suspense } from 'react';
|
|
20
|
+
import { cn } from 'cn';
|
|
21
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
22
|
+
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
|
+
export function RichTextEditor(props) {
|
|
24
|
+
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex min-h-24 w-full flex-col gap-2 rounded-lg border border-input px-2.5 py-2', props.className), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] }), children: _jsx(MilkdownEditor, { ...props }) }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the record is: `0` the first stage (a dashed outline), `1`–`3` a
|
|
3
|
+
* stage under way (a quarter, a half, three quarters filled), `'complete'`
|
|
4
|
+
* the final stage (solid, with a tick), `'exit'` off the lifecycle (struck
|
|
5
|
+
* through: dropped, cancelled).
|
|
6
|
+
*/
|
|
7
|
+
export type StageCircleStage = 0 | 1 | 2 | 3 | 'complete' | 'exit';
|
|
8
|
+
/**
|
|
9
|
+
* The tone, as text on the page (`*-text` tokens). `muted` is the default:
|
|
10
|
+
* colour only the stages that mean something (in progress `info`,
|
|
11
|
+
* complete `success`), like a tone-mapped badge (cell pattern 5).
|
|
12
|
+
*/
|
|
13
|
+
export type StageCircleTone = 'muted' | 'info' | 'success' | 'warning' | 'destructive';
|
|
14
|
+
export interface StageCircleProps {
|
|
15
|
+
stage: StageCircleStage;
|
|
16
|
+
tone?: StageCircleTone;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
|
|
20
|
+
export declare function StageCircle({ stage, tone, className }: StageCircleProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A stage circle (cell pattern 19): where a record is in an ordered
|
|
3
|
+
// lifecycle, as a small circle that fills as it moves on. It sits beside
|
|
4
|
+
// the status word, which stays: the shape carries the stage in greyscale
|
|
5
|
+
// and the tone only reinforces it.
|
|
6
|
+
//
|
|
7
|
+
// Lucide has no part-filled circle, so it's drawn here, in currentColor
|
|
8
|
+
// (16×16 viewBox, the ring and fill in the tone, the tick cut out of the
|
|
9
|
+
// solid circle in the page's background).
|
|
10
|
+
import { cn } from 'cn';
|
|
11
|
+
const TONE_CLASS = {
|
|
12
|
+
muted: 'text-muted-foreground',
|
|
13
|
+
info: 'text-info-text',
|
|
14
|
+
success: 'text-success-text',
|
|
15
|
+
warning: 'text-warning-text',
|
|
16
|
+
destructive: 'text-destructive-text',
|
|
17
|
+
};
|
|
18
|
+
// The filled part of a stage under way: a pie from twelve o'clock,
|
|
19
|
+
// clockwise, inside the ring with a gap so it reads as filling, not as a
|
|
20
|
+
// thicker ring.
|
|
21
|
+
const PIE = {
|
|
22
|
+
1: 'M8 8V4.5A3.5 3.5 0 0 1 11.5 8Z',
|
|
23
|
+
2: 'M8 8V4.5A3.5 3.5 0 0 1 8 11.5Z',
|
|
24
|
+
3: 'M8 8V4.5A3.5 3.5 0 1 1 4.5 8Z',
|
|
25
|
+
};
|
|
26
|
+
const RING = { cx: 8, cy: 8, r: 6, strokeWidth: 1.5, className: 'fill-none stroke-current' };
|
|
27
|
+
/** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
|
|
28
|
+
export function StageCircle({ stage, tone = 'muted', className }) {
|
|
29
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", "data-slot": "stage-circle", "data-stage": stage, className: cn('size-4 shrink-0', TONE_CLASS[tone], className), children: [stage === 0 && _jsx("circle", { ...RING, strokeDasharray: "2.4 2.3" }), (stage === 1 || stage === 2 || stage === 3) && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: PIE[stage], className: "fill-current" })] })), stage === 'complete' && (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "8", cy: "8", r: "7", className: "fill-current" }), _jsx("path", { d: "M5 8.2 7.1 10.2 11 6", className: "fill-none stroke-background", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" })] })), stage === 'exit' && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: "M4 12 12 4", className: "fill-none stroke-current", strokeWidth: "1.5", strokeLinecap: "round" })] }))] }));
|
|
30
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRender } from "@base-ui/react/use-render";
|
|
2
2
|
import { type VariantProps } from "class-variance-authority";
|
|
3
3
|
declare const badgeVariants: (props?: ({
|
|
4
|
-
variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "
|
|
4
|
+
variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "info" | "warning" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
|
|
5
5
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
6
|
declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
7
7
|
export { Badge, badgeVariants };
|
|
@@ -13,7 +13,7 @@ function FieldLegend({ className, variant = "legend", ...props }) {
|
|
|
13
13
|
function FieldGroup({ className, ...props }) {
|
|
14
14
|
return (_jsx("div", { "data-slot": "field-group", className: cn("group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4", className), ...props }));
|
|
15
15
|
}
|
|
16
|
-
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", {
|
|
16
|
+
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive-text", {
|
|
17
17
|
variants: {
|
|
18
18
|
orientation: {
|
|
19
19
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
|
@@ -62,6 +62,6 @@ function FieldError({ className, children, errors, ...props }) {
|
|
|
62
62
|
if (!content) {
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
|
-
return (_jsx("div", { role: "alert", "data-slot": "field-error", className: cn("text-sm font-normal text-destructive", className), ...props, children: content }));
|
|
65
|
+
return (_jsx("div", { role: "alert", "data-slot": "field-error", className: cn("text-sm font-normal text-destructive-text", className), ...props, children: content }));
|
|
66
66
|
}
|
|
67
67
|
export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle, };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
2
|
+
import { type VariantProps } from "class-variance-authority";
|
|
3
|
+
declare const toggleVariants: (props?: ({
|
|
4
|
+
variant?: "default" | "outline" | null | undefined;
|
|
5
|
+
size?: "default" | "sm" | "lg" | "icon-xs" | "icon-sm" | null | undefined;
|
|
6
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
|
+
declare function Toggle({ className, variant, size, ...props }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>): import("react").JSX.Element;
|
|
8
|
+
export { Toggle, toggleVariants };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
// The `icon-xs`/`icon-sm` sizes are this repo's patch to the upstream
|
|
6
|
+
// primitive, matching Button's: a square toggle holding one icon, like the
|
|
7
|
+
// pressed flag inside a table cell (cell pattern 15). A local patch:
|
|
8
|
+
// re-running `shadcn add toggle` here would overwrite it. Diff against
|
|
9
|
+
// upstream before taking a new version.
|
|
10
|
+
const toggleVariants = cva("group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: "bg-transparent",
|
|
14
|
+
outline: "border border-input bg-transparent hover:bg-muted",
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
18
|
+
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
19
|
+
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
20
|
+
// 24px: the smallest target WCAG 2.2 allows (2.5.8).
|
|
21
|
+
"icon-xs": "size-6 min-w-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3.5",
|
|
22
|
+
"icon-sm": "size-7 min-w-7 rounded-[min(var(--radius-md),12px)]",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: "default",
|
|
27
|
+
size: "default",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
function Toggle({ className, variant = "default", size = "default", ...props }) {
|
|
31
|
+
return (_jsx(TogglePrimitive, { "data-slot": "toggle", className: cn(toggleVariants({ variant, size, className })), ...props }));
|
|
32
|
+
}
|
|
33
|
+
export { Toggle, toggleVariants };
|
|
@@ -23,9 +23,22 @@ export type RecordUpdateOptions<TRecord extends {
|
|
|
23
23
|
update: (id: TRecord['id'], update: TUpdate) => Promise<TRecord>;
|
|
24
24
|
/** What a refusal's toast calls the record: "Couldn't update <name>: <reason>". */
|
|
25
25
|
name: (record: TRecord) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Show the change before the server answers (default `true`, a quick
|
|
28
|
+
* action). `false` for editing in place: the value shows as saved only
|
|
29
|
+
* once it is, and until then the field holds it.
|
|
30
|
+
*/
|
|
31
|
+
optimistic?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Toast a refusal (default `true`). `false` when the control shows the
|
|
34
|
+
* reason itself (editing in place): `mutateAsync` rejects with the
|
|
35
|
+
* `AppError`, and `refusalReason` words it.
|
|
36
|
+
*/
|
|
37
|
+
toastOnError?: boolean;
|
|
26
38
|
};
|
|
27
39
|
type Entry<TRecord, TUpdate> = {
|
|
28
40
|
change: RecordChange<TRecord, TUpdate>;
|
|
41
|
+
optimistic: boolean;
|
|
29
42
|
};
|
|
30
43
|
/**
|
|
31
44
|
* Why a save was refused, in the server's words: for a 422, the message
|
|
@@ -39,7 +52,7 @@ export declare function refusalReason(error: AppError, sent?: object): string;
|
|
|
39
52
|
*/
|
|
40
53
|
export declare function recordUpdateMutationOptions<TRecord extends {
|
|
41
54
|
id: RecordId;
|
|
42
|
-
}, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
|
|
55
|
+
}, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name, optimistic, toastOnError }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
|
|
43
56
|
entry: Entry<TRecord, TUpdate>;
|
|
44
57
|
}>;
|
|
45
58
|
/**
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
// or the AppError's).
|
|
22
22
|
// - **The lists refetch once the last save settles**, so a filter on the
|
|
23
23
|
// field drops the row once the server agrees.
|
|
24
|
+
//
|
|
25
|
+
// Editing in place uses the same save with `optimistic: false` and
|
|
26
|
+
// `toastOnError: false`: nothing changes on screen until the server
|
|
27
|
+
// agrees, and a refusal comes back to the field (`mutateAsync` rejects)
|
|
28
|
+
// instead of a toast.
|
|
24
29
|
import { hashKey, useMutation, useQueryClient, } from '@tanstack/react-query';
|
|
25
30
|
import { toast } from 'sonner';
|
|
26
31
|
const states = new WeakMap();
|
|
@@ -48,7 +53,7 @@ export function refusalReason(error, sent) {
|
|
|
48
53
|
* The mutation options behind `useRecordUpdate`, for a given QueryClient.
|
|
49
54
|
* Exported for tests and for code outside React; screens use the hook.
|
|
50
55
|
*/
|
|
51
|
-
export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name }) {
|
|
56
|
+
export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name, optimistic = true, toastOnError = true }) {
|
|
52
57
|
const key = hashKey(detailKey);
|
|
53
58
|
const lists = { queryKey: listsKey };
|
|
54
59
|
const map = recordStates(queryClient);
|
|
@@ -69,7 +74,7 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
|
|
|
69
74
|
function render(state) {
|
|
70
75
|
if (!state.confirmed)
|
|
71
76
|
return;
|
|
72
|
-
const shown = state.pending.reduce((record, entry) => ({ ...record, ...resolve(entry.change, record) }), state.confirmed);
|
|
77
|
+
const shown = state.pending.reduce((record, entry) => (entry.optimistic ? { ...record, ...resolve(entry.change, record) } : record), state.confirmed);
|
|
73
78
|
queryClient.setQueryData(detailKey, (detail) => detail && shown);
|
|
74
79
|
queryClient.setQueriesData(lists, (page) => page && { ...page, items: page.items.map((record) => (record.id === id ? shown : record)) });
|
|
75
80
|
}
|
|
@@ -97,7 +102,7 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
|
|
|
97
102
|
let state = map.get(key);
|
|
98
103
|
if (!state)
|
|
99
104
|
map.set(key, (state = { confirmed: undefined, pending: [] }));
|
|
100
|
-
const entry = { change };
|
|
105
|
+
const entry = { change, optimistic };
|
|
101
106
|
state.pending.push(entry);
|
|
102
107
|
// A refetch landing now would overwrite the change on screen.
|
|
103
108
|
await Promise.all([
|
|
@@ -117,7 +122,8 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
|
|
|
117
122
|
const record = state.confirmed;
|
|
118
123
|
const sent = record && resolve(context.entry.change, record);
|
|
119
124
|
settle(state, context.entry);
|
|
120
|
-
|
|
125
|
+
if (toastOnError)
|
|
126
|
+
toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
|
|
121
127
|
},
|
|
122
128
|
onSuccess: (saved, _change, context) => {
|
|
123
129
|
const state = map.get(key);
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { AppShell, type AppShellProps, type NavItem } from './components/app/app
|
|
|
2
2
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
3
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
4
|
export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
5
|
+
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
|
|
6
|
+
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
5
7
|
export { ErrorState } from './components/app/error-state.js';
|
|
6
8
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
7
9
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
@@ -10,7 +12,9 @@ export { MultiChoice } from './components/app/multi-choice.js';
|
|
|
10
12
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
11
13
|
export { ListEditor } from './components/app/list-editor.js';
|
|
12
14
|
export { PasswordInput } from './components/app/password-input.js';
|
|
15
|
+
export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
|
|
13
16
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
17
|
+
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
14
18
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
15
19
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
16
20
|
export { RegisterRoute } from './routes/register.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,8 @@ export { AppShell } from './components/app/app-shell.js';
|
|
|
7
7
|
export { DataTable } from './components/app/data-table.js';
|
|
8
8
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
9
|
export { EntityView, } from './components/app/entity-view.js';
|
|
10
|
+
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
11
|
+
export { EditableValue } from './components/app/editable-value.js';
|
|
10
12
|
export { ErrorState } from './components/app/error-state.js';
|
|
11
13
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
12
14
|
export { Markdown } from './components/app/markdown.js';
|
|
@@ -15,7 +17,9 @@ export { MultiChoice } from './components/app/multi-choice.js';
|
|
|
15
17
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
16
18
|
export { ListEditor } from './components/app/list-editor.js';
|
|
17
19
|
export { PasswordInput } from './components/app/password-input.js';
|
|
20
|
+
export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
18
21
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
22
|
+
export { StageCircle, } from './components/app/stage-circle.js';
|
|
19
23
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
20
24
|
// Screens outside the app shell
|
|
21
25
|
export { LoginRoute } from './routes/login.js';
|