@tristan2828/ui-foundation 3.3.0 → 3.5.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/README.md +2 -2
- package/conventions/docs/add-an-entity.md +17 -2
- package/conventions/docs/cell-patterns.md +7 -3
- package/conventions/docs/entity-plan-template.md +9 -3
- package/dist/components/app/multi-reference.d.ts +27 -0
- package/dist/components/app/multi-reference.js +27 -0
- package/dist/components/ui/switch.d.ts +5 -0
- package/dist/components/ui/switch.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,8 +18,8 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
22
|
-
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, table, textarea, tooltip |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `MultiReference`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
22
|
+
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
25
25
|
| `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
|
|
@@ -76,11 +76,26 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
76
76
|
and labels from its field table, and field types by pattern (a
|
|
77
77
|
`reference` field copies Category's searchable combobox, a
|
|
78
78
|
`single choice` copies Status's select, a `date-time` copies
|
|
79
|
-
Available From's date picker, a `
|
|
79
|
+
Available From's date picker, a `yes/no` copies In Stock — a `Switch`
|
|
80
|
+
(`@tristan2828/ui-foundation/ui/switch`) in a horizontal `Field` with
|
|
81
|
+
its label beside it on the form, cell pattern 12 (the word Yes/No) in
|
|
82
|
+
the table, and a three-way toolbar `Select` (either / yes / no) whose URL
|
|
83
|
+
value is `'true'`/`'false'` and whose `filters` value is a real boolean,
|
|
84
|
+
so `false` reaches the wire — and a `multi choice` copies Tags — the
|
|
80
85
|
`<MultiChoice>` control from the foundation on the
|
|
81
86
|
form and as a toolbar filter via `useTableUrlState`'s multi filters,
|
|
82
87
|
badges in the table, and a `filters` array the gateway sends as a
|
|
83
|
-
repeated parameter
|
|
88
|
+
repeated parameter — and a `multi reference` copies Extra Categories:
|
|
89
|
+
the `<MultiReference>` control on the form and as a toolbar filter, an
|
|
90
|
+
array of ids on the wire, a repeated any-of list filter, and names from
|
|
91
|
+
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
|
|
94
|
+
entity's list endpoint if it lacks one. Never name a picked id from
|
|
95
|
+
search results alone: a saved pick the current search doesn't return
|
|
96
|
+
would show without a name. The table looks up every id on its page in
|
|
97
|
+
one request; the backend rejects an unknown id as a 422 on the field,
|
|
98
|
+
never a foreign-key 500):
|
|
84
99
|
- `src/routes/widgets/use-widgets.ts`, `use-categories.ts` →
|
|
85
100
|
`src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
|
|
86
101
|
new gateway module)
|
|
@@ -7,7 +7,9 @@ per-entity, and only the shape is reusable. Copy the markup into your
|
|
|
7
7
|
|
|
8
8
|
Patterns 1–10 were each chosen for a real column, from a page of
|
|
9
9
|
alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
|
|
10
|
-
|
|
10
|
+
Pattern 12 (boolean) is the template's own reference: Widget's In Stock
|
|
11
|
+
column renders it and its specs pin it. The other patterns from 11 on are
|
|
12
|
+
marked **Unproven**: written ahead of a real
|
|
11
13
|
column, so no app has tested them on real data yet. Prefer a proven
|
|
12
14
|
pattern when one fits. When you use an unproven one, say so in the
|
|
13
15
|
entity's plan, and raise what you learned for the foundation, so the
|
|
@@ -257,9 +259,11 @@ cell: ({ getValue }) => {
|
|
|
257
259
|
template's `USD` is the demo's.
|
|
258
260
|
- **Cost:** none to speak of. It's the plain-text pattern with formatting.
|
|
259
261
|
|
|
260
|
-
## 12. Boolean
|
|
262
|
+
## 12. Boolean
|
|
261
263
|
|
|
262
|
-
A yes/no field.
|
|
264
|
+
A yes/no field. Widget's In Stock column (`src/routes/widgets/widgets-columns.tsx`)
|
|
265
|
+
is the reference; its value is never null, so it drops the `emptyCell`
|
|
266
|
+
branch below.
|
|
263
267
|
|
|
264
268
|
```tsx
|
|
265
269
|
cell: ({ getValue }) => {
|
|
@@ -24,15 +24,21 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
24
24
|
- **Type** — what the foundation supports today:
|
|
25
25
|
- *Demonstrated by the Widget reference* (copied directly): `text` (one
|
|
26
26
|
line), `long text`, `decimal` (fixed places — say how many),
|
|
27
|
-
`date-time`, `email`, `
|
|
27
|
+
`date-time`, `email`, `yes/no` (a boolean that is always yes or no:
|
|
28
|
+
say its default and the filter's three labels, e.g. Any stock /
|
|
29
|
+
In stock / Out of stock), `single choice` (list the options, in display
|
|
28
30
|
order; see the note below if it is *optional*), `multi choice` (list
|
|
29
31
|
the options; any number can be picked —
|
|
30
32
|
chips on the form, badges in the table, an any-of filter), `reference`
|
|
31
|
-
(to another entity — name it; a searchable combobox)
|
|
33
|
+
(to another entity — name it; a searchable combobox), `multi reference`
|
|
34
|
+
(to any number of records of another entity — name it; chips with a
|
|
35
|
+
searchable dropdown, names as badges in the table, an any-of filter;
|
|
36
|
+
the other entity's list endpoint must also accept `ids`).
|
|
32
37
|
- *Close variants* (built by a small, stated change to the nearest
|
|
33
38
|
pattern): `integer` (from decimal), `url` (from email — a format
|
|
34
39
|
check), `date` (from date-time — no time part).
|
|
35
|
-
- *Not supported yet:*
|
|
40
|
+
- *Not supported yet:* a yes/no that can also be unset (three states),
|
|
41
|
+
file, and anything else.
|
|
36
42
|
Write it anyway with a note; the playbook will stop and raise it
|
|
37
43
|
rather than improvise.
|
|
38
44
|
- **Required**: `yes`, or `no` (then say whether empty means "unknown").
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
export declare function MultiReference<Id extends string | number>({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
|
|
3
|
+
/** The records the current search returned, in display order. */
|
|
4
|
+
options: readonly {
|
|
5
|
+
id: Id;
|
|
6
|
+
label: string;
|
|
7
|
+
}[];
|
|
8
|
+
/** The picked ids. */
|
|
9
|
+
value: Id[];
|
|
10
|
+
onValueChange: (value: Id[]) => void;
|
|
11
|
+
/**
|
|
12
|
+
* The name of any id: a picked one (from the app's lookup by ids) or an
|
|
13
|
+
* option. `undefined` while it's still loading.
|
|
14
|
+
*/
|
|
15
|
+
getLabel: (id: Id) => string | undefined;
|
|
16
|
+
/** Called with the typed text; the app feeds it to its search query. */
|
|
17
|
+
onSearchChange: (search: string) => void;
|
|
18
|
+
/** Id for the text input, so a <FieldLabel htmlFor> labels it. */
|
|
19
|
+
id?: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Shown in the dropdown when the search matches nothing. */
|
|
22
|
+
emptyText?: string;
|
|
23
|
+
/** For use without a visible label (e.g. a table toolbar filter). */
|
|
24
|
+
'aria-label'?: string;
|
|
25
|
+
'aria-invalid'?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A multi-reference picker: links to several records of another entity,
|
|
3
|
+
// shown as removable chips, with the rest found by typing. The one control
|
|
4
|
+
// for every "multi reference" field (conventions/docs/entity-plan-template.md),
|
|
5
|
+
// on a form (Widget Extra Categories) and as a table filter.
|
|
6
|
+
//
|
|
7
|
+
// MultiChoice's sibling, for values that are record ids rather than enum
|
|
8
|
+
// strings. Two differences follow from that:
|
|
9
|
+
// - A chip needs a *name* for an id the current search may not include
|
|
10
|
+
// (editing a saved record, a filter from a shared link). The app fetches
|
|
11
|
+
// names for exactly the picked ids (Widget: GET /categories?ids=...) and
|
|
12
|
+
// passes `getLabel`; this component never guesses one.
|
|
13
|
+
// - Matching happens on the server: `onSearchChange` reports what was typed
|
|
14
|
+
// and `options` is whatever the app's search query returned, so nothing
|
|
15
|
+
// is filtered here (filter={null}).
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
|
|
18
|
+
/** Shown on a chip whose name hasn't loaded yet, instead of a bare id. */
|
|
19
|
+
const PENDING_LABEL = '…';
|
|
20
|
+
export function MultiReference({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText = 'No matches.', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
|
|
21
|
+
const anchor = useComboboxAnchor();
|
|
22
|
+
const label = (option) => getLabel(option) ?? PENDING_LABEL;
|
|
23
|
+
return (_jsxs(Combobox, { multiple: true, autoHighlight: true,
|
|
24
|
+
// Ids, not {id,label} objects: Base UI compares items to the value
|
|
25
|
+
// with Object.is, so the items must have the value's own shape.
|
|
26
|
+
items: options.map((option) => option.id), value: value, onValueChange: (next) => onValueChange(next), onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(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: emptyText }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: label(option) }, option)) })] })] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
function Switch({ className, size = "default", ...props }) {
|
|
5
|
+
return (_jsx(SwitchPrimitive.Root, { "data-slot": "switch", "data-size": size, className: cn("peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", className), ...props, children: _jsx(SwitchPrimitive.Thumb, { "data-slot": "switch-thumb", className: "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground" }) }));
|
|
6
|
+
}
|
|
7
|
+
export { Switch };
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
5
5
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
6
6
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
7
7
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
8
|
+
export { MultiReference } from './components/app/multi-reference.js';
|
|
8
9
|
export { PasswordInput } from './components/app/password-input.js';
|
|
9
10
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
10
11
|
export { ThemeProvider } from './components/theme-provider.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { ErrorState } from './components/app/error-state.js';
|
|
|
10
10
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
11
11
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
12
12
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
13
|
+
export { MultiReference } from './components/app/multi-reference.js';
|
|
13
14
|
export { PasswordInput } from './components/app/password-input.js';
|
|
14
15
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
15
16
|
export { ThemeProvider } from './components/theme-provider.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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": {
|