@tristan2828/ui-foundation 3.7.0 → 3.9.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.
@@ -132,7 +132,9 @@ net.
132
132
  Follow `docs/foundation/add-an-entity.md` (in Claude Code, `/new-entity
133
133
  <Name>` is a shortcut to it). It builds from the entity's plan,
134
134
  `docs/entities/<entity>.md`; if there's no plan, work one out with the
135
- developer first. Never guess the fields.
135
+ developer first. Never guess the fields. The files it copies are the
136
+ Widgets demo's until the app removes it; after that, the `### Reference
137
+ files` section below names the app's own.
136
138
 
137
139
  ## Upgrading the Foundation
138
140
  ```bash
@@ -12,6 +12,30 @@ Do not skip steps or reorder them. Replace `<Entity>` with the PascalCase
12
12
  entity name (e.g. `Invoice`) and `<entity>` with its kebab-case form
13
13
  (`invoice`) throughout.
14
14
 
15
+ ## The reference files
16
+
17
+ The steps name the template's Widgets demo as the code to copy:
18
+ `src/routes/widgets/`, `src/api/gateway/widgets.ts` and
19
+ `widget-categories.ts`, their mocks and tests. An app deletes the demo
20
+ once its own entity works, and from then on those names point at nothing.
21
+ So the app's `AGENTS.md`, below the foundation block, has a
22
+ **`### Reference files`** section: for each pattern (a table, a form, a
23
+ reference field, a multi choice and the rest), the app's own file to copy.
24
+
25
+ - **That section exists:** it wins. Wherever a step below names a demo
26
+ file, copy the file the section names for that pattern instead. A
27
+ pattern it doesn't list, and the demo is gone: stop and ask which file
28
+ to copy. Never rebuild a pattern from this file's prose alone.
29
+ - **It doesn't, and the demo is still here:** copy the demo, as written.
30
+ - **When the app removes the demo:** write the section first, naming the
31
+ file that now holds each pattern the demo showed. The template's own
32
+ `AGENTS.md` has the section, filled in for the demo, as the shape.
33
+
34
+ The demo's referenced entity is `WidgetCategory` (`/widget-categories`,
35
+ the `widget_categories` table), named for its widget so it never collides
36
+ with an app's own entity. An app whose entity is a Category builds it
37
+ alongside the demo with no rename.
38
+
15
39
  ## Before anything: the entity plan
16
40
 
17
41
  **Never guess what an entity is.** Every field, type, option list and rule
@@ -64,14 +88,14 @@ is a filled-in example while the Widgets demo is still here).
64
88
  a raw sentinel instead of a "not set" label, passes `verify` unnoticed.
65
89
  One row costs nothing and step 8 asserts it. Extend `tests/mocks/conformance.test.ts` so the
66
90
  new handlers are validated against `openapi.yaml`, the same way
67
- `widgets`/`categories` already are.
91
+ `widgets`/`widget-categories` already are.
68
92
  5. **Add a gateway module in `src/api/gateway/<entity>.ts`** until step 3's
69
93
  tests pass, built on `safeFetch`/`toAppError` from
70
94
  `@tristan2828/ui-foundation/gateway` (see `src/api/gateway/widgets.ts`).
71
95
  Wire → `Page<T>` / `AppError` translation only; no hand-written types
72
96
  (everything comes from `schema.d.ts`).
73
- 6. **Copy the widgets reference files, one for one, not just the two
74
- screens** — shaped by the plan: table columns and sortable columns from
97
+ 6. **Copy the reference files, one for one, not just the two screens**
98
+ (the Widgets demo's, or the ones the app's `### Reference files` names) — shaped by the plan: table columns and sortable columns from
75
99
  its "List" column, toolbar filters from its "Filter" column, form fields
76
100
  and labels from its field table, and field types by pattern (a
77
101
  `reference` field copies Category's searchable combobox, a
@@ -85,12 +109,14 @@ is a filled-in example while the Widgets demo is still here).
85
109
  `<MultiChoice>` control from the foundation on the
86
110
  form and as a toolbar filter via `useTableUrlState`'s multi filters,
87
111
  badges in the table, and a `filters` array the gateway sends as a
88
- repeated parameter — and a `multi reference` copies Extra Categories:
112
+ repeated parameter; each option shows its label, never its wire value
113
+ (`quick_win` reads "Quick win"): one `Record<value, label>`
114
+ (`WIDGET_TAG_LABELS`) feeds `MultiChoice`'s `getLabel` and the badges — and a `multi reference` copies Extra Categories:
89
115
  the `<MultiReference>` control on the form and as a toolbar filter, an
90
116
  array of ids on the wire, a repeated any-of list filter, and names from
91
117
  a **lookup by id** on the referenced entity
92
- (`GET /categories?ids=1&ids=3`, `getCategoriesByIds`,
93
- `useCategoriesByIdsQuery`). Add that `ids` parameter to the referenced
118
+ (`GET /widget-categories?ids=1&ids=3`, `getWidgetCategoriesByIds`,
119
+ `useWidgetCategoriesByIdsQuery`). Add that `ids` parameter to the referenced
94
120
  entity's list endpoint if it lacks one. Never name a picked id from
95
121
  search results alone: a saved pick the current search doesn't return
96
122
  would show without a name. The table looks up every id on its page in
@@ -120,7 +146,15 @@ is a filled-in example while the Widgets demo is still here).
120
146
  table flickers when, say, reference names arrive after the rows. Data
121
147
  that arrives later reaches the cells through context, as
122
148
  `category-names.tsx` does for category names.
123
- - `src/routes/widgets/use-widgets.ts`, `use-categories.ts` →
149
+
150
+ **A `yes/no` marked `toggle`** copies In Stock's cell
151
+ (`in-stock-toggle.tsx`, cell pattern 15): a `Switch` in its own
152
+ component that PATCHes the one field through an optimistic mutation
153
+ (`useToggleWidgetInStockMutation`: every cached list page updates at
154
+ once and is restored if the save fails, with a toast naming the row),
155
+ then refetches the lists. Its specs cover a flip that saves, the
156
+ keyboard, and a failed save putting the switch back.
157
+ - `src/routes/widgets/use-widgets.ts`, `use-widget-categories.ts` →
124
158
  `src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
125
159
  new gateway module)
126
160
  - `src/routes/widgets/widget-schema.ts` →
@@ -330,3 +330,23 @@ A person: an assignee or an owner.
330
330
  - **Cost:** a column of faces draws the eye more than any other cell. Use
331
331
  it when *who* matters most in the row, not as decoration.
332
332
 
333
+ ## 15. Yes/no you flip in the row
334
+
335
+ A yes/no the user changes more often than they open the form ("available
336
+ now", "happened"). Widget's In Stock column is the reference
337
+ (`src/routes/widgets/in-stock-toggle.tsx`).
338
+
339
+ - **A `Switch`, never a checkbox.** It acts at once; a checkbox in a row
340
+ reads as "select this row".
341
+ - **It saves on its own:** a PATCH of that one field, not the form. Show
342
+ the new value at once (optimistic) and put it back, with a toast naming
343
+ the row, if the save fails (`useToggleWidgetInStockMutation`).
344
+ - **Keep the word beside it** (pattern 12), `aria-hidden` since the switch
345
+ announces its own state. Give the switch a row-specific name
346
+ (`In stock: ${row.original.name}`).
347
+ - Its own component in its own file, rendered from the column's `cell`,
348
+ so the hook lives in a component and the columns stay stable
349
+ ([`add-an-entity.md`](add-an-entity.md) "Keep column definitions
350
+ stable").
351
+ - **Cost:** one click changes data, with no confirmation. Only for values
352
+ that are cheap to flip back; never for anything destructive.
@@ -28,7 +28,8 @@ One or two sentences: what one record is, and who uses the screens.
28
28
  say its default and the filter's three labels, e.g. Any stock /
29
29
  In stock / Out of stock), `single choice` (list the options, in display
30
30
  order; see the note below if it is *optional*), `multi choice` (list
31
- the options; any number can be picked —
31
+ the options, with a label for any whose wire value isn't fit to show,
32
+ e.g. `quick_win` (Quick win); any number can be picked —
32
33
  chips on the form, badges in the table, an any-of filter), `reference`
33
34
  (to another entity — name it; a searchable combobox), `multi reference`
34
35
  (to any number of records of another entity — name it; chips with a
@@ -36,7 +37,7 @@ One or two sentences: what one record is, and who uses the screens.
36
37
  the other entity's list endpoint must also accept `ids`),
37
38
  `computed` (read-only: the server works it out from other data on
38
39
  every read and never stores it — give its rule, and if it's a choice,
39
- its values in sort order; a read-only column, a filter and a sort the
40
+ its values in sort order with a label for each; a read-only column, a filter and a sort the
40
41
  server evaluates, never on the form). A count of related records is a
41
42
  `computed` integer.
42
43
  - *Close variants* (built by a small, stated change to the nearest
@@ -55,7 +56,8 @@ One or two sentences: what one record is, and who uses the screens.
55
56
  a screen — a Base UI `Select` can't take `""` as an item value, so the
56
57
  pattern needs a sentinel and gets the display wrong without one.
57
58
  - **List**: `column` if it shows in the table, and `sortable` if you can
58
- sort by it; blank if it's form-only.
59
+ sort by it; blank if it's form-only. A `yes/no` can also say `toggle`:
60
+ flipped straight from its row, saved on its own (cell pattern 15).
59
61
  - **Filter**: `search` (the text search box), `yes` (a filter control), or
60
62
  blank.
61
63
 
@@ -1,9 +1,14 @@
1
1
  import * as React from 'react';
2
- export declare function MultiChoice<T extends string>({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
2
+ export declare function MultiChoice<T extends string>({ options, value, onValueChange, getLabel, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
3
3
  /** Every allowed option, in display order. */
4
4
  options: readonly T[];
5
5
  value: T[];
6
6
  onValueChange: (value: T[]) => void;
7
+ /**
8
+ * The text shown for an option: on its chip, in the dropdown, and what
9
+ * typing matches against. Defaults to the value itself.
10
+ */
11
+ getLabel?: (value: T) => string;
7
12
  /** Id for the text input, so a <FieldLabel htmlFor> labels it. */
8
13
  id?: string;
9
14
  placeholder?: string;
@@ -5,9 +5,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // filter — so entities reuse it rather than re-assembling the combobox
6
6
  // primitive each time. Built on shadcn's Combobox `multiple` mode, following
7
7
  // its own ComboboxMultiple example.
8
+ //
9
+ // Values are the wire's enum strings; `getLabel` names them when those
10
+ // aren't fit to show (`quick_win` → "Quick win"). Chips, options and the
11
+ // typed-text match all use the label, and the value is what's emitted.
8
12
  import * as React from 'react';
9
13
  import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
10
- export function MultiChoice({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
14
+ export function MultiChoice({ options, value, onValueChange, getLabel = String, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
11
15
  const anchor = useComboboxAnchor();
12
- return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next) => onValueChange(next), children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: option }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: option }, option)) })] })] }));
16
+ return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next) => onValueChange(next), itemToStringLabel: getLabel, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: getLabel(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: getLabel(option) }, option)) })] })] }));
13
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.7.0",
3
+ "version": "3.9.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": {