@tristan2828/ui-foundation 3.25.0 → 3.26.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
@@ -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.26.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": {