@tristan2828/ui-foundation 4.40.0 → 4.41.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.
@@ -26,14 +26,14 @@
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
28
28
  - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–759
29
- - [18. Editable value](#18-editable-value) · lines 761–1176
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1020–1077
31
- - [In a table's rows](#in-a-tables-rows) · lines 1079–1164
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1166–1176
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1178–1244
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1246–1310
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1312–1377
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1379–1407
29
+ - [18. Editable value](#18-editable-value) · lines 761–1207
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1023–1108
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1110–1195
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1197–1207
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1209–1275
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1277–1341
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1343–1408
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1410–1438
37
37
 
38
38
  <!-- /index -->
39
39
 
@@ -781,8 +781,11 @@ the form stay.
781
781
  **At rest: says it's editable without shouting.** The value reads as it
782
782
  always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
783
783
  a real button ("Edit <label>"), so Tab reaches it and Enter opens the
784
- field, and its focus ring tints the value too. On a touch screen the
785
- pencil always shows. A click anywhere on the value opens it, except on a
784
+ field, and its focus ring tints the value too. Muted text inside it (the
785
+ view's "Not set", a "No caption", a done item) turns `text-foreground`
786
+ on that tint, since muted on `bg-muted` is under 4.5:1; the package does
787
+ it, so an app just uses `text-muted-foreground` as anywhere else. On a
788
+ touch screen the pencil always shows. A click anywhere on the value opens it, except on a
786
789
  link inside it (a reference to a record with a view, a link in the
787
790
  notes), which still navigates.
788
791
 
@@ -1056,6 +1059,14 @@ Checklist is the reference (`checklist` in `widgetEdits`). It's built on
1056
1059
  option is still inside the row, and Enter in the row's text box or
1057
1060
  leaving the row saves the item, ids only on the wire. A new item starts
1058
1061
  with no pick, which the list's schema refuses on that row.
1062
+ - **Items that come from elsewhere** (a record's files, added by upload):
1063
+ `add` in place of `newItem` and `addLabel`. It renders the app's own
1064
+ control (an "Add files" button over a hidden file input) and is given
1065
+ `add(items)`, which saves them at the end through `save`, as a move is
1066
+ ("Saving…", a refusal under the list, the caret back on the control),
1067
+ plus `items` (to stop at a limit), `disabled` (a change is saving) and
1068
+ `onProblem(message)` (an upload refused, under the list). Each item
1069
+ then reads, edits, moves and removes like a checklist's.
1059
1070
 
1060
1071
  ```tsx
1061
1072
  const checklist = editInPlace<ChecklistItem>({
@@ -1076,6 +1087,26 @@ The row's fields are one component shared with the form
1076
1087
  (`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
1077
1088
  position ("Item 2 text") as the row buttons name the item.
1078
1089
 
1090
+ A list added by upload:
1091
+
1092
+ ```tsx
1093
+ const files = editInPlace<Attachment>({
1094
+ kind: 'list',
1095
+ value: record.attachments,
1096
+ save: (change) => editField.mutateAsync((current) => ({ attachments: change(current.attachments) })),
1097
+ itemName: (index) => record.attachments[index]?.name ?? `file ${index + 1}`,
1098
+ add: ({ add, items, disabled, onProblem }) => (
1099
+ <UploadButton // the app's: a button over <input type="file" hidden>
1100
+ disabled={disabled || items.length >= MAX}
1101
+ onUploaded={(stored) => add(stored.map(toAttachment))}
1102
+ onRefused={(error) => onProblem(error.message)}
1103
+ />
1104
+ ),
1105
+ renderShown: (file) => <FileRow file={file} />, // its thumbnail, name and caption
1106
+ renderItem: (props) => <CaptionInput {...props} />, // what edits on a click: the caption
1107
+ })
1108
+ ```
1109
+
1079
1110
  ### In a table's rows
1080
1111
 
1081
1112
  For a **small entity edited in its table** (a name and a few fields,
@@ -123,14 +123,31 @@ export type EditListItemProps<TItem> = EditControlProps<TItem> & {
123
123
  /** The item being added at the end, not one already in the list. */
124
124
  isNew: boolean;
125
125
  };
126
+ /** What a list's own `add` is given: how to put new items at the end, saved as the list's other changes are. */
127
+ export type EditListAddProps<TItem> = {
128
+ /**
129
+ * Saves these items at the end of the list, through the list's `save`
130
+ * (built on its latest list, after whatever is open and whatever is
131
+ * still saving). "Saving…" and a refusal show under the list, as for a
132
+ * move. Resolves once it's done: `true` saved, `false` refused.
133
+ */
134
+ add: (items: TItem[]) => Promise<boolean>;
135
+ /** The list as saved: to stop adding at a limit (`items.length >= max`). */
136
+ items: readonly TItem[];
137
+ /** A change is saving: the list's buttons wait, and so should this one. */
138
+ disabled: boolean;
139
+ /** Why the add can't happen (an upload refused), under the list where a refused save shows; `null` clears it. */
140
+ onProblem: (message: string | null) => void;
141
+ };
126
142
  /**
127
- * A list of sub-items edited in place (a checklist, a list of links), on
128
- * a `content` section of EntityView. Make one with `editInPlace({ kind:
129
- * 'list', ... })`. Every item reads as `renderShown` (a quick action, such
130
- * as its done box, still works there); clicking it or its pencil turns it
131
- * into `renderItem`, the form's own row. "Add" at the end opens a new item;
132
- * move up, move down and remove are ListEditor's buttons. Each change is one
133
- * save of the whole list.
143
+ * A list of sub-items edited in place (a checklist, a list of links, a
144
+ * record's files), on a `content` section of EntityView. Make one with
145
+ * `editInPlace({ kind: 'list', ... })`. Every item reads as `renderShown`
146
+ * (a quick action, such as its done box, still works there); clicking it
147
+ * or its pencil turns it into `renderItem`, the form's own row. "Add" at
148
+ * the end opens a new item (`newItem`), or the app's own `add` puts items
149
+ * there (an upload); move up, move down and remove are ListEditor's
150
+ * buttons. Each change is one save of the whole list.
134
151
  */
135
152
  export type EditInPlaceListOptions<TItem> = {
136
153
  kind: 'list';
@@ -145,12 +162,19 @@ export type EditInPlaceListOptions<TItem> = {
145
162
  renderItem: (props: EditListItemProps<TItem>) => ReactNode;
146
163
  /** How the item reads when it isn't being edited. Its own controls (a done box) keep working. */
147
164
  renderShown: (item: TItem, index: number) => ReactNode;
148
- /** A blank item, what "Add" starts from. */
149
- newItem: () => TItem;
165
+ /** A blank item, what "Add" starts from. Not needed with `add`; one or the other is. */
166
+ newItem?: () => TItem;
167
+ /**
168
+ * In place of "Add" and its blank item: what adds items, such as a file
169
+ * upload or a picker, e.g. a button that uploads, then calls
170
+ * `props.add(stored.map(toItem))`. The items save at the end of the list
171
+ * through `save`, so they read, move and save like any other.
172
+ */
173
+ add?: (props: EditListAddProps<TItem>) => ReactNode;
150
174
  /** How the buttons name an item, as on the form: (i) => `item ${i + 1}` gives "Move item 2 up". */
151
175
  itemName: (index: number) => string;
152
- /** The Add button's text, e.g. "Add item". */
153
- addLabel: string;
176
+ /** The Add button's text, e.g. "Add item". Not needed with `add`, whose control has its own. */
177
+ addLabel?: string;
154
178
  /**
155
179
  * The form's schema for the whole list (`widgetFormSchema.shape.checklist`).
156
180
  * Each change is checked as the list it would make; a message about an
@@ -169,7 +193,8 @@ export type EditInPlaceListOptions<TItem> = {
169
193
  save: (change: (current: TItem[]) => TItem[]) => Promise<unknown>;
170
194
  };
171
195
  /** A list marked editable in place. Make one with `editInPlace({ kind: 'list', ... })`. */
172
- export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save'> & {
196
+ export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save' | 'add'> & {
197
+ add?: (props: EditListAddProps<unknown>) => ReactNode;
173
198
  renderItem: (props: EditListItemProps<unknown>) => ReactNode;
174
199
  renderShown: (item: unknown, index: number) => ReactNode;
175
200
  schema?: {
@@ -9,7 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // form's row (`renderItem`), an EditableValue like any field: leaving or
10
10
  // Enter saves, Esc gives up, one field open at a time.
11
11
  // - "Add" at the end opens a new item the same way. Enter saves it and
12
- // opens the next, so a run of items goes in from the keyboard.
12
+ // opens the next, so a run of items goes in from the keyboard. Or the
13
+ // app's own `add` (an upload) puts items at the end, saved as a move is.
13
14
  // - Move and remove save at once. Whatever is open is saved first; while
14
15
  // the change saves, the buttons wait, "Saving…" shows under the list,
15
16
  // and a refusal leaves the list as it was with the reason under it.
@@ -56,16 +57,22 @@ function checkItem(edit, items, index, draft) {
56
57
  const issue = issues.find(({ path }) => path?.[0] === index) ?? issues.find(({ path }) => !path?.length) ?? issues[0];
57
58
  return { success: false, error: { issues: issue ? [issue] : [] } };
58
59
  }
60
+ // The app's own add, as a component of its own: it renders the app's
61
+ // control with what it's given, and holds nothing.
62
+ function ListAdd({ render, ...props }) {
63
+ return _jsx("div", { "data-list-add-slot": "", children: render(props) });
64
+ }
59
65
  export function EditableList({ label, edit, emptyLabel }) {
60
66
  const store = useContext(EditInPlaceContext);
61
67
  if (!store)
62
68
  throw new Error('A list edited in place works inside an <EntityView>, which runs editing in place.');
63
69
  const items = edit.value;
64
70
  const statusId = useId();
65
- const [status, setStatus] = useState({ saving: false, error: null });
71
+ const [status, setStatus] = useState({ saving: false, inFlight: 0, error: null });
66
72
  // The buttons are disabled while a change saves, which drops the caret.
67
73
  // Once the list shows the outcome it goes to the first of these buttons
68
- // (by name) that's there and enabled, else to Add.
74
+ // (by name) that's there and enabled, else to Add (or the app's add's
75
+ // first button).
69
76
  const root = useRef(null);
70
77
  const focusNext = useRef(null);
71
78
  useEffect(() => {
@@ -75,26 +82,30 @@ export function EditableList({ label, edit, emptyLabel }) {
75
82
  focusNext.current = null;
76
83
  const buttons = [...(root.current?.querySelectorAll('button:not(:disabled)') ?? [])];
77
84
  const target = names.map((name) => buttons.find((button) => button.getAttribute('aria-label') === name)).find(Boolean);
78
- (target ?? root.current?.querySelector('[data-list-add]:not(:disabled)'))?.focus();
85
+ (target ?? root.current?.querySelector('[data-list-add]:not(:disabled), [data-list-add-slot] button:not(:disabled)'))?.focus();
79
86
  }, [items, status.saving]);
80
- // Move and remove: save the open field first (if it can't save, it stays
81
- // and nothing else happens), then the change.
82
- const act = async (change, focusAfter, focusIfRefused) => {
83
- if (status.saving)
84
- return;
87
+ // Move, remove and the app's own add: save the open field first (if it
88
+ // can't save, it stays and nothing else happens), then the change. An
89
+ // add that lands while a change saves (an upload finishing) still goes:
90
+ // `save` builds on the latest list, and "Saving…" stays until both are done.
91
+ const act = async (change, focusAfter, focusIfRefused, waits = true) => {
92
+ if (waits && status.saving)
93
+ return false;
85
94
  const open = store.open;
86
95
  if (open !== null && !(await store.commit(open)))
87
- return;
88
- setStatus({ saving: true, error: null });
96
+ return false;
97
+ setStatus((now) => ({ saving: true, inFlight: now.inFlight + 1, error: null }));
98
+ let error = null;
89
99
  try {
90
100
  await edit.save(change);
91
101
  focusNext.current = focusAfter;
92
- setStatus({ saving: false, error: null });
93
102
  }
94
- catch (error) {
103
+ catch (refused) {
95
104
  focusNext.current = focusIfRefused;
96
- setStatus({ saving: false, error: messageOf(error) });
105
+ error = messageOf(refused);
97
106
  }
107
+ setStatus((now) => ({ saving: now.inFlight > 1, inFlight: now.inFlight - 1, error: error ?? now.error }));
108
+ return error === null;
98
109
  };
99
110
  const move = (from, to) => {
100
111
  const direction = to < from ? 'up' : 'down';
@@ -124,13 +135,19 @@ export function EditableList({ label, edit, emptyLabel }) {
124
135
  return current.map((other, i) => (i === at ? item : other));
125
136
  }),
126
137
  });
138
+ // The app's own add: its items saved at the end, the caret back on it.
139
+ const addItems = (added) => added.length === 0 ? Promise.resolve(true) : act((current) => [...current, ...added], [], [], false);
140
+ const onProblem = (message) => setStatus((now) => ({ ...now, error: message }));
127
141
  // The item being added, at the end.
142
+ const newItem = edit.newItem;
143
+ if (!newItem && !edit.add)
144
+ throw new Error('A list edited in place needs `newItem` (a blank item to add) or `add` (its own control).');
128
145
  const addEdit = {
129
146
  kind: 'text',
130
- value: edit.newItem(),
147
+ value: newItem?.(),
131
148
  control: (props) => _jsx(ItemRow, { children: edit.renderItem({ ...props, index: items.length, isNew: true }) }),
132
149
  schema: { safeParse: (draft) => checkItem(edit, items, items.length, draft) },
133
150
  save: (item) => edit.save((current) => [...current, item]),
134
151
  };
135
- return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel, emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: _jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) }) }), _jsx("span", { id: statusId, className: "type-body", children: status.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"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
152
+ return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel ?? '', emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: edit.add ? (_jsx(ListAdd, { render: edit.add, add: addItems, items: items, disabled: status.saving, onProblem: onProblem })) : (_jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) })) }), _jsx("span", { id: statusId, className: "type-body", children: status.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"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
136
153
  }
@@ -206,7 +206,7 @@ export function EditableField({ label, edit, children, layout = 'block', classNa
206
206
  if (state.status === 'closed' && !always) {
207
207
  if (trigger)
208
208
  return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
209
- return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
209
+ return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1 group-hover/editable:[&_.text-muted-foreground]:text-foreground group-has-[[data-edit]:focus-visible]/editable:[&_.text-muted-foreground]:text-foreground", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
210
210
  }
211
211
  // Always editable, before the store has it (the first render): the saved value.
212
212
  const open = state.status === 'closed' ? { status: 'open', draft: edit.value, error: null, autosave: undefined, revision: 0 } : state;
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { type RowGroup } from './components/app/table-groups.js';
8
8
  export { byDay, type ByDayOptions } from './lib/group-by-day.js';
9
9
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
10
10
  export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
11
- export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
11
+ export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
12
12
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
13
13
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
14
14
  export { ErrorState } from './components/app/error-state.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.40.0",
3
+ "version": "4.41.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": {