@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.
@@ -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). A field gets `edit`, a `content` section gets `edit`,
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
- // The latest config, read by the store whenever it needs it.
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}`. Alt+F10 reaches the floating one when it
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 (toolbar)
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 = toolbar && !readOnly && focused && editorState ? slashQuery(editorState) : null;
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(toolbar &&
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 rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', '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', className), children: [toolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
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]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3',
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 &nbsp;.
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; `false` leaves the text alone, its
27
- * shortcuts still working.
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 !== false && _jsx("div", { className: "h-9 border-b border-input" }), _jsxs("div", { className: "flex min-h-24 flex-col gap-2 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 }) }));
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.18.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": {