@tristan2828/ui-foundation 3.25.0 → 3.27.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.
@@ -61,6 +61,18 @@ is a filled-in example while the Widgets demo is still here).
61
61
  written before the view existed has neither: ask the developer whether
62
62
  to add them before step 1. The reference files build the table *with* a
63
63
  view, where Edit and Delete live, so the table has no row actions.
64
+ - **`Screens: list, edited in the row`** is a small entity with no view
65
+ and no form: build the contract, gateway, mocks and table as below, but
66
+ no view, form or `/new`, `/:id`, `/:id/edit` routes. Its table follows
67
+ cell pattern 18, "In a table's rows": each field's cell an
68
+ `EditableValue` over the shared control from `<entity>-fields.tsx` and
69
+ `useEdit<Entity>Field`, `InlineCreate` in the toolbar for create, a
70
+ delete row action with its confirm dialog, and `width="content"` if the
71
+ plan's List screen says so. There's no reference file in the template
72
+ for this shape; the pattern's code is the reference. Its specs: each
73
+ cell kind saves, a refusal stays in its cell, one cell at a time, the
74
+ leave prompt, create (Enter, a blank name, a taken one), delete, and
75
+ the in-place a11y states for a cell open and refused, in both themes.
64
76
 
65
77
  1. **Add `<Entity>` to `openapi.yaml`** — schema, list, get, create, update,
66
78
  delete — exactly as the plan specifies: its fields, types, required
@@ -490,6 +502,8 @@ is a filled-in example while the Widgets demo is still here).
490
502
  the in-place a11y block unless the plan has an `Edit in place` line,
491
503
  and then only for the fields it names.
492
504
 
505
+ - `width="content"` on `DataTable` unless the plan's List screen says
506
+ `Width: content` (the Widgets table is wide and leaves it out).
493
507
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
494
508
  button group and `visibleColumns`) unless the plan's List screen lists
495
509
  saved views. When it does, copy it: `useTableUrlState`'s third argument
@@ -819,6 +819,88 @@ The row's fields are one component shared with the form
819
819
  (`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
820
820
  position ("Item 2 text") as the row buttons name the item.
821
821
 
822
+ ### In a table's rows
823
+
824
+ For a **small entity edited in its table** (a name and a few fields,
825
+ with no view page and no form: the plan's `Screens: list, edited in the
826
+ row`), every editable cell is an editable value, exactly as on a view.
827
+ `DataTable` runs it: one cell open at a time, the leave-page prompt while
828
+ one is open, "Saving…" and a refusal in the cell. There's no pencil
829
+ column and no edit route.
830
+
831
+ - **A cell** is `<EditableValue label="<Column> of <name>" layout="inline"
832
+ edit={editInPlace({ ... })}>`, its shown value the cell as it always
833
+ was. The label names the column and the row, so its pencil reads "Edit
834
+ Colour of Work" and its control "Colour of Work". The kinds are the
835
+ same: `text` (Enter saves), `choice` (the list opens with it, non-modal,
836
+ a pick saves), `multi`.
837
+ - **A cell is a component** (`<Entity><Field>Cell`) that calls
838
+ `useEdit<Entity>Field(row.id)` for its save, the view's single-field
839
+ save. A hook can't be called inside a column definition.
840
+ - **Stable columns and row ids.** Make the column definitions once
841
+ (at module level, or `useMemo`) and give
842
+ `DataTable` a `getRowId`: new definitions every render, or rows keyed
843
+ by position, remount an open cell and lose what's typed.
844
+ - **Create is `InlineCreate`** in the toolbar: the new record's name, Enter
845
+ or Add. It's created with every other field at its default (say which in
846
+ the plan) and appears as a row, edited like the others; the box empties
847
+ and keeps the caret for the next one. The name's rule is the form's
848
+ (`schema`), and a refusal (a taken name's 422) shows under the box.
849
+ - **Delete** stays a row action: a trash button in a pinned last column
850
+ (`pinLastColumn`), with its confirm dialog.
851
+ - **Few short columns?** `width="content"` (below) keeps the row's cells
852
+ and its delete together on a wide screen.
853
+
854
+ ```tsx
855
+ // A cell is its own component, so it can call the record's save hook.
856
+ function CategoryColourCell({ category }: { category: Category }) {
857
+ const editField = useEditCategoryField(category.id) // optimistic: false, toastOnError: false
858
+ return (
859
+ <EditableValue label={`Colour of ${category.name}`} layout="inline"
860
+ edit={editInPlace({
861
+ kind: 'choice',
862
+ value: category.colour,
863
+ schema: categoryFormSchema.shape.colour,
864
+ save: (colour) => editField.mutateAsync({ colour }),
865
+ control: (props) => (
866
+ <CategoryColourSelect id={props.id} value={props.value} onChange={(colour) => props.commit(colour)}
867
+ readOnly={props.disabled} defaultOpen modal={false} onOpenChange={(open) => !open && props.cancel()}
868
+ aria-label={props.label} aria-describedby={props.describedBy} />
869
+ ),
870
+ })}>
871
+ <ColourSwatch colour={category.colour} />
872
+ </EditableValue>
873
+ )
874
+ }
875
+
876
+ // Made once: the cells read the row they're given.
877
+ const CATEGORY_COLUMNS: LegacyColumnDef<Category, unknown>[] = [
878
+ { id: 'name', header: 'Name', cell: ({ row }) => <CategoryNameCell category={row.original} /> },
879
+ { id: 'colour', header: 'Colour', cell: ({ row }) => <CategoryColourCell category={row.original} /> },
880
+ { id: 'actions', header: () => <span className="sr-only">Actions</span>, cell: ({ row }) => <DeleteCategoryAction category={row.original} /> },
881
+ ]
882
+
883
+ <DataTable columns={CATEGORY_COLUMNS} getRowId={(row) => String(row.id)} width="content" pinFirstColumn pinLastColumn
884
+ toolbar={<InlineCreate label="New category's name" schema={categoryFormSchema.shape.name}
885
+ create={(name) => createCategory.mutateAsync({ name, colour: 'blue', icon: 'house' })} />} … />
886
+ ```
887
+
888
+ The worked example is the package's Storybook story `app/DataTable`
889
+ "Edit in the rows" (`data-table.stories.tsx`); the template has no small
890
+ entity yet.
891
+
892
+ ### A table of a few short columns: `width="content"`
893
+
894
+ On a wide screen a table of three to five short columns (a name, a
895
+ colour, a count, its delete) spreads them across the page, and the
896
+ row's actions end up far from its name. `width="content"` sizes such a
897
+ table to its columns once its area is 80rem or wider, never under 36rem,
898
+ with the toolbar and pagination at the same width. Below 80rem, and on a
899
+ phone, it's the full width as always. While it's loading, empty or
900
+ failed after a load, it keeps the width it last had, so paging and
901
+ filtering don't move it; only the first load can. A table that grows
902
+ wide (many columns) has no reason to opt in.
903
+
822
904
  ## 19. Stage circle for an ordered status
823
905
 
824
906
  A status that's an ordered lifecycle (each record moves through the same
@@ -125,30 +125,38 @@ that lets people pick a colour stores the slot's name (an enum of
125
125
  `category-1` … `category-12` in its contract, with a CHECK on the column)
126
126
  and maps each to its whole class the same way.
127
127
 
128
- | Slots | What they are | Near info's blue |
129
- |---|---|---|
130
- | 1–8 | eight hues, about 25° apart | 1, 6, 8 |
131
- | 9–12 | deeper shades of four of those hues: 9 teal (4's), 10 plum (5's), 11 olive (3's), 12 violet (2's) | none |
128
+ | Slot | Colour | Slot | Colour |
129
+ |---|---|---|---|
130
+ | 1 | blue (near info) | 7 | teal |
131
+ | 2 | orange | 8 | red |
132
+ | 3 | green | 9 | gold |
133
+ | 4 | purple | 10 | navy |
134
+ | 5 | pink | 11 | lime |
135
+ | 6 | brown | 12 | magenta |
136
+
137
+ Every slot is a colour with its own name: no two blues, no two teals. So
138
+ in a picker, label each with its name ("Orange"), not its number.
132
139
 
133
140
  Five things about them:
134
141
 
135
- - **The hues avoid red, green and amber**, which the tones already spend. A
136
- category drawn in red reads as "this one is bad" even when nothing is
137
- wrong. Info's blue came later, and slots 1, 6 and 8 sit near it: a table
138
- that shows an info badge keeps its categories off those three, so a blue
139
- genre isn't read as "notice this". That still leaves nine.
140
- - **They are ordered by distinctness, not by hue angle.** An app using
141
- three categories gets three obviously different colours. Past about five,
142
- hue alone stops separating them — slots 3 and 7 are both yellow-greens —
143
- so the glyph has to carry the meaning and the colour only reinforces it.
144
- - **Past eight, it's lightness, not hue.** The hues left between the tones
145
- are spent, so slots 9–12 are darker shades of four of the first eight, in
146
- both themes. Each is at least as far from every other slot as the first
147
- eight are from each other (a unit test holds every pair to it), but a
148
- deep shade beside its own hue is told apart by how dark it is, which
149
- reads less readily than a hue. Use them for a set that really has more
150
- than eight values, and keep a shade away from its own hue where you can
151
- choose (10 isn't 5's neighbour in a list).
142
+ - **Chosen to be told apart, not spaced by hue.** They use the whole hue
143
+ circle and vary lightness and chroma as well, so a light lime sits next
144
+ to a deep navy rather than two mid-tone hues 25° apart. Every pair is at
145
+ least 17.9 apart in CIEDE2000 ΔE (the perceived difference; 10 and up
146
+ reads as a different colour), in both themes. A unit test holds every
147
+ pair to 15.
148
+ - **A category is not a tone.** Red, orange, brown and green are slots
149
+ because a category is a tinted glyph beside its name, never a status
150
+ badge, and doesn't read as good or bad there. Where it still could, keep
151
+ them apart: a column that shows tone badges doesn't tint its categories
152
+ in the same column, and a table that shows an info badge keeps its
153
+ categories off slot 1, the one near info's blue.
154
+ - **They are ordered for small sets.** An app using the first few gets the
155
+ most separable few, for everyone: the first six also stay apart (ΔE 10
156
+ or more, tested) for readers with deuteranopia or protanopia, the common
157
+ red-green deficiencies. Past six, some pairs merge for those readers
158
+ (gold and lime are nearly one colour), so the glyph and the name carry
159
+ the meaning and the colour only reinforces it.
152
160
  - **Tint the glyph, never fill behind text.** The label stays
153
161
  `text-foreground`, so readability never depends on the hue. A slot used
154
162
  as a background needs its own contrast check, which nothing here does
@@ -157,12 +165,13 @@ Five things about them:
157
165
  non-text minimum in both themes, on the page and on a card (recorded in
158
166
  `theme.css`). axe checks text contrast, not icon contrast, so the
159
167
  package's Storybook glyph check draws an icon in each slot on both
160
- surfaces (`patterns/CategoricalColour`, which also shows them side by
161
- side) and fails a change that takes one below 3:1.
168
+ surfaces (`patterns/CategoricalColour`) and fails a change that takes
169
+ one below 3:1. Its `SideBySide` story shows both themes at once, under
170
+ normal vision and simulated deuteranopia and protanopia.
162
171
 
163
172
  If you find yourself wanting a thirteenth, that is a sign the column should
164
173
  be showing a shape or a label rather than more colours: there is no more
165
- room in hue or lightness that stays readable.
174
+ room that stays readable.
166
175
 
167
176
  ## Typography
168
177
 
@@ -111,6 +111,9 @@ per list; delete the section if there are none.
111
111
  | All tasks | none | dueDate asc | |
112
112
  | Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
113
113
 
114
+ - Width (optional): `content` for a table of a few short columns, so on a
115
+ wide screen it takes the width they need rather than the whole page
116
+ (cell pattern 18, "`width="content"`"). Leave it out for the full width.
114
117
  - Anything else the table must show or do:
115
118
 
116
119
  ## View screen
@@ -176,7 +179,12 @@ except the quick actions, if any.
176
179
  ## Screens and access
177
180
 
178
181
  - Screens: list, view, create, edit, delete (the shape the playbook
179
- builds).
182
+ builds). Or, for a small entity (a name and a few fields, no page of
183
+ its own): `list, edited in the row`, with no View screen section. Every
184
+ field the table shows is edited in its cell, a new record is created
185
+ from its name in the table's toolbar (say what every other field
186
+ starts as), and delete is a row action (cell pattern 18, "In a table's
187
+ rows").
180
188
  - Ownership: **shared** (every signed-in user sees every record) or
181
189
  **per-user** (each user sees only their own).
182
190
 
@@ -60,5 +60,16 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
60
60
  * view is active, which can leave out a column only one view shows.
61
61
  */
62
62
  visibleColumns?: readonly string[];
63
+ /**
64
+ * `full` (the default): the table, its toolbar and its pagination take
65
+ * the whole width. `content`, for a table of a few short columns: on a
66
+ * wide screen (a table area of 80rem or more) they take the width the
67
+ * columns need, never under 36rem, with the row actions right after the
68
+ * last column. Below that, as `full`. While loading, empty or failed
69
+ * after a load, it keeps the width it last had, so paging and filtering
70
+ * don't move it (only the first load can).
71
+ */
72
+ width?: DataTableWidth;
63
73
  };
64
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }: DataTableProps<TData>): import("react").JSX.Element;
74
+ export type DataTableWidth = 'full' | 'content';
75
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  // pagination tree-shakeable and independently swappable, which buys nothing
11
11
  // here: every row model is manual (the server sorts and pages), so this
12
12
  // table never uses TanStack's own sorted/paginated row models at all.
13
- import { useEffect, useRef, useState } from 'react';
13
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
14
14
  import { flexRender } from '@tanstack/react-table';
15
15
  import { cn } from 'cn';
16
16
  import { getCoreRowModel, useLegacyTable } from '@tanstack/react-table/legacy';
@@ -19,6 +19,9 @@ import { ErrorState } from './error-state.js';
19
19
  import { Button } from '../ui/button.js';
20
20
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
21
21
  import { Skeleton } from '../ui/skeleton.js';
22
+ import { EditInPlaceContext } from './edit-in-place.js';
23
+ import { EditInPlaceStore } from './edit-in-place-store.js';
24
+ import { LeaveGuard } from './editable-value.js';
22
25
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
23
26
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
24
27
  // `accessorKey` with dots as underscores, then a string header. Group
@@ -71,6 +74,12 @@ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-norma
71
74
  // background so it reads identically on striped and unstriped rows, and it
72
75
  // stays on screen when the table is scrolled right.
73
76
  const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
77
+ // `width="content"`: from a table area 80rem wide, as wide as the columns
78
+ // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
79
+ // width it last had loaded while it shows another state
80
+ // (--data-table-last-width), at most the area. Narrower, no change.
81
+ const CONTENT_WIDTH_CLASS = '@7xl/data-table:w-fit @7xl/data-table:max-w-full ' +
82
+ '@7xl/data-table:min-w-[max(36rem,var(--data-table-last-width,0px))]';
74
83
  // A second horizontal scrollbar, stuck to the viewport's bottom edge. The
75
84
  // real one sits directly under the last row, which on a full page is
76
85
  // off-screen until you have already scrolled past every row. Only rendered
@@ -133,7 +142,13 @@ function useBottomScrollbar(containerRef, isSuccessView) {
133
142
  }, [containerRef, isSuccessView, isOverflowing]);
134
143
  return { barRef, isOverflowing, scrollWidth };
135
144
  }
136
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }) {
145
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', }) {
146
+ // Editing in place in the rows (cell pattern 18, "In a table's rows"):
147
+ // a cell that renders an <EditableValue> edits where it's shown, one at
148
+ // a time, with the leave-page prompt while one is open, the same rules
149
+ // as a record's view.
150
+ const [store] = useState(() => new EditInPlaceStore());
151
+ const openField = useSyncExternalStore(store.subscribe, () => store.open, () => store.open);
137
152
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
138
153
  // TanStack treats a column missing from this map as visible, so every
139
154
  // column gets an entry once a list is given.
@@ -171,15 +186,34 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
171
186
  }, [isPastLastPage, pageCount, onPageChange]);
172
187
  const isEmpty = !isLoading && !error && data.length === 0 && !isPastLastPage;
173
188
  const isSuccessView = !error && !isLoading && !isPastLastPage && !isEmpty;
189
+ // `width="content"`: the width the table last had loaded, kept while it
190
+ // shows another state.
191
+ const root = useRef(null);
192
+ const [lastWidth, setLastWidth] = useState(null);
193
+ useLayoutEffect(() => {
194
+ const element = root.current;
195
+ if (width !== 'content' || !isSuccessView || !element)
196
+ return;
197
+ const observer = new ResizeObserver(() => setLastWidth(Math.round(element.getBoundingClientRect().width)));
198
+ observer.observe(element);
199
+ return () => observer.disconnect();
200
+ }, [width, isSuccessView]);
174
201
  const containerRef = useRef(null);
175
202
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
176
- return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
203
+ const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
204
+ ? { '--data-table-last-width': `${lastWidth}px` }
205
+ : undefined, children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
177
206
  // With the sticky bar below as the visible scrollbar, hide the
178
207
  // container's own. It stays overflow-x-auto and fully
179
208
  // scrollable (wheel, trackpad, drag, and the sync effect); it
180
209
  // just no longer draws a bar directly under the last row.
181
210
  containerClassName: cn(pinFirstColumn &&
182
- '@container [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
211
+ '@container [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
212
+ // A size container has no width of its own to give, so
213
+ // `width="content"` couldn't fit the table. Its query only
214
+ // serves a phone-width rule, and an 80rem table area never
215
+ // is one.
216
+ width === 'content' && '@7xl/data-table:[container-type:normal]'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
183
217
  const canSort = header.column.getCanSort();
184
218
  const sortDirection = header.column.getIsSorted();
185
219
  const content = header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext()));
@@ -195,4 +229,5 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
195
229
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn(pinFirstColumn && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
196
230
  index === row.getVisibleCells().length - 1 &&
197
231
  PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
232
+ return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [openField !== null && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
198
233
  }
@@ -0,0 +1,30 @@
1
+ type SafeParse = (value: unknown) => {
2
+ success: true;
3
+ data: unknown;
4
+ } | {
5
+ success: false;
6
+ error: {
7
+ issues: readonly {
8
+ message: string;
9
+ }[];
10
+ };
11
+ };
12
+ export type InlineCreateProps = {
13
+ /** What the box is for, e.g. "New category's name": its accessible name and placeholder. */
14
+ label: string;
15
+ /** The button's text. Defaults to "Add". */
16
+ addLabel?: string;
17
+ /** The form's rule for the field (`<entity>FormSchema.shape.name`). Its output is what `create` gets. */
18
+ schema?: {
19
+ safeParse: SafeParse;
20
+ };
21
+ /**
22
+ * Creates the record from the name: the entity's create mutation's
23
+ * `mutateAsync`, with the other fields at their defaults. Rejects with the
24
+ * AppError; a 422's field error shows under the box.
25
+ */
26
+ create: (value: string) => Promise<unknown>;
27
+ className?: string;
28
+ };
29
+ export declare function InlineCreate({ label, addLabel, schema, create, className }: InlineCreateProps): import("react").JSX.Element;
30
+ export {};
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Creates a record from its name, in a table's toolbar: the way in for a
3
+ // small entity edited in its rows (cell pattern 18, "In a table's rows").
4
+ // The rest of the new record's fields start at their defaults and are
5
+ // edited in its row like any other.
6
+ //
7
+ // - Enter or Add creates it. While it saves the box is read-only and Add
8
+ // shows a spinner; then the box empties and keeps the caret, so a run of
9
+ // records goes in from the keyboard, and a status line says what was added.
10
+ // - A name the form's schema refuses is never sent; a refusal from the
11
+ // server (a 422's field error, word for word) shows under the box with
12
+ // what was typed kept.
13
+ import { PlusIcon } from 'lucide-react';
14
+ import { useId, useRef, useState } from 'react';
15
+ import { cn } from 'cn';
16
+ import { Button } from '../ui/button.js';
17
+ import { Field, FieldError } from '../ui/field.js';
18
+ import { Input } from '../ui/input.js';
19
+ import { Spinner } from '../ui/spinner.js';
20
+ import { messageOf } from './edit-in-place-store.js';
21
+ export function InlineCreate({ label, addLabel = 'Add', schema, create, className }) {
22
+ const id = useId();
23
+ const input = useRef(null);
24
+ const [value, setValue] = useState('');
25
+ const [error, setError] = useState(null);
26
+ const [saving, setSaving] = useState(false);
27
+ const [added, setAdded] = useState('');
28
+ const submit = async (event) => {
29
+ event.preventDefault();
30
+ if (saving)
31
+ return;
32
+ const parsed = schema ? schema.safeParse(value) : { success: true, data: value.trim() };
33
+ if (!parsed.success) {
34
+ setError(parsed.error.issues[0]?.message ?? 'Check this value.');
35
+ return;
36
+ }
37
+ const name = String(parsed.data);
38
+ setSaving(true);
39
+ setError(null);
40
+ try {
41
+ await create(name);
42
+ setValue('');
43
+ setAdded(`Added ${name}`);
44
+ }
45
+ catch (refusal) {
46
+ setError(messageOf(refusal));
47
+ }
48
+ finally {
49
+ setSaving(false);
50
+ input.current?.focus();
51
+ }
52
+ };
53
+ return (_jsxs("form", { onSubmit: submit, noValidate: true, className: cn('flex items-start gap-2', className), "data-slot": "inline-create", children: [_jsxs(Field, { "data-invalid": error !== null, className: "w-72 max-w-full", children: [_jsx(Input, { ref: input, value: value, readOnly: saving, placeholder: label, "aria-label": label, "aria-invalid": error !== null, "aria-describedby": error !== null ? `${id}-error` : undefined, onChange: (event) => setValue(event.target.value) }), error !== null && (_jsx(FieldError, { id: `${id}-error`, className: "type-caption", children: error }))] }), _jsxs(Button, { type: "submit", variant: "outline", disabled: saving, children: [saving ? _jsx(Spinner, { "aria-hidden": "true", className: "size-4" }) : _jsx(PlusIcon, {}), addLabel] }), _jsx("span", { role: "status", className: "sr-only", children: saving ? 'Adding…' : added })] }));
54
+ }
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
2
2
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
3
- export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
3
+ export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
4
4
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
5
5
  export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
6
6
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListItemProps, } from './components/app/edit-in-place.js';
7
7
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
8
8
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
9
9
  export { ErrorState } from './components/app/error-state.js';
10
+ export { InlineCreate, type InlineCreateProps } from './components/app/inline-create.js';
10
11
  export { FoundationProviders } from './components/app/foundation-providers.js';
11
12
  export { Markdown, type MarkdownProps } from './components/app/markdown.js';
12
13
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { editInPlace, } from './components/app/edit-in-place.js';
12
12
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
13
13
  export { EditableValue } from './components/app/editable-value.js';
14
14
  export { ErrorState } from './components/app/error-state.js';
15
+ export { InlineCreate } from './components/app/inline-create.js';
15
16
  export { FoundationProviders } from './components/app/foundation-providers.js';
16
17
  export { Markdown } from './components/app/markdown.js';
17
18
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.25.0",
3
+ "version": "3.27.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": {
package/styles/theme.css CHANGED
@@ -66,54 +66,50 @@
66
66
  * to a slot in its own columns file, so a new enum value costs a line
67
67
  * there rather than three tokens here.
68
68
  *
69
- * Hues avoid the red and green bands that destructive/success already
70
- * spend, and the amber that warning does — a category drawn in red reads
71
- * as "bad". The blue slots (1, 6, 8) sit near info's hue, which came
72
- * later: a table using info keeps its categories off those three. They are ordered by distinctness, not by hue angle, so an app
73
- * using only the first three gets three obviously different colours.
74
- * Past about five, hue alone stops separating them and the glyph has to
75
- * carry the meaning; see docs/design-language.md. Twelve slots in all.
69
+ * Chosen to be told apart (#108), not spaced by hue: twelve colours a
70
+ * person names differently, from the whole hue circle, with lightness and
71
+ * chroma varied per slot as well as hue. Red, orange, brown and green are
72
+ * in: a category is a tinted glyph beside its name, never a status badge,
73
+ * so it doesn't read as a tone (design-language.md says where that still
74
+ * matters). Slot 1 (blue) sits near info's hue; a table using info keeps
75
+ * its categories off it.
76
76
  *
77
- * Ratios are against the page, measured (WCAG 2.x, sRGB), and all clear
78
- * the 3:1 non-text minimum in both themes with room to spare (4.5:1 at
79
- * the least, slots 9-12 on the dark card). axe does NOT check icon
80
- * contrast; the Storybook glyph check re-measures every slot on the
81
- * page and on a card (design-language/CategoricalColour).
82
- */
83
- --category-1-700: oklch(0.5 0.15 240);
84
- --category-1-400: oklch(0.78 0.12 240);
85
- --category-2-700: oklch(0.5 0.15 300);
86
- --category-2-400: oklch(0.78 0.12 300);
87
- --category-3-700: oklch(0.5 0.15 95);
88
- --category-3-400: oklch(0.78 0.12 95);
89
- --category-4-700: oklch(0.5 0.15 190);
90
- --category-4-400: oklch(0.78 0.12 190);
91
- --category-5-700: oklch(0.5 0.15 330);
92
- --category-5-400: oklch(0.78 0.12 330);
93
- --category-6-700: oklch(0.5 0.15 265);
94
- --category-6-400: oklch(0.78 0.12 265);
95
- --category-7-700: oklch(0.5 0.15 120);
96
- --category-7-400: oklch(0.78 0.12 120);
97
- --category-8-700: oklch(0.5 0.15 215);
98
- --category-8-400: oklch(0.78 0.12 215);
99
- /*
100
- * Slots 9-12 are a second channel, not new hues: the hues left between
101
- * the tones' are already spent ~25 degrees apart, so these are deeper
102
- * shades of the four that sit furthest from info (teal, plum, olive,
103
- * violet), darker than their slot in both themes. Each is at least as
104
- * far from its nearest slot (OKLab distance) as slots 1-8 are from each
105
- * other; tests/category-slots.test.ts holds every pair to that. A deep
106
- * shade of the blues or the yellow-green fell to half that distance, so
107
- * twelve is where it stops. Chroma is the most each holds in sRGB.
77
+ * Every pair is at least 17.9 apart in CIEDE2000 ΔE in both themes (the
78
+ * hue-spaced slots before them got to 8.8); tests/category-slots.test.ts
79
+ * holds every pair to 15. They are ordered so the first few stay apart
80
+ * under deuteranopia and protanopia too (the first six are 12 apart);
81
+ * past that, the glyph has to carry the meaning for those readers.
82
+ *
83
+ * Ratios are against the page, measured (WCAG 2.x, sRGB): all clear the
84
+ * 3:1 non-text minimum in both themes (3.75:1 at the least, orange and
85
+ * teal on white). axe does NOT check icon contrast; the Storybook glyph
86
+ * check re-measures every slot on the page and on a card
87
+ * (patterns/CategoricalColour).
108
88
  */
109
- --category-9-700: oklch(0.36 0.06 190);
110
- --category-9-400: oklch(0.62 0.1 190);
111
- --category-10-700: oklch(0.36 0.12 330);
112
- --category-10-400: oklch(0.62 0.15 330);
113
- --category-11-700: oklch(0.36 0.07 95);
114
- --category-11-400: oklch(0.62 0.12 95);
115
- --category-12-700: oklch(0.36 0.14 300);
116
- --category-12-400: oklch(0.62 0.17 300);
89
+ --category-1-700: oklch(0.44 0.11 245); /* blue */
90
+ --category-1-400: oklch(0.626 0.156 245);
91
+ --category-2-700: oklch(0.627 0.161 52); /* orange */
92
+ --category-2-400: oklch(0.722 0.16 52);
93
+ --category-3-700: oklch(0.492 0.105 152); /* green */
94
+ --category-3-400: oklch(0.763 0.17 152);
95
+ --category-4-700: oklch(0.55 0.254 305); /* purple */
96
+ --category-4-400: oklch(0.772 0.116 305);
97
+ --category-5-700: oklch(0.615 0.144 7); /* pink */
98
+ --category-5-400: oklch(0.812 0.102 7);
99
+ --category-6-700: oklch(0.445 0.1 56); /* brown */
100
+ --category-6-400: oklch(0.602 0.054 56);
101
+ --category-7-700: oklch(0.599 0.104 183); /* teal */
102
+ --category-7-400: oklch(0.849 0.139 183);
103
+ --category-8-700: oklch(0.503 0.176 27); /* red */
104
+ --category-8-400: oklch(0.646 0.152 27);
105
+ --category-9-700: oklch(0.573 0.115 84); /* gold */
106
+ --category-9-400: oklch(0.87 0.136 84);
107
+ --category-10-700: oklch(0.362 0.203 279); /* navy */
108
+ --category-10-400: oklch(0.601 0.164 279);
109
+ --category-11-700: oklch(0.593 0.121 119); /* lime */
110
+ --category-11-400: oklch(0.823 0.163 119);
111
+ --category-12-700: oklch(0.423 0.181 337); /* magenta */
112
+ --category-12-400: oklch(0.648 0.232 337);
117
113
  --blue-500: oklch(0.488 0.243 264.376);
118
114
 
119
115
  --radius-base: 0.625rem;