@tristan2828/ui-foundation 4.18.4 → 4.20.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/conventions/docs/add-an-entity.md +4 -1
- package/conventions/docs/cell-patterns.md +83 -1
- package/conventions/docs/entity-plan-template.md +3 -0
- package/dist/components/app/edit-in-place.d.ts +15 -0
- package/dist/components/app/editable-value.js +8 -5
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +19 -9
- package/dist/components/app/multi-pick.d.ts +43 -0
- package/dist/components/app/multi-pick.js +125 -0
- package/dist/components/app/rich-text-editor.d.ts +12 -3
- package/dist/components/app/rich-text-editor.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -367,7 +367,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
367
367
|
`defaultOpen`, non-modal, `onChange={(v) => props.commit(v)}`,
|
|
368
368
|
`onOpenChange={(open) => !open && props.cancel()}`; a yes/no switch
|
|
369
369
|
commits on change), `multi` (a multi reference; `isEqual` comparing
|
|
370
|
-
ids as a set
|
|
370
|
+
ids as a set; a field the plan marks `small` is `MultiPick`, on the
|
|
371
|
+
form too, every option loaded once, and saves each click itself:
|
|
372
|
+
`value` is the record's, `onPick` the single-field save as a
|
|
373
|
+
function of the record, cell pattern 18 "A small set"). A field gets `edit`, a `content` section gets `edit`,
|
|
371
374
|
the title gets `titleEdit={{ label, ...edit }}`, and a header badge
|
|
372
375
|
wraps itself in `<EditableValue label edit layout="inline">`. The
|
|
373
376
|
shown value stays exactly as before. A sub-record list the line
|
|
@@ -740,6 +740,7 @@ Save button, no Undo:
|
|
|
740
740
|
| `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line); with `autosave`, also a pause in typing | the same; with `autosave`, saves what's left and closes |
|
|
741
741
|
| `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
|
|
742
742
|
| `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
|
|
743
|
+
| `multi` over a small set (`MultiPick`) | each click, on its own | closes it: every pick is already saved |
|
|
743
744
|
|
|
744
745
|
In either multi picker (`MultiChoice`, `MultiReference`), on a form, in
|
|
745
746
|
a toolbar filter or in place, Escape never removes a pick: it closes the
|
|
@@ -747,6 +748,59 @@ list when it's open and leaves the value alone when it's closed (Base UI
|
|
|
747
748
|
would empty it). A pick comes off only by its chip's remove button,
|
|
748
749
|
Backspace in the empty input, or unpicking it in the list.
|
|
749
750
|
|
|
751
|
+
**A small set (`MultiPick`): every option shown, each click saves.** A
|
|
752
|
+
multi field whose options are few (up to about 25) and all loaded (a
|
|
753
|
+
dozen labels, a short tag list) uses `MultiPick` instead, on the form
|
|
754
|
+
and in place: every option a toggle chip with its icon or colour where
|
|
755
|
+
the app draws one (`icon`), the picked ones pressed with a check, and a
|
|
756
|
+
filter box above them, not in front of them. Larger sets, or ones that
|
|
757
|
+
grow with the data, keep `MultiReference` (typing searches the server)
|
|
758
|
+
or `MultiChoice`.
|
|
759
|
+
|
|
760
|
+
- **The plan says which**, by marking the field `small`; the control
|
|
761
|
+
isn't switched by option count. The two save differently in place
|
|
762
|
+
(each pick here, the whole set on leaving there) and load differently
|
|
763
|
+
(every option here, a search there), so a set crossing 25 would change
|
|
764
|
+
how saving works under the user.
|
|
765
|
+
- **Each click saves that one pick** (`onPick`), like a quick action
|
|
766
|
+
(pattern 17): the chip shows the pick at once with a spinner beside it
|
|
767
|
+
(`aria-busy`) until the server agrees. Nothing is disabled, and quick
|
|
768
|
+
picks each save, one after another, each built on the record's latest
|
|
769
|
+
value (a function change, as a sub-record list's is).
|
|
770
|
+
- **A refusal puts back that one pick**, with its reason under the chips
|
|
771
|
+
in the destructive text tone ("Couldn't add Archived: <the server's
|
|
772
|
+
reason>"), the chip described by it. Picks still saving carry on. If
|
|
773
|
+
the field closed before the answer, the reason is a toast.
|
|
774
|
+
- **Nothing is drafted**, so leaving the field (a click or Tab
|
|
775
|
+
elsewhere), Esc or Ctrl/Cmd+Enter only close it: there's no "Saving…"
|
|
776
|
+
under the field and nothing to give up.
|
|
777
|
+
- **Keyboard:** opening puts the caret in the filter box; Down goes to
|
|
778
|
+
the chips, Left/Right (Home/End) move along them, Space or Enter
|
|
779
|
+
toggles one, Up goes back to the box.
|
|
780
|
+
- Pass the record's value as `value`, not the control's `props.value`
|
|
781
|
+
(the draft, which never moves here), and keep the field's `save`: the
|
|
782
|
+
form's whole-set save, never called in place.
|
|
783
|
+
|
|
784
|
+
```tsx
|
|
785
|
+
const editField = useEditWidgetField(widget.id) // optimistic: false, toastOnError: false
|
|
786
|
+
labels: editInPlace({
|
|
787
|
+
kind: 'multi',
|
|
788
|
+
value: widget.labelIds,
|
|
789
|
+
isEqual: sameIds,
|
|
790
|
+
save: (labelIds) => editField.mutateAsync({ labelIds }),
|
|
791
|
+
control: (props) => (
|
|
792
|
+
<WidgetLabelsPicker id={props.id} value={widget.labelIds}
|
|
793
|
+
onPick={(id, picked) => editField.mutateAsync((current) => ({
|
|
794
|
+
labelIds: [...current.labelIds.filter((other) => other !== id), ...(picked ? [id] : [])],
|
|
795
|
+
}))}
|
|
796
|
+
aria-label={props.label} aria-describedby={props.describedBy} />
|
|
797
|
+
),
|
|
798
|
+
}),
|
|
799
|
+
// WidgetLabelsPicker: <MultiPick options={labels.map((l) => ({ id: l.id, label: l.name,
|
|
800
|
+
// icon: <TagIcon className={LABEL_COLOUR[l.slot]} /> }))} value onPick aria-label aria-describedby />
|
|
801
|
+
// On the form, the same control with onValueChange={field.onChange} instead of onPick.
|
|
802
|
+
```
|
|
803
|
+
|
|
750
804
|
Leaving means clicking or tabbing anywhere else. Unchanged closes with no
|
|
751
805
|
request; a value the form's schema refuses never leaves the browser and
|
|
752
806
|
shows the schema's message. Opening another value saves the open one
|
|
@@ -802,6 +856,32 @@ view, so a link in the notes is edited there, not followed, and the
|
|
|
802
856
|
editor loads on every visit. Use it where the notes are what the page is
|
|
803
857
|
for; elsewhere `autosave` alone keeps the read view at rest.
|
|
804
858
|
|
|
859
|
+
**Edited where it's read (`inline: true`, implies `alwaysEditable`)**,
|
|
860
|
+
Notion-style, for rich text: the editor from the start, drawn as the read
|
|
861
|
+
view rather than as a field (no border, no box, the text where the
|
|
862
|
+
section's text would be), so there's no mode change at all: click into
|
|
863
|
+
the words and type. Its control gets `inline: true` among its props;
|
|
864
|
+
pass it to `RichTextEditor`'s `inline`, with `toolbar="floating"`, which
|
|
865
|
+
keeps the toolbar over a selection (Alt+F10 reaches it) and the "/" menu
|
|
866
|
+
but drops the bar above the text. An empty one shows the editor's
|
|
867
|
+
`placeholder` (say "or type / for blocks" in it), not the view's "Not
|
|
868
|
+
set". It saves like any always-editable value (a pause after typing,
|
|
869
|
+
leaving the page saves first) and is otherwise the same: on the form the
|
|
870
|
+
control is the boxed editor with its fixed toolbar.
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
notes: editInPlace({
|
|
874
|
+
kind: 'long-text',
|
|
875
|
+
inline: true,
|
|
876
|
+
value: widget.notes,
|
|
877
|
+
schema: widgetFormSchema.shape.notes,
|
|
878
|
+
save: (notes) => editField.mutateAsync({ notes }),
|
|
879
|
+
// The form's own control, spreading what it gets onto RichTextEditor:
|
|
880
|
+
// inline={props.inline} toolbar={props.inline ? 'floating' : true}
|
|
881
|
+
control: (props) => <WidgetNotesEditor {...} />,
|
|
882
|
+
}),
|
|
883
|
+
```
|
|
884
|
+
|
|
805
885
|
```tsx
|
|
806
886
|
description: editInPlace({
|
|
807
887
|
kind: 'long-text',
|
|
@@ -865,7 +945,9 @@ const price = editInPlace({
|
|
|
865
945
|
beside it add a row or a column, and the toolbar's Table menu inserts
|
|
866
946
|
rows and columns, aligns a column and deletes.
|
|
867
947
|
`toolbar={false}` leaves out the toolbars and the slash menu where a
|
|
868
|
-
value is short and the shortcuts are enough
|
|
948
|
+
value is short and the shortcuts are enough; `toolbar="floating"` keeps
|
|
949
|
+
the floating one and the slash menu without the bar above (an `inline`
|
|
950
|
+
field's look).
|
|
869
951
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
870
952
|
`optimistic: false, toastOnError: false`, the entity's
|
|
871
953
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -39,6 +39,9 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
39
39
|
(to any number of records of another entity — name it; chips with a
|
|
40
40
|
searchable dropdown, names as badges in the table, an any-of filter;
|
|
41
41
|
the other entity's list endpoint must also accept `ids`),
|
|
42
|
+
either multi marked `small` when its options are few and stay few (up
|
|
43
|
+
to about 25: a dozen labels): every option shown at once as chips,
|
|
44
|
+
and editing in place saves each click,
|
|
42
45
|
`computed` (read-only: the server works it out from other data on
|
|
43
46
|
every read and never stores it — give its rule, and if it's a choice,
|
|
44
47
|
its values in sort order with a label for each; a read-only column, a filter and a sort the
|
|
@@ -24,6 +24,11 @@ export type EditControlProps<TDraft> = {
|
|
|
24
24
|
describedBy: string;
|
|
25
25
|
/** For a control that can't always produce a value to save (RichTextEditor's `onProblem`). */
|
|
26
26
|
onProblem: (message: string | null) => void;
|
|
27
|
+
/**
|
|
28
|
+
* The field is `inline`: always editable, drawn as the text it edits.
|
|
29
|
+
* Give it to RichTextEditor's `inline` (with `toolbar="floating"`).
|
|
30
|
+
*/
|
|
31
|
+
inline?: boolean;
|
|
27
32
|
};
|
|
28
33
|
/**
|
|
29
34
|
* How the value saves, which follows from its control:
|
|
@@ -60,6 +65,7 @@ export type EditInPlace = {
|
|
|
60
65
|
isEqual?: (a: unknown, b: unknown) => boolean;
|
|
61
66
|
autosave?: boolean;
|
|
62
67
|
alwaysEditable?: boolean;
|
|
68
|
+
inline?: boolean;
|
|
63
69
|
};
|
|
64
70
|
export type EditInPlaceOptions<TDraft, TValue> = {
|
|
65
71
|
kind: EditKind;
|
|
@@ -100,6 +106,15 @@ export type EditInPlaceOptions<TDraft, TValue> = {
|
|
|
100
106
|
* in rich text is edited there, not followed.
|
|
101
107
|
*/
|
|
102
108
|
alwaysEditable?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* For long text: always editable (implies `alwaysEditable`), and drawn
|
|
111
|
+
* as the read view, not as a field: no border, no box, typed into where
|
|
112
|
+
* it's read (Notion-style). The control gets `inline: true`; pass it to
|
|
113
|
+
* RichTextEditor's `inline`, with `toolbar="floating"` (the toolbar over
|
|
114
|
+
* a selection and the slash menu, no bar above the text). An empty one
|
|
115
|
+
* shows the editor's `placeholder`, not the view's "Not set".
|
|
116
|
+
*/
|
|
117
|
+
inline?: boolean;
|
|
103
118
|
};
|
|
104
119
|
/** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
|
|
105
120
|
export type EditListItemProps<TItem> = EditControlProps<TItem> & {
|
|
@@ -16,6 +16,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
16
|
// Ctrl/Cmd+Enter and Esc save what's left now and close it.
|
|
17
17
|
// - `alwaysEditable`: the control is shown at rest, with no pencil and no
|
|
18
18
|
// click to open, and never closes. Ctrl/Cmd+Enter and Esc save now.
|
|
19
|
+
// - `inline`: always editable, and the control is told to look like the
|
|
20
|
+
// value it edits (`inline` among its props).
|
|
19
21
|
import { CheckIcon, PencilIcon } from 'lucide-react';
|
|
20
22
|
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
21
23
|
import { useBlocker } from 'react-router';
|
|
@@ -58,10 +60,13 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
58
60
|
const key = useId();
|
|
59
61
|
const controlId = `${key}-control`;
|
|
60
62
|
const describedBy = `${key}-status`;
|
|
61
|
-
|
|
63
|
+
const always = Boolean(edit.alwaysEditable || edit.inline);
|
|
64
|
+
const autosave = always || Boolean(edit.autosave);
|
|
65
|
+
// The latest config, read by the store whenever it needs it (`inline`
|
|
66
|
+
// is always editable to it).
|
|
62
67
|
const config = useRef(edit);
|
|
63
68
|
useLayoutEffect(() => {
|
|
64
|
-
config.current = edit;
|
|
69
|
+
config.current = always && !edit.alwaysEditable ? { ...edit, alwaysEditable: true } : edit;
|
|
65
70
|
});
|
|
66
71
|
useEffect(() => store.register(key, () => config.current), [store, key]);
|
|
67
72
|
const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
|
|
@@ -72,8 +77,6 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
72
77
|
// Set by a keyboard save or Esc: the caret goes back to the edit button.
|
|
73
78
|
const refocus = useRef(false);
|
|
74
79
|
const blurTimer = useRef(undefined);
|
|
75
|
-
const always = Boolean(edit.alwaysEditable);
|
|
76
|
-
const autosave = always || Boolean(edit.autosave);
|
|
77
80
|
// An always-editable control starts over (it's uncontrolled: a rich-text
|
|
78
81
|
// editor reads its value once) when the value changed elsewhere while
|
|
79
82
|
// nothing of its own was unsaved and the caret wasn't in it.
|
|
@@ -209,7 +212,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
|
|
|
209
212
|
const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
|
|
210
213
|
const saving = open.status === 'saving';
|
|
211
214
|
const autosaving = open.autosave === 'saving';
|
|
212
|
-
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.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: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
|
|
215
|
+
return (_jsxs("span", { ref: container, "data-slot": "editable-value", "data-state": saving || autosaving ? 'saving' : open.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: open.draft, onChange: (value) => store.change(key, value), commit: commit, cancel: cancel, disabled: saving, invalid: !saving && open.error !== null, label: label, describedBy: describedBy, onProblem: (problem) => store.report(key, problem), inline: Boolean(edit.inline) }, open.revision ?? 0), _jsx("span", { id: describedBy, className: "type-body", children: autosave ? (_jsxs(_Fragment, { children: [open.error && _jsx(FieldError, { className: "type-caption", children: open.error }), _jsx("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: autosaving ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (open.autosave === 'saved' &&
|
|
213
216
|
!open.error && (_jsxs(_Fragment, { children: [_jsx(CheckIcon, { "aria-hidden": "true", className: "size-3.5" }), "Saved"] }))) })] })) : 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"] })) : (open.error && _jsx(FieldError, { className: "type-caption", children: open.error })) })] }));
|
|
214
217
|
}
|
|
215
218
|
/**
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, toolbar, inline, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -33,11 +33,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
33
33
|
// length as it nears `maxLength`.
|
|
34
34
|
// - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
|
|
35
35
|
// floating one over selected words (rich-text-floating-toolbar.tsx),
|
|
36
|
-
// unless `toolbar={false}
|
|
37
|
-
// shows, else the fixed one.
|
|
36
|
+
// unless `toolbar={false}`; `toolbar="floating"` leaves out the fixed
|
|
37
|
+
// one. Alt+F10 reaches the floating one when it shows, else the fixed one.
|
|
38
38
|
// - A slash menu (rich-text-slash.ts, rich-text-slash-menu.tsx): "/" at a
|
|
39
39
|
// line's start lists the blocks it can become, also unless
|
|
40
40
|
// `toolbar={false}`.
|
|
41
|
+
// - `inline`: the field drawn as the text it edits (no border, no box),
|
|
42
|
+
// for notes that are always editable where they're read.
|
|
41
43
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
42
44
|
import { createPortal } from 'react-dom';
|
|
43
45
|
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
@@ -238,7 +240,7 @@ function applyLink(view, target, href) {
|
|
|
238
240
|
}
|
|
239
241
|
view.dispatch(tr.scrollIntoView());
|
|
240
242
|
}
|
|
241
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
243
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, toolbar = true, inline = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
242
244
|
const rootRef = useRef(null);
|
|
243
245
|
const editorRef = useRef(null);
|
|
244
246
|
const [linkBox, setLinkBox] = useState(null);
|
|
@@ -273,6 +275,10 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
273
275
|
useEffect(() => {
|
|
274
276
|
latest.current = { onChange, onProblem, readOnly, placeholder };
|
|
275
277
|
});
|
|
278
|
+
// The fixed bar above the text, and the menus over it (the floating
|
|
279
|
+
// toolbar and the slash menu), which `'floating'` keeps on their own.
|
|
280
|
+
const fixedToolbar = toolbar === true;
|
|
281
|
+
const menus = toolbar !== false;
|
|
276
282
|
const ids = useId();
|
|
277
283
|
const counterId = `${ids}-count`;
|
|
278
284
|
// The editable element's id: the app's, or one of its own, so the
|
|
@@ -297,11 +303,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
297
303
|
attributes['aria-invalid'] = 'true';
|
|
298
304
|
if (placeholder)
|
|
299
305
|
attributes['aria-placeholder'] = placeholder;
|
|
300
|
-
if (
|
|
306
|
+
if (menus)
|
|
301
307
|
attributes['aria-keyshortcuts'] = 'Alt+F10';
|
|
302
308
|
// The slash menu: open while a "/" typed at a line's start is followed
|
|
303
309
|
// by something some block answers to, and the field has focus.
|
|
304
|
-
const slash =
|
|
310
|
+
const slash = menus && !readOnly && focused && editorState ? slashQuery(editorState) : null;
|
|
305
311
|
const slashMatches = slash ? slashItems(slash.query) : [];
|
|
306
312
|
const slashOpen = slash !== null && slashMatches.length > 0;
|
|
307
313
|
const slashMenuKey = slash ? `${slash.from}:${slash.query}` : '';
|
|
@@ -674,7 +680,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
674
680
|
// The floating toolbar: words selected, focus in the field, the mouse up,
|
|
675
681
|
// and nothing else open over the text.
|
|
676
682
|
const selection = editorState?.selection;
|
|
677
|
-
const floatingOpen = Boolean(
|
|
683
|
+
const floatingOpen = Boolean(menus &&
|
|
678
684
|
!readOnly &&
|
|
679
685
|
editorView &&
|
|
680
686
|
focused &&
|
|
@@ -696,15 +702,19 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
696
702
|
setLinkBox(null);
|
|
697
703
|
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
698
704
|
};
|
|
699
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col
|
|
705
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col bg-transparent type-body text-foreground break-words',
|
|
706
|
+
// Inline, it's the text itself: the caret shows where it's
|
|
707
|
+
// edited, and a refusal's reason under it says what's wrong.
|
|
708
|
+
!inline &&
|
|
709
|
+
'rounded-lg border border-input transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', !inline && 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', className), children: [fixedToolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
|
|
700
710
|
// Only once the editor is made: until then the editable element,
|
|
701
711
|
// and its id, aren't in the page (axe: aria-valid-attr-value).
|
|
702
|
-
controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
|
|
712
|
+
controls: editorState ? editableId : undefined, onFormat: runFormat, onTable: runTable, text: textElement })), _jsx("div", { ref: rootRef, className: cn(!inline && 'min-h-24 px-2.5 py-2',
|
|
703
713
|
// Read-only text is muted; the toolbar's buttons say so by being disabled.
|
|
704
714
|
'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
705
715
|
// The same type roles <Markdown> uses, so text reads the same
|
|
706
716
|
// being edited as being read.
|
|
707
|
-
'[&_.ProseMirror]:flex [&_.ProseMirror]:
|
|
717
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', !inline && '[&_.ProseMirror]:min-h-20',
|
|
708
718
|
// ProseMirror's own required style (prosemirror-view's CSS): spaces
|
|
709
719
|
// as typed, so one at the end of a line isn't kept as .
|
|
710
720
|
'[&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:break-words', '[&_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',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type MultiPickOption<Id extends string | number> = {
|
|
3
|
+
id: Id;
|
|
4
|
+
label: string;
|
|
5
|
+
/**
|
|
6
|
+
* The icon or colour the app draws for this value elsewhere (its
|
|
7
|
+
* category glyph in its categorical slot), shown before the label. Drawn
|
|
8
|
+
* `aria-hidden`: the label names it.
|
|
9
|
+
*/
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
export type MultiPickProps<Id extends string | number> = {
|
|
13
|
+
/** Every option, in display order. */
|
|
14
|
+
options: readonly MultiPickOption<Id>[];
|
|
15
|
+
/**
|
|
16
|
+
* The picked ids. Edited in place, the record's saved value
|
|
17
|
+
* (`widget.tagIds`), not the control's `props.value`: nothing is drafted.
|
|
18
|
+
*/
|
|
19
|
+
value: readonly Id[];
|
|
20
|
+
/** On a form: the value changed. */
|
|
21
|
+
onValueChange?: (value: Id[]) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Edited in place: saves one pick (`picked` true adds `id`, false removes
|
|
24
|
+
* it), resolving once saved and rejecting with the AppError. Build it on
|
|
25
|
+
* the record's latest value, so quick picks don't lose each other:
|
|
26
|
+
* `(id, picked) => save((widget) => ({ tags: toggled(widget.tags, id, picked) }))`.
|
|
27
|
+
*/
|
|
28
|
+
onPick?: (id: Id, picked: boolean) => Promise<unknown>;
|
|
29
|
+
/** The field's label: the chips' group is named by it, the filter box "Filter <label>". */
|
|
30
|
+
'aria-label': string;
|
|
31
|
+
/** The id of a line under the control (an edit in place's status line). */
|
|
32
|
+
'aria-describedby'?: string;
|
|
33
|
+
/** Id for the filter box, so a <FieldLabel htmlFor> labels it on a form. */
|
|
34
|
+
id?: string;
|
|
35
|
+
/** The filter box's placeholder. */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Shown when there are no options at all. */
|
|
38
|
+
emptyText?: string;
|
|
39
|
+
/** Shown, not changeable. */
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function MultiPick<Id extends string | number>({ options, value, onValueChange, onPick, 'aria-label': label, 'aria-describedby': describedBy, id, placeholder, emptyText, readOnly, className, }: MultiPickProps<Id>): import("react").JSX.Element;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A picker for a small set (up to about 25 options): every option shown
|
|
3
|
+
// at once as a toggle chip, with a box above that filters them. The
|
|
4
|
+
// control for a multi field whose options are few and all loaded (tags, a
|
|
5
|
+
// dozen categories), on a form and edited in place (cell-patterns.md
|
|
6
|
+
// "Editable value", "A small set"). A large set keeps MultiReference:
|
|
7
|
+
// typing searches the server.
|
|
8
|
+
//
|
|
9
|
+
// Which one is the app's choice, by the control it renders, not switched
|
|
10
|
+
// here by option count: the two save differently in place (each pick at
|
|
11
|
+
// once here, the whole set on leaving there) and load differently (every
|
|
12
|
+
// option here, a server search there), so a set crossing 25 would change
|
|
13
|
+
// how saving works under the user. The plan names the set small.
|
|
14
|
+
//
|
|
15
|
+
// - **Edited in place, each pick saves on its own** (`onPick`), like a
|
|
16
|
+
// quick action (pattern 17): the chip shows the pick at once with a
|
|
17
|
+
// spinner beside it until the server answers. A refusal puts that one
|
|
18
|
+
// chip back, with its reason under the chips; picks still saving carry
|
|
19
|
+
// on. Nothing is drafted, so leaving the field or Esc only closes it.
|
|
20
|
+
// - **On a form** (`onValueChange`): a click changes the value, saved
|
|
21
|
+
// with the form.
|
|
22
|
+
// - Keyboard: the filter box first; Down goes to the chips, Left/Right
|
|
23
|
+
// (Home/End) move along them, Up goes back to the box, and Space or
|
|
24
|
+
// Enter toggles a chip.
|
|
25
|
+
import { CheckIcon } from 'lucide-react';
|
|
26
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
27
|
+
import { toast } from 'sonner';
|
|
28
|
+
import { cn } from 'cn';
|
|
29
|
+
import { FieldError } from '../ui/field.js';
|
|
30
|
+
import { Input } from '../ui/input.js';
|
|
31
|
+
import { Spinner } from '../ui/spinner.js';
|
|
32
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
33
|
+
import { messageOf } from './edit-in-place-store.js';
|
|
34
|
+
export function MultiPick({ options, value, onValueChange, onPick, 'aria-label': label, 'aria-describedby': describedBy, id, placeholder = 'Filter', emptyText = 'No options.', readOnly = false, className, }) {
|
|
35
|
+
const key = useId();
|
|
36
|
+
const [filter, setFilter] = useState('');
|
|
37
|
+
const [pending, setPending] = useState(() => new Map());
|
|
38
|
+
const [refused, setRefused] = useState(() => new Map());
|
|
39
|
+
const filterBox = useRef(null);
|
|
40
|
+
const chips = useRef(null);
|
|
41
|
+
const mounted = useRef(true);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
mounted.current = true;
|
|
44
|
+
return () => {
|
|
45
|
+
mounted.current = false;
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
// The saved value moved (a save's record arrived): answered picks give
|
|
49
|
+
// way to it. Picks still saving keep showing.
|
|
50
|
+
const [seenValue, setSeenValue] = useState(value);
|
|
51
|
+
if (seenValue !== value) {
|
|
52
|
+
setSeenValue(value);
|
|
53
|
+
if ([...pending.values()].some((entry) => entry.inFlight === 0))
|
|
54
|
+
setPending(new Map([...pending].filter(([, entry]) => entry.inFlight > 0)));
|
|
55
|
+
}
|
|
56
|
+
const isPicked = (option) => pending.get(option)?.picked ?? value.includes(option);
|
|
57
|
+
const shown = options.filter((option) => isPicked(option.id)).map((option) => option.id);
|
|
58
|
+
const settle = (option, ok) => setPending((current) => {
|
|
59
|
+
const entry = current.get(option);
|
|
60
|
+
if (!entry)
|
|
61
|
+
return current;
|
|
62
|
+
const next = new Map(current);
|
|
63
|
+
const inFlight = entry.inFlight - 1;
|
|
64
|
+
// Refused with nothing else of this pick out: back to the saved value.
|
|
65
|
+
if (!ok && inFlight === 0)
|
|
66
|
+
next.delete(option);
|
|
67
|
+
else
|
|
68
|
+
next.set(option, { ...entry, inFlight });
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
const toggle = (option, picked) => {
|
|
72
|
+
if (!onPick) {
|
|
73
|
+
// In the options' order, keeping any picked id with no option.
|
|
74
|
+
const known = new Set(options.map((other) => other.id));
|
|
75
|
+
onValueChange?.([
|
|
76
|
+
...options.filter((other) => (other.id === option.id ? picked : isPicked(other.id))).map((other) => other.id),
|
|
77
|
+
...value.filter((other) => !known.has(other)),
|
|
78
|
+
]);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setRefused((current) => without(current, option.id));
|
|
82
|
+
setPending((current) => new Map(current).set(option.id, { picked, inFlight: (current.get(option.id)?.inFlight ?? 0) + 1 }));
|
|
83
|
+
onPick(option.id, picked).then(() => settle(option.id, true), (error) => {
|
|
84
|
+
settle(option.id, false);
|
|
85
|
+
const reason = `Couldn't ${picked ? 'add' : 'remove'} ${option.label}: ${messageOf(error)}`;
|
|
86
|
+
// Closed before the answer: the reason has nowhere else to show.
|
|
87
|
+
if (mounted.current)
|
|
88
|
+
setRefused((current) => new Map(current).set(option.id, reason));
|
|
89
|
+
else
|
|
90
|
+
toast.error(reason);
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
const needle = filter.trim().toLowerCase();
|
|
94
|
+
const visible = needle ? options.filter((option) => option.label.toLowerCase().includes(needle)) : options;
|
|
95
|
+
// Down from the box: into the chips, at the one the group would tab to.
|
|
96
|
+
const onFilterKeyDown = (event) => {
|
|
97
|
+
if (event.key !== 'ArrowDown')
|
|
98
|
+
return;
|
|
99
|
+
const items = [...(chips.current?.querySelectorAll('[data-slot="toggle-group-item"]') ?? [])];
|
|
100
|
+
const target = items.find((item) => item.tabIndex === 0) ?? items[0];
|
|
101
|
+
if (!target)
|
|
102
|
+
return;
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
target.focus();
|
|
105
|
+
};
|
|
106
|
+
const onChipsKeyDown = (event) => {
|
|
107
|
+
if (event.key !== 'ArrowUp')
|
|
108
|
+
return;
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
filterBox.current?.focus();
|
|
111
|
+
};
|
|
112
|
+
const errorId = (option) => `${key}-refused-${String(option)}`;
|
|
113
|
+
return (_jsxs("div", { "data-slot": "multi-pick", className: cn('flex w-full min-w-0 flex-col gap-2', className), children: [_jsx(Input, { ref: filterBox, id: id, type: "search", value: filter, onChange: (event) => setFilter(event.target.value), onKeyDown: onFilterKeyDown, placeholder: placeholder, "aria-label": `Filter ${label}`, "aria-describedby": describedBy, autoComplete: "off", readOnly: readOnly, className: "max-w-xs" }), visible.length === 0 ? (_jsx("p", { className: "type-caption text-muted-foreground", children: options.length === 0 ? emptyText : 'No matches.' })) : (_jsx(ToggleGroup, { ref: chips, multiple: true, variant: "outline", size: "sm", spacing: 1.5, value: shown.map(String), disabled: readOnly, onKeyDown: onChipsKeyDown, "aria-label": label, "aria-describedby": describedBy, className: "flex-wrap", children: visible.map((option) => {
|
|
114
|
+
const picked = isPicked(option.id);
|
|
115
|
+
const saving = (pending.get(option.id)?.inFlight ?? 0) > 0;
|
|
116
|
+
return (_jsxs(ToggleGroupItem, { value: String(option.id), onPressedChange: (next) => toggle(option, next), "aria-busy": saving || undefined, "aria-describedby": refused.has(option.id) ? errorId(option.id) : undefined, children: [option.icon && (_jsx("span", { "aria-hidden": "true", className: "inline-flex shrink-0", children: option.icon })), option.label, _jsx("span", { "aria-hidden": "true", className: "inline-flex size-3.5 shrink-0 items-center justify-center", children: saving ? (_jsx(Spinner, { className: "size-3.5 text-muted-foreground" })) : (picked && _jsx(CheckIcon, { className: "size-3.5" })) })] }, option.id));
|
|
117
|
+
}) })), refused.size > 0 && (_jsx(FieldError, { className: "type-caption flex flex-col gap-0.5", children: [...refused].map(([option, reason]) => (_jsx("span", { id: errorId(option), children: reason }, String(option)))) }))] }));
|
|
118
|
+
}
|
|
119
|
+
function without(map, key) {
|
|
120
|
+
if (!map.has(key))
|
|
121
|
+
return map;
|
|
122
|
+
const next = new Map(map);
|
|
123
|
+
next.delete(key);
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
@@ -23,10 +23,19 @@ export type RichTextEditorProps = {
|
|
|
23
23
|
* lists, link, …), each button with its shortcut, and one floating over
|
|
24
24
|
* selected words (bold to link). Alt+F10 reaches them from the text.
|
|
25
25
|
* With them, "/" at a line's start lists the blocks the line can become
|
|
26
|
-
* (the slash menu). On by default; `
|
|
27
|
-
*
|
|
26
|
+
* (the slash menu). On by default; `'floating'` keeps the floating one
|
|
27
|
+
* and the slash menu but no bar above the text (Notion-style, with
|
|
28
|
+
* `inline`); `false` leaves the text alone, its shortcuts still working.
|
|
28
29
|
*/
|
|
29
|
-
toolbar?: boolean;
|
|
30
|
+
toolbar?: boolean | 'floating';
|
|
31
|
+
/**
|
|
32
|
+
* Looks like the text it edits: no border, no box, no padding, so it
|
|
33
|
+
* sits where `<Markdown>` would and reads the same. For notes that are
|
|
34
|
+
* always editable in place (`editInPlace`'s `inline`, which the control
|
|
35
|
+
* gets as `props.inline`); give it `toolbar="floating"` and a
|
|
36
|
+
* `placeholder`, which is all an empty one shows.
|
|
37
|
+
*/
|
|
38
|
+
inline?: boolean;
|
|
30
39
|
className?: string;
|
|
31
40
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
32
41
|
'aria-label'?: string;
|
|
@@ -21,5 +21,5 @@ import { cn } from 'cn';
|
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
22
|
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
23
|
export function RichTextEditor(props) {
|
|
24
|
-
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex w-full flex-col rounded-lg border border-input', props.className), children: [props.toolbar
|
|
24
|
+
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 }) }));
|
|
25
25
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
|
14
14
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
15
15
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
16
16
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
17
|
+
export { MultiPick, type MultiPickOption, type MultiPickProps } from './components/app/multi-pick.js';
|
|
17
18
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
18
19
|
export { ListEditor } from './components/app/list-editor.js';
|
|
19
20
|
export { PasswordInput } from './components/app/password-input.js';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
|
19
19
|
export { Markdown } from './components/app/markdown.js';
|
|
20
20
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
21
21
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
22
|
+
export { MultiPick } from './components/app/multi-pick.js';
|
|
22
23
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
23
24
|
export { ListEditor } from './components/app/list-editor.js';
|
|
24
25
|
export { PasswordInput } from './components/app/password-input.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.20.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": {
|