@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.
- package/conventions/AGENTS.md +3 -1
- package/conventions/docs/add-an-entity.md +41 -7
- package/conventions/docs/cell-patterns.md +20 -0
- package/conventions/docs/entity-plan-template.md +5 -3
- package/dist/components/app/multi-choice.d.ts +6 -1
- package/dist/components/app/multi-choice.js +6 -2
- package/package.json +1 -1
package/conventions/AGENTS.md
CHANGED
|
@@ -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
|
|
74
|
-
|
|
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
|
|
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`, `
|
|
93
|
-
`
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|