@tristan2828/ui-foundation 4.18.4 → 4.19.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
@@ -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
@@ -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
+ }
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.19.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": {