@tristan2828/ui-foundation 3.12.0 → 3.13.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 +53 -11
- package/conventions/docs/cell-patterns.md +114 -17
- 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/ui/field.js +2 -2
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +195 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Editing in place on a record's view: what a field passes to EntityView
|
|
2
|
+
// (or to an EditableValue in its header) to become editable where it's
|
|
3
|
+
// shown. The rules live in edit-in-place-store.ts; the markup in
|
|
4
|
+
// editable-value.tsx. Design language: cell-patterns.md "Editable value".
|
|
5
|
+
import { createContext } from 'react';
|
|
6
|
+
/** Marks a field editable in place, typed end to end: draft → schema → save. */
|
|
7
|
+
export function editInPlace(options) {
|
|
8
|
+
return options;
|
|
9
|
+
}
|
|
10
|
+
export const EditInPlaceContext = createContext(null);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type EditInPlace } from './edit-in-place.js';
|
|
3
|
+
import type { EditInPlaceStore } from './edit-in-place-store.js';
|
|
4
|
+
export type EditableValueProps = {
|
|
5
|
+
/** The field's label: the control's accessible name, and the edit button's ("Edit Name"). */
|
|
6
|
+
label: string;
|
|
7
|
+
edit: EditInPlace;
|
|
8
|
+
/** The value as the view shows it. */
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** `inline` in a heading or beside badges, `block` in a field row (default). */
|
|
11
|
+
layout?: 'inline' | 'block';
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function EditableValue({ label, edit, children, layout, className }: EditableValueProps): import("react").JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* While a field holds something unsaved, leaving asks first: an in-app
|
|
17
|
+
* link or Back (React Router's blocker, so the app needs a data router),
|
|
18
|
+
* or closing the tab (`beforeunload`). Rendered by EntityView only while
|
|
19
|
+
* a field is open, so a view nobody's editing never blocks anything.
|
|
20
|
+
*/
|
|
21
|
+
export declare function LeaveGuard({ store }: {
|
|
22
|
+
store: EditInPlaceStore;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A value on a record's view that turns into its form control where it's
|
|
3
|
+
// shown, and saves when you leave it (cell-patterns.md "Editable value").
|
|
4
|
+
// EntityView wraps every field, section and title marked `edit`; a header
|
|
5
|
+
// badge uses <EditableValue> itself. The rules are edit-in-place-store.ts.
|
|
6
|
+
//
|
|
7
|
+
// - Start: click the value (a link inside it still navigates), or tab to
|
|
8
|
+
// its edit button and press Enter. Hover and focus show it's editable.
|
|
9
|
+
// - Save: leave it (click or tab elsewhere), Enter on one line,
|
|
10
|
+
// Ctrl/Cmd+Enter anywhere, or pick a choice. Esc puts the saved value
|
|
11
|
+
// back. Unchanged closes with no request.
|
|
12
|
+
// - Saving: the control stays, read-only, with "Saving…" under it, until
|
|
13
|
+
// the server agrees. Failed: it stays, with the draft and the reason.
|
|
14
|
+
import { PencilIcon } from 'lucide-react';
|
|
15
|
+
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
|
|
16
|
+
import { useBlocker } from 'react-router';
|
|
17
|
+
import { cn } from 'cn';
|
|
18
|
+
import { Button } from '../ui/button.js';
|
|
19
|
+
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '../ui/dialog.js';
|
|
20
|
+
import { FieldError } from '../ui/field.js';
|
|
21
|
+
import { Spinner } from '../ui/spinner.js';
|
|
22
|
+
import { EditInPlaceContext } from './edit-in-place.js';
|
|
23
|
+
// What a click on the shown value must leave alone: links navigate,
|
|
24
|
+
// buttons do their own thing.
|
|
25
|
+
const INTERACTIVE = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
|
|
26
|
+
// Where the caret goes once the control shows: the first match, in this
|
|
27
|
+
// order (a picker's text box before its chips' remove buttons).
|
|
28
|
+
const FOCUS_ORDER = [
|
|
29
|
+
'input:not([type="hidden"]), textarea, [contenteditable="true"]',
|
|
30
|
+
'[role="combobox"], [role="switch"], button, [tabindex]:not([tabindex="-1"])',
|
|
31
|
+
];
|
|
32
|
+
// Focus moving here doesn't save the field: a link that leaves the page
|
|
33
|
+
// (the leave prompt decides what happens to the draft) or the prompt itself.
|
|
34
|
+
const LEAVING = 'a[href]:not([target="_blank"]), [data-leave-guard]';
|
|
35
|
+
// The field's own control, given what it needs.
|
|
36
|
+
function Control({ render, ...props }) {
|
|
37
|
+
return render(props);
|
|
38
|
+
}
|
|
39
|
+
function useStore() {
|
|
40
|
+
const store = useContext(EditInPlaceContext);
|
|
41
|
+
if (!store)
|
|
42
|
+
throw new Error('<EditableValue> works inside an <EntityView>, which runs editing in place.');
|
|
43
|
+
return store;
|
|
44
|
+
}
|
|
45
|
+
export function EditableValue({ label, edit, children, layout = 'block', className }) {
|
|
46
|
+
const store = useStore();
|
|
47
|
+
const key = useId();
|
|
48
|
+
const controlId = `${key}-control`;
|
|
49
|
+
const describedBy = `${key}-status`;
|
|
50
|
+
// The latest config, read by the store whenever it needs it.
|
|
51
|
+
const config = useRef(edit);
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
config.current = edit;
|
|
54
|
+
});
|
|
55
|
+
useEffect(() => store.register(key, () => config.current), [store, key]);
|
|
56
|
+
const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
|
|
57
|
+
const editButton = useRef(null);
|
|
58
|
+
const container = useRef(null);
|
|
59
|
+
// Set by a keyboard save or Esc: the caret goes back to the edit button.
|
|
60
|
+
const refocus = useRef(false);
|
|
61
|
+
const blurTimer = useRef(undefined);
|
|
62
|
+
const isOpen = state.status !== 'closed';
|
|
63
|
+
const wasOpen = useRef(false);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (isOpen && !wasOpen.current) {
|
|
66
|
+
for (const selector of FOCUS_ORDER) {
|
|
67
|
+
const target = container.current?.querySelector(selector);
|
|
68
|
+
if (target) {
|
|
69
|
+
target.focus();
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Closed: the caret goes back to the value's edit button after a
|
|
75
|
+
// keyboard save or Esc, or whenever closing left it nowhere (a pick
|
|
76
|
+
// from a list), so the keyboard never loses its place.
|
|
77
|
+
if (!isOpen && wasOpen.current && (refocus.current || document.activeElement === document.body)) {
|
|
78
|
+
refocus.current = false;
|
|
79
|
+
editButton.current?.focus();
|
|
80
|
+
}
|
|
81
|
+
wasOpen.current = isOpen;
|
|
82
|
+
}, [isOpen]);
|
|
83
|
+
useEffect(() => () => window.clearTimeout(blurTimer.current), []);
|
|
84
|
+
// Whether an Esc is closing a list inside the control. Read on the way
|
|
85
|
+
// down (window, capture): a picker closes its list from a document
|
|
86
|
+
// listener, before React sees the key, and by then it reads as closed.
|
|
87
|
+
const escapeClosesList = useRef(false);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!isOpen)
|
|
90
|
+
return;
|
|
91
|
+
const onKeyDown = (event) => {
|
|
92
|
+
if (event.key !== 'Escape')
|
|
93
|
+
return;
|
|
94
|
+
escapeClosesList.current = Boolean(event.target?.closest?.('[aria-expanded="true"]'));
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
97
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
98
|
+
}, [isOpen]);
|
|
99
|
+
const start = useCallback(() => void store.start(key), [store, key]);
|
|
100
|
+
const commit = useCallback((value) => {
|
|
101
|
+
window.clearTimeout(blurTimer.current);
|
|
102
|
+
void store.commit(key, value);
|
|
103
|
+
}, [store, key]);
|
|
104
|
+
const cancel = useCallback(() => store.cancel(key), [store, key]);
|
|
105
|
+
const onShownClick = (event) => {
|
|
106
|
+
if (event.target.closest(INTERACTIVE))
|
|
107
|
+
return;
|
|
108
|
+
start();
|
|
109
|
+
};
|
|
110
|
+
const onKeyDown = (event) => {
|
|
111
|
+
if (state.status !== 'open')
|
|
112
|
+
return;
|
|
113
|
+
if (event.key === 'Escape') {
|
|
114
|
+
// A list open inside the control closes first; Esc again gives up.
|
|
115
|
+
if (event.defaultPrevented || escapeClosesList.current)
|
|
116
|
+
return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
refocus.current = true;
|
|
119
|
+
cancel();
|
|
120
|
+
}
|
|
121
|
+
else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
refocus.current = true;
|
|
124
|
+
commit();
|
|
125
|
+
}
|
|
126
|
+
else if (event.key === 'Enter' && edit.kind === 'text' && !event.shiftKey && !event.altKey) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
refocus.current = true;
|
|
129
|
+
commit();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
// Leaving the field saves it. Focus moving into the control's own list
|
|
133
|
+
// (a portal) still counts as inside: React sends its focus events here.
|
|
134
|
+
const onBlur = (event) => {
|
|
135
|
+
window.clearTimeout(blurTimer.current);
|
|
136
|
+
if (event.relatedTarget?.closest(LEAVING))
|
|
137
|
+
return;
|
|
138
|
+
blurTimer.current = window.setTimeout(() => {
|
|
139
|
+
if (store.state(key).status === 'open')
|
|
140
|
+
void store.commit(key);
|
|
141
|
+
}, 0);
|
|
142
|
+
};
|
|
143
|
+
const onFocus = () => window.clearTimeout(blurTimer.current);
|
|
144
|
+
if (state.status === 'closed') {
|
|
145
|
+
return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
|
|
146
|
+
}
|
|
147
|
+
const saving = state.status === 'saving';
|
|
148
|
+
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving ? 'saving' : state.error ? 'failed' : 'editing', onKeyDown: onKeyDown, onBlur: onBlur, onFocus: onFocus, className: cn('flex min-w-0 flex-col gap-1.5', layout === 'block' ? 'w-full' : 'w-fit max-w-full', className), children: [_jsx(Control, { render: edit.control, id: controlId, value: state.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && state.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }), _jsx("span", { id: describedBy, className: "type-body", children: saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (state.error && _jsx(FieldError, { className: "type-caption", children: state.error })) })] }));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* While a field holds something unsaved, leaving asks first: an in-app
|
|
152
|
+
* link or Back (React Router's blocker, so the app needs a data router),
|
|
153
|
+
* or closing the tab (`beforeunload`). Rendered by EntityView only while
|
|
154
|
+
* a field is open, so a view nobody's editing never blocks anything.
|
|
155
|
+
*/
|
|
156
|
+
export function LeaveGuard({ store }) {
|
|
157
|
+
const dirty = useSyncExternalStore(store.subscribe, () => store.dirty, () => store.dirty);
|
|
158
|
+
const blocker = useBlocker(({ currentLocation, nextLocation }) => store.dirty && currentLocation.pathname !== nextLocation.pathname);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!dirty)
|
|
161
|
+
return;
|
|
162
|
+
const onBeforeUnload = (event) => {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
};
|
|
165
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
166
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
167
|
+
}, [dirty]);
|
|
168
|
+
return (_jsx(Dialog, { open: blocker.state === 'blocked', onOpenChange: (open) => {
|
|
169
|
+
if (!open && blocker.state === 'blocked')
|
|
170
|
+
blocker.reset();
|
|
171
|
+
}, children: _jsxs(DialogContent, { showCloseButton: false, "data-leave-guard": "", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Discard your changes?" }), _jsx(DialogDescription, { children: "A field you're editing hasn't been saved. Leaving now loses what you typed." })] }), _jsxs(DialogFooter, { children: [_jsx(DialogClose, { render: _jsx(Button, { variant: "outline" }), children: "Keep editing" }), _jsx(Button, { variant: "destructive", onClick: () => {
|
|
172
|
+
store.discard();
|
|
173
|
+
if (blocker.state === 'blocked')
|
|
174
|
+
blocker.proceed();
|
|
175
|
+
}, children: "Discard" })] })] }) }));
|
|
176
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { AppError } from '../../api/contracts.js';
|
|
3
|
+
import { type EditInPlace } from './edit-in-place.js';
|
|
3
4
|
export type EntityViewField = {
|
|
4
5
|
label: string;
|
|
5
6
|
/**
|
|
@@ -10,6 +11,8 @@ export type EntityViewField = {
|
|
|
10
11
|
value: ReactNode;
|
|
11
12
|
/** What an empty value reads as: the plan's "not set" label. Defaults to "Not set". */
|
|
12
13
|
emptyLabel?: string;
|
|
14
|
+
/** Editable in place: `editInPlace({...})`. Unmarked, the value is read-only. */
|
|
15
|
+
edit?: EditInPlace;
|
|
13
16
|
};
|
|
14
17
|
/**
|
|
15
18
|
* A titled card on the view: either label/value rows (`fields`), or one
|
|
@@ -26,10 +29,16 @@ export type EntityViewSection = {
|
|
|
26
29
|
title: string;
|
|
27
30
|
content: ReactNode;
|
|
28
31
|
emptyLabel?: string;
|
|
32
|
+
/** The block is editable in place (long text), named by the section's title. */
|
|
33
|
+
edit?: EditInPlace;
|
|
29
34
|
};
|
|
30
35
|
export type EntityViewProps = {
|
|
31
36
|
/** The record's name or title, the page's <h1>. */
|
|
32
37
|
title?: string;
|
|
38
|
+
/** The title is editable in place; `label` is the field's ("Name"). */
|
|
39
|
+
titleEdit?: EditInPlace & {
|
|
40
|
+
label: string;
|
|
41
|
+
};
|
|
33
42
|
/**
|
|
34
43
|
* A few status-like values beside the title, as `<Badge>`s, or as the
|
|
35
44
|
* control that changes one when the plan makes it a quick action (a
|
|
@@ -49,4 +58,4 @@ export type EntityViewProps = {
|
|
|
49
58
|
label: string;
|
|
50
59
|
};
|
|
51
60
|
};
|
|
52
|
-
export declare function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
61
|
+
export declare function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -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
|
+
}
|