@tristan2828/ui-foundation 3.5.0 → 3.6.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 +9 -1
- package/conventions/docs/entity-plan-template.md +22 -0
- package/dist/api/gateway/errors.js +17 -4
- package/dist/components/app/list-editor.d.ts +25 -0
- package/dist/components/app/list-editor.js +10 -0
- package/dist/components/ui/checkbox.d.ts +3 -0
- package/dist/components/ui/checkbox.js +8 -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`, `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 |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `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, checkbox, 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` |
|
|
@@ -95,7 +95,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
95
95
|
search results alone: a saved pick the current search doesn't return
|
|
96
96
|
would show without a name. The table looks up every id on its page in
|
|
97
97
|
one request; the backend rejects an unknown id as a 422 on the field,
|
|
98
|
-
never a foreign-key 500
|
|
98
|
+
never a foreign-key 500 — and each **sub-records** list copies
|
|
99
|
+
Checklist: an array of item objects on the wire, read and written
|
|
100
|
+
whole and in order (no item ids), a child table with a `position`
|
|
101
|
+
column, react-hook-form's `useFieldArray` feeding the foundation's
|
|
102
|
+
`<ListEditor>` inside a `FieldSet`/`FieldLegend` on the form, each row's
|
|
103
|
+
controls named by position ("Item 2 text"), a per-item `FieldError`
|
|
104
|
+
(a server 422 at `["body", "checklist", 2, "text"]` arrives as
|
|
105
|
+
`fieldErrors["checklist.2.text"]` and binds to that row), and a summary
|
|
106
|
+
in the table, never the items):
|
|
99
107
|
- `src/routes/widgets/use-widgets.ts`, `use-categories.ts` →
|
|
100
108
|
`src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
|
|
101
109
|
new gateway module)
|
|
@@ -54,6 +54,28 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
54
54
|
- **Filter**: `search` (the text search box), `yes` (a filter control), or
|
|
55
55
|
blank.
|
|
56
56
|
|
|
57
|
+
## Sub-records
|
|
58
|
+
|
|
59
|
+
Optional. A list of small items that belong to one record alone and are
|
|
60
|
+
edited on its form, saved with it (a checklist, a set of links). One block
|
|
61
|
+
per list; delete the section if there are none.
|
|
62
|
+
|
|
63
|
+
### checklist — Checklist
|
|
64
|
+
|
|
65
|
+
- Most items: 50.
|
|
66
|
+
- In the table: a done-count (`2/6 done`), or blank if the list isn't
|
|
67
|
+
shown.
|
|
68
|
+
|
|
69
|
+
| Field | Label | Type | Required | Options / rules |
|
|
70
|
+
|---|---|---|---|---|
|
|
71
|
+
| text | Text | text | yes | 1–300 chars, trimmed |
|
|
72
|
+
| done | Done | yes/no | yes | default no |
|
|
73
|
+
|
|
74
|
+
- Item fields use the same types as the main table (`text`, `url`,
|
|
75
|
+
`yes/no`… — not another sub-record or a reference). Items have no screen
|
|
76
|
+
of their own: they're added, edited, reordered and removed on the
|
|
77
|
+
record's form, and the order is kept.
|
|
78
|
+
|
|
57
79
|
## List screen
|
|
58
80
|
|
|
59
81
|
- Default sort:
|
|
@@ -16,10 +16,7 @@ export function toAppError(status, body) {
|
|
|
16
16
|
const detail = body?.detail ?? [];
|
|
17
17
|
const fieldErrors = {};
|
|
18
18
|
for (const issue of detail) {
|
|
19
|
-
|
|
20
|
-
// an array field (["body", "tags", 0] is a bad tag), and a form binds
|
|
21
|
-
// errors by field name, never by position.
|
|
22
|
-
const field = String([...issue.loc].reverse().find((segment) => typeof segment === "string") ?? issue.loc[issue.loc.length - 1]);
|
|
19
|
+
const field = fieldKey(issue.loc);
|
|
23
20
|
(fieldErrors[field] ??= []).push(issue.msg);
|
|
24
21
|
}
|
|
25
22
|
return { kind: "validation", message: "Validation failed", fieldErrors };
|
|
@@ -31,6 +28,22 @@ export function toAppError(status, body) {
|
|
|
31
28
|
return { kind: "auth", message };
|
|
32
29
|
return { kind: "server", message };
|
|
33
30
|
}
|
|
31
|
+
// The key a form binds a 422 to (react-hook-form's field path):
|
|
32
|
+
// - ["body", "price"] → "price"
|
|
33
|
+
// - ["body", "tags", 0] → "tags": an item of a list of values is the list's
|
|
34
|
+
// error, never a position.
|
|
35
|
+
// - ["body", "checklist", 2, "text"] → "checklist.2.text": a field inside an
|
|
36
|
+
// item of a list of objects, so it lands on that row's input.
|
|
37
|
+
// Anything else keeps the last string segment, as before nested paths
|
|
38
|
+
// existed (["body", "address", "city"] → "city").
|
|
39
|
+
function fieldKey(loc) {
|
|
40
|
+
const path = loc.slice(1); // drop "body" / "query"
|
|
41
|
+
const listIndex = path.findIndex((segment) => typeof segment === "number");
|
|
42
|
+
if (listIndex > 0 && path.slice(listIndex + 1).some((segment) => typeof segment === "string")) {
|
|
43
|
+
return path.join(".");
|
|
44
|
+
}
|
|
45
|
+
return String([...loc].reverse().find((segment) => typeof segment === "string") ?? loc[loc.length - 1]);
|
|
46
|
+
}
|
|
34
47
|
export function networkError() {
|
|
35
48
|
return { kind: "network", message: "Network error — check your connection and try again." };
|
|
36
49
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, 'aria-label': ariaLabel, }: {
|
|
3
|
+
/** One entry per item, with a stable key (useFieldArray's `fields`). */
|
|
4
|
+
items: readonly {
|
|
5
|
+
id: string;
|
|
6
|
+
}[];
|
|
7
|
+
/** The item's own fields, for the item at this index. */
|
|
8
|
+
renderItem: (index: number) => ReactNode;
|
|
9
|
+
onAdd: () => void;
|
|
10
|
+
onRemove: (index: number) => void;
|
|
11
|
+
onMove: (from: number, to: number) => void;
|
|
12
|
+
/**
|
|
13
|
+
* How the row buttons name an item for a screen reader, e.g.
|
|
14
|
+
* (i) => `item ${i + 1}` gives "Move item 2 up", "Remove item 2".
|
|
15
|
+
*/
|
|
16
|
+
itemName: (index: number) => string;
|
|
17
|
+
/** The Add button's text, e.g. "Add item". */
|
|
18
|
+
addLabel: string;
|
|
19
|
+
/** Shown instead of the list when it's empty. */
|
|
20
|
+
emptyText?: string;
|
|
21
|
+
/** The most items allowed; Add is disabled once reached. */
|
|
22
|
+
max?: number;
|
|
23
|
+
/** Names the list (e.g. the field's label). */
|
|
24
|
+
'aria-label'?: string;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react';
|
|
3
|
+
import { Button } from '../ui/button.js';
|
|
4
|
+
export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, 'aria-label': ariaLabel, }) {
|
|
5
|
+
const atMax = max !== undefined && items.length >= max;
|
|
6
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [items.length === 0 ? (_jsx("p", { className: "type-caption text-muted-foreground", children: emptyText })) : (_jsx("ul", { "aria-label": ariaLabel, className: "flex flex-col gap-2", children: items.map((item, index) => {
|
|
7
|
+
const name = itemName(index);
|
|
8
|
+
return (_jsxs("li", { className: "flex items-start gap-1", children: [_jsx("div", { className: "flex min-w-0 flex-1 items-start gap-2", children: renderItem(index) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} up`, disabled: index === 0, onClick: () => onMove(index, index - 1), children: _jsx(ArrowUpIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Move ${name} down`, disabled: index === items.length - 1, onClick: () => onMove(index, index + 1), children: _jsx(ArrowDownIcon, {}) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }, item.id));
|
|
9
|
+
}) })), _jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, disabled: atMax, children: [_jsx(PlusIcon, {}), addLabel] }) })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
import { CheckIcon } from "lucide-react";
|
|
5
|
+
function Checkbox({ className, ...props }) {
|
|
6
|
+
return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) }) }));
|
|
7
|
+
}
|
|
8
|
+
export { Checkbox };
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
9
|
+
export { ListEditor } from './components/app/list-editor.js';
|
|
9
10
|
export { PasswordInput } from './components/app/password-input.js';
|
|
10
11
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
11
12
|
export { ThemeProvider } from './components/theme-provider.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
13
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
14
|
+
export { ListEditor } from './components/app/list-editor.js';
|
|
14
15
|
export { PasswordInput } from './components/app/password-input.js';
|
|
15
16
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
16
17
|
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.6.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": {
|