@tristan2828/ui-foundation 3.24.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
@@ -325,9 +337,16 @@ is a filled-in example while the Widgets demo is still here).
325
337
  ids as a set). A field gets `edit`, a `content` section gets `edit`,
326
338
  the title gets `titleEdit={{ label, ...edit }}`, and a header badge
327
339
  wraps itself in `<EditableValue label edit layout="inline">`. The
328
- shown value stays exactly as before. **With no `Edit in place` line,
329
- nothing is editable in place** and none of this is copied. Never a
330
- computed field or a sub-record list.
340
+ shown value stays exactly as before. A sub-record list the line
341
+ names is `editInPlace({ kind: 'list', ... })` on its `content`
342
+ section (cell pattern 18, "A list of sub-items"; copy `checklist` in
343
+ `widgetEdits`): the form's row as `renderItem`, from one component
344
+ shared with the form (`WidgetChecklistItemFields`), the item as the
345
+ view shows it (its quick action, if any) as `renderShown`, the list's
346
+ schema, and `save: (change) => edit.mutateAsync((current) => ({
347
+ <field>: change(current.<field>) }))`. **With no `Edit in place`
348
+ line, nothing is editable in place** and none of this is copied.
349
+ Never a computed field.
331
350
 
332
351
  **The table opens the view, and the form returns to it.** The title
333
352
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -433,7 +452,12 @@ is a filled-in example while the Widgets demo is still here).
433
452
  sent; a 422 (an MSW override, the reason on the field) and a 500 stay
434
453
  open with the draft and the reason; opening a second field saves the
435
454
  first, or doesn't open if that fails; and leaving the page with
436
- unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
455
+ unsaved text asks first. A list edited in place gets its own block
456
+ (copy `the checklist edited in place`): three items added by keyboard
457
+ alone and still there after leaving and coming back, an item edited
458
+ (Enter) and given up (Esc), move and remove with the PATCH each sends,
459
+ a blank item never sent, a refused change, and a tick plus an add both
460
+ landing. Add it to `mobile-chrome`'s `testMatch`. If
437
461
  the plan has Markdown long text, a test forces a record whose text has
438
462
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
439
463
  shows as text. Add the view's spec to `playwright.config.ts`'s
@@ -460,8 +484,9 @@ is a filled-in example while the Widgets demo is still here).
460
484
  page is picked up from the sidebar automatically; the form and the view
461
485
  aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
462
486
  "editing in place" block for them: a field open, saving and refused,
463
- the rich-text editor, an open list, and the leave prompt, in both
464
- themes.
487
+ the rich-text editor, an open list, a list edited in place (an item
488
+ open, one being added, a refused change), and the leave prompt, in
489
+ both themes.
465
490
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
466
491
  anything beyond what this list covers; note ideas in the app's
467
492
  backlog instead.
@@ -477,6 +502,8 @@ is a filled-in example while the Widgets demo is still here).
477
502
  the in-place a11y block unless the plan has an `Edit in place` line,
478
503
  and then only for the fields it names.
479
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).
480
507
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
481
508
  button group and `visibleColumns`) unless the plan's List screen lists
482
509
  saved views. When it does, copy it: `useTableUrlState`'s third argument
@@ -663,11 +663,11 @@ The rule behind every part of it: **nothing typed is ever lost.**
663
663
 
664
664
  **Which values.** Only those the plan's "View screen" names under `Edit
665
665
  in place`; read-only is the default, and a value not named renders
666
- exactly as it did. Single values only: text, long text, a single choice
667
- (optional ones with their "not set" option), an integer or a rating, a
668
- yes/no, a single or multi reference. Never a computed field, and never a
669
- sub-record list (adding, removing and reordering items stays on the
670
- form). The view's Edit button and the form stay.
666
+ exactly as it did. Text, long text, a single choice (optional ones with
667
+ their "not set" option), an integer or a rating, a yes/no, a single or
668
+ multi reference, and a sub-record list (its own rules, under "A list of
669
+ sub-items" below). Never a computed field. The view's Edit button and
670
+ the form stay.
671
671
 
672
672
  **At rest: says it's editable without shouting.** The value reads as it
673
673
  always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
@@ -769,6 +769,138 @@ const price = editInPlace({
769
769
  Keep it to the fields the plan names, and leave the form for everything
770
770
  else.
771
771
 
772
+ ### A list of sub-items
773
+
774
+ A sub-record list (a checklist, a list of links) on a `content` section,
775
+ edited where it's shown: `editInPlace({ kind: 'list', ... })`. Widget's
776
+ Checklist is the reference (`checklist` in `widgetEdits`). It's built on
777
+ `ListEditor`, so the rows and their buttons are the form's.
778
+
779
+ - **Each item reads as it always did** (`renderShown`), its own controls
780
+ included: a done box still ticks it on its own, a quick action. A click
781
+ on the item (not its box, not a link in it) or its pencil ("Edit item
782
+ 2") turns it into the form's row (`renderItem`, the same fields as the
783
+ form's `ListEditor` row), an editable value like any above: Enter or
784
+ leaving saves, Esc gives up, one open at a time.
785
+ - **Add at the end.** "Add item" opens a new row; Enter saves it and
786
+ opens the next, the caret already in it, so a run of items goes in from
787
+ the keyboard. Esc on an empty one closes it; nothing is sent.
788
+ - **Move and remove** are `ListEditor`'s buttons, with its names ("Move
789
+ item 2 up", "Remove item 2"). Each saves at once; whatever is open is
790
+ saved first. While it saves, the buttons wait and "Saving…" shows under
791
+ the list; the caret follows the item it moved. A refusal leaves the
792
+ list as it was, with the reason under it.
793
+ - **One save per change, of the whole list**, built on the record's
794
+ latest list when it's sent: `save` is given a change, not a list, and
795
+ hands it to the single-field save as a function of the record, so it
796
+ never undoes a tick or a change still saving.
797
+ - **The form's rules**, the whole list's schema, run on the list each
798
+ change would make: a message about an item shows under that item, one
799
+ about the list (too many items) under the row being added.
800
+ - `content` shows above the items (the done-count) and `emptyLabel` in
801
+ place of them when there are none.
802
+
803
+ ```tsx
804
+ const checklist = editInPlace<ChecklistItem>({
805
+ kind: 'list',
806
+ value: widget.checklist,
807
+ schema: widgetFormSchema.shape.checklist, // the whole list's rule
808
+ save: (change) => editField.mutateAsync((current) => ({ checklist: change(current.checklist) })),
809
+ newItem: () => ({ text: '', done: false }),
810
+ itemName: (index) => `item ${index + 1}`, // as the form's ListEditor names them
811
+ addLabel: 'Add item',
812
+ renderShown: (_item, index) => <ChecklistItem widget={widget} index={index} />, // the quick action
813
+ renderItem: (props) => <WidgetChecklistItemFields index={props.index} … />, // the form's row
814
+ })
815
+ // The section: { title: 'Checklist', content: <ChecklistDoneCount …/>, emptyLabel: 'No items', edit: checklist }
816
+ ```
817
+
818
+ The row's fields are one component shared with the form
819
+ (`WidgetChecklistItemFields` in `widget-fields.tsx`), each field named by
820
+ position ("Item 2 text") as the row buttons name the item.
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
+
772
904
  ## 19. Stage circle for an ordered status
773
905
 
774
906
  A status that's an ordered lifecycle (each record moves through the same
@@ -105,7 +105,7 @@ lower; say so in a comment.
105
105
 
106
106
  Tones say good, bad or *notice this*. A value that is merely *different* from its
107
107
  neighbours — a genre, a team, a category — needs a different mechanism, and
108
- it is `--category-1` through `--category-8`.
108
+ it is `--category-1` through `--category-12`.
109
109
 
110
110
  They are **generic slots, not per-value tokens**. An app maps its enum to a
111
111
  slot in its own columns file, so a new enum value costs a line there rather
@@ -120,31 +120,49 @@ const GENRE_COLOR: Record<Genre, string> = {
120
120
  ```
121
121
 
122
122
  Tailwind needs whole class names, so map the value to a complete class —
123
- never build one by interpolation, or the class won't be generated.
123
+ never build one by interpolation, or the class won't be generated. An app
124
+ that lets people pick a colour stores the slot's name (an enum of
125
+ `category-1` … `category-12` in its contract, with a CHECK on the column)
126
+ and maps each to its whole class the same way.
124
127
 
125
- Four things about them:
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 |
132
+
133
+ Five things about them:
126
134
 
127
135
  - **The hues avoid red, green and amber**, which the tones already spend. A
128
136
  category drawn in red reads as "this one is bad" even when nothing is
129
137
  wrong. Info's blue came later, and slots 1, 6 and 8 sit near it: a table
130
138
  that shows an info badge keeps its categories off those three, so a blue
131
- genre isn't read as "notice this".
139
+ genre isn't read as "notice this". That still leaves nine.
132
140
  - **They are ordered by distinctness, not by hue angle.** An app using
133
141
  three categories gets three obviously different colours. Past about five,
134
142
  hue alone stops separating them — slots 3 and 7 are both yellow-greens —
135
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).
136
152
  - **Tint the glyph, never fill behind text.** The label stays
137
153
  `text-foreground`, so readability never depends on the hue. A slot used
138
154
  as a background needs its own contrast check, which nothing here does
139
155
  for you.
140
- - **The glyph check re-measures them.** All eight clear the 3:1
141
- non-text minimum in both themes (recorded in `theme.css`). axe checks
142
- text contrast, not icon contrast, so the package's Storybook glyph check
143
- draws an icon in each slot (`patterns/CellPatterns`, "Icons with one
144
- tooltip") and fails a change that takes one below 3:1.
145
-
146
- If you find yourself wanting a ninth, that is usually a sign the column
147
- should be showing a shape or a label rather than more colours.
156
+ - **The glyph check re-measures them.** All twelve clear the 3:1
157
+ non-text minimum in both themes, on the page and on a card (recorded in
158
+ `theme.css`). axe checks text contrast, not icon contrast, so the
159
+ 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.
162
+
163
+ If you find yourself wanting a thirteenth, that is a sign the column should
164
+ be showing a shape or a label rather than more colours: there is no more
165
+ room in hue or lightness that stays readable.
148
166
 
149
167
  ## Typography
150
168
 
@@ -81,7 +81,8 @@ per list; delete the section if there are none.
81
81
  - On the view: always shown: the done-count, then each item in order (a
82
82
  `url` item field opens in a new tab). Read-only, unless the View
83
83
  screen's `Quick actions` names the list: then its yes/no item field is a
84
- checkbox on each item, ticked in place.
84
+ checkbox on each item, ticked in place. If its `Edit in place` names
85
+ the list, its items are also added, edited, moved and removed there.
85
86
 
86
87
  | Field | Label | Type | Required | Options / rules |
87
88
  |---|---|---|---|---|
@@ -110,6 +111,9 @@ per list; delete the section if there are none.
110
111
  | All tasks | none | dueDate asc | |
111
112
  | Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
112
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.
113
117
  - Anything else the table must show or do:
114
118
 
115
119
  ## View screen
@@ -162,12 +166,12 @@ except the quick actions, if any.
162
166
  - Edit in place (optional; delete if none): the fields that turn into
163
167
  their form control where the view shows them and save when you leave
164
168
  them (cell pattern 18): the title, a badge field, a field row, a long
165
- text. Single values only: text, long text, a single choice, an integer
166
- or a rating, a yes/no, a single or multi reference. Never a computed
167
- field or a sub-records list (those stay on the form). Every value not
168
- named here, or under Quick actions, stays read-only. Say any rule the
169
- server enforces on one (its refusal shows under the field) and any
170
- side effect:
169
+ text, a sub-records list (its items added, edited, moved and removed on
170
+ the view). Text, long text, a single choice, an integer or a rating, a
171
+ yes/no, a single or multi reference, a sub-records list. Never a
172
+ computed field. Every value not named here, or under Quick actions,
173
+ stays read-only. Say any rule the server enforces on one (its refusal
174
+ shows under the field) and any side effect:
171
175
 
172
176
  Edit in place: title, status (the server refuses Finished while the
173
177
  checklist has open items), dueDate, notes
@@ -175,7 +179,12 @@ except the quick actions, if any.
175
179
  ## Screens and access
176
180
 
177
181
  - Screens: list, view, create, edit, delete (the shape the playbook
178
- 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").
179
188
  - Ownership: **shared** (every signed-in user sees every record) or
180
189
  **per-user** (each user sees only their own).
181
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
@@ -43,15 +46,18 @@ function TableSkeleton({ columnCount }) {
43
46
  // bleed-through a solid background avoids. On an even row it's the stripe
44
47
  // pre-mixed onto the page (--table-stripe), so the stripe runs the whole
45
48
  // row's width (issue #92); `:where()` keeps that rule's specificity at the
46
- // background's, so the hover below still wins on every row. The pinned
47
- // cell carries the hover highlight. --accent is only ~3% off the page
48
- // background, so a solid left edge marker carries the signal; an inset
49
- // shadow rather than a border, so nothing reflows.
50
- const PINNED_CELL_CLASS = 'z-10 bg-background [:where(tbody>tr:nth-child(even))>&]:bg-(--table-stripe) group-hover/row:bg-accent';
51
- const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]`;
52
- // The right-edge mirror, opaque for the same reason. Its edge marker points
53
- // inward from the right, so the two pinned columns bracket the row.
54
- const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS} group-hover/row:shadow-[inset_-3px_0_0_0_var(--primary)]`;
49
+ // background's, so the first column's hover below still wins on every row.
50
+ const PINNED_CELL_CLASS = 'z-10 bg-background [:where(tbody>tr:nth-child(even))>&]:bg-(--table-stripe)';
51
+ // The pinned first cell carries the hover highlight. --accent is only ~3%
52
+ // off the page background, so a solid left edge marker carries the signal;
53
+ // an inset shadow rather than a border, so nothing reflows.
54
+ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:bg-accent ` +
55
+ 'group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]';
56
+ // The right-edge mirror, opaque for the same reason, but with no hover
57
+ // highlight (issue #103): it holds the row's actions, which name nothing,
58
+ // so lighting it too read as a second selected thing. Only the first
59
+ // column marks the hovered row.
60
+ const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
55
61
  // A pinned first column's content, capped below md at 45% of the table's
56
62
  // scroll area (`cqw`: the container is an inline-size query container)
57
63
  // and wrapping, so a long title can't widen the column past the screen
@@ -68,6 +74,12 @@ const PINNED_FIRST_CONTENT_CLASS = 'max-md:max-w-[45cqw] max-md:whitespace-norma
68
74
  // background so it reads identically on striped and unstriped rows, and it
69
75
  // stays on screen when the table is scrolled right.
70
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))]';
71
83
  // A second horizontal scrollbar, stuck to the viewport's bottom edge. The
72
84
  // real one sits directly under the last row, which on a full page is
73
85
  // off-screen until you have already scrolled past every row. Only rendered
@@ -130,7 +142,13 @@ function useBottomScrollbar(containerRef, isSuccessView) {
130
142
  }, [containerRef, isSuccessView, isOverflowing]);
131
143
  return { barRef, isOverflowing, scrollWidth };
132
144
  }
133
- 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);
134
152
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
135
153
  // TanStack treats a column missing from this map as visible, so every
136
154
  // column gets an entry once a list is given.
@@ -168,15 +186,34 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
168
186
  }, [isPastLastPage, pageCount, onPageChange]);
169
187
  const isEmpty = !isLoading && !error && data.length === 0 && !isPastLastPage;
170
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]);
171
201
  const containerRef = useRef(null);
172
202
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
173
- 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,
174
206
  // With the sticky bar below as the visible scrollbar, hide the
175
207
  // container's own. It stays overflow-x-auto and fully
176
208
  // scrollable (wheel, trackpad, drag, and the sync effect); it
177
209
  // just no longer draws a bar directly under the last row.
178
210
  containerClassName: cn(pinFirstColumn &&
179
- '@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) => {
180
217
  const canSort = header.column.getCanSort();
181
218
  const sortDirection = header.column.getIsSorted();
182
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()));
@@ -189,7 +226,8 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
189
226
  : sortDirection === 'desc'
190
227
  ? 'descending'
191
228
  : 'none', children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
192
- }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn((pinFirstColumn || pinLastColumn) && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
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 &&
193
230
  index === row.getVisibleCells().length - 1 &&
194
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] }));
195
233
  }
@@ -36,6 +36,8 @@ export type OpenFieldState = {
36
36
  /** A problem the control reports itself (rich text that can't save as it stands). */
37
37
  problem: string | null;
38
38
  };
39
+ /** Why a save failed, in words to show beside what was being saved. */
40
+ export declare function messageOf(error: unknown): string;
39
41
  export declare class EditInPlaceStore {
40
42
  private configs;
41
43
  private states;
@@ -1,7 +1,8 @@
1
1
  import { refusalReason } from '../../hooks/use-record-update.js';
2
2
  const CLOSED = { status: 'closed' };
3
3
  const sameAsJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
4
- function messageOf(error) {
4
+ /** Why a save failed, in words to show beside what was being saved. */
5
+ export function messageOf(error) {
5
6
  if (error && typeof error === 'object' && 'kind' in error)
6
7
  return refusalReason(error);
7
8
  if (error instanceof Error)
@@ -44,6 +44,7 @@ type SafeParseResult<TValue> = {
44
44
  error: {
45
45
  issues: readonly {
46
46
  message: string;
47
+ path?: readonly PropertyKey[];
47
48
  }[];
48
49
  };
49
50
  };
@@ -82,6 +83,67 @@ export type EditInPlaceOptions<TDraft, TValue> = {
82
83
  /** Whether a draft is still the saved value. Defaults to comparing as JSON. */
83
84
  isEqual?: (a: TDraft, b: TDraft) => boolean;
84
85
  };
86
+ /** What a list item's fields are given while it's edited: a control's props, plus where the item is. */
87
+ export type EditListItemProps<TItem> = EditControlProps<TItem> & {
88
+ /** The item's position; for the item being added, the one it will take (the list's length). */
89
+ index: number;
90
+ /** The item being added at the end, not one already in the list. */
91
+ isNew: boolean;
92
+ };
93
+ /**
94
+ * A list of sub-items edited in place (a checklist, a list of links), on
95
+ * a `content` section of EntityView. Make one with `editInPlace({ kind:
96
+ * 'list', ... })`. Every item reads as `renderShown` (a quick action, such
97
+ * as its done box, still works there); clicking it or its pencil turns it
98
+ * into `renderItem`, the form's own row. "Add" at the end opens a new item;
99
+ * move up, move down and remove are ListEditor's buttons. Each change is one
100
+ * save of the whole list.
101
+ */
102
+ export type EditInPlaceListOptions<TItem> = {
103
+ kind: 'list';
104
+ /** The saved list. */
105
+ value: readonly TItem[];
106
+ /**
107
+ * The item's own fields while it's edited: the form's row (what its
108
+ * ListEditor's `renderItem` shows), as controls over `props.value`.
109
+ */
110
+ renderItem: (props: EditListItemProps<TItem>) => ReactNode;
111
+ /** How the item reads when it isn't being edited. Its own controls (a done box) keep working. */
112
+ renderShown: (item: TItem, index: number) => ReactNode;
113
+ /** A blank item, what "Add" starts from. */
114
+ newItem: () => TItem;
115
+ /** How the buttons name an item, as on the form: (i) => `item ${i + 1}` gives "Move item 2 up". */
116
+ itemName: (index: number) => string;
117
+ /** The Add button's text, e.g. "Add item". */
118
+ addLabel: string;
119
+ /**
120
+ * The form's schema for the whole list (`widgetFormSchema.shape.checklist`).
121
+ * Each change is checked as the list it would make; a message about an
122
+ * item shows under that item.
123
+ */
124
+ schema?: {
125
+ safeParse: (value: unknown) => SafeParseResult<TItem[]>;
126
+ };
127
+ /**
128
+ * Saves a change to the list: call the record's single-field save with
129
+ * the change applied to the record's latest list, so it builds on any
130
+ * save still in flight (a tick, a move), e.g.
131
+ * `(change) => edit.mutateAsync((widget) => ({ checklist: change(widget.checklist) }))`.
132
+ * Rejects with the AppError; a 422's message shows where the change was made.
133
+ */
134
+ save: (change: (current: TItem[]) => TItem[]) => Promise<unknown>;
135
+ };
136
+ /** A list marked editable in place. Make one with `editInPlace({ kind: 'list', ... })`. */
137
+ export type EditInPlaceList = Omit<EditInPlaceListOptions<unknown>, 'renderItem' | 'renderShown' | 'schema' | 'save'> & {
138
+ renderItem: (props: EditListItemProps<unknown>) => ReactNode;
139
+ renderShown: (item: unknown, index: number) => ReactNode;
140
+ schema?: {
141
+ safeParse: (value: unknown) => SafeParseResult<unknown[]>;
142
+ };
143
+ save: (change: (current: unknown[]) => unknown[]) => Promise<unknown>;
144
+ };
145
+ /** Marks a list editable in place: its items added, edited, removed and moved on the view. */
146
+ export declare function editInPlace<TItem>(options: EditInPlaceListOptions<TItem>): EditInPlaceList;
85
147
  /** Marks a field editable in place, typed end to end: draft → schema → save. */
86
148
  export declare function editInPlace<TDraft, TValue = TDraft>(options: EditInPlaceOptions<TDraft, TValue>): EditInPlace;
87
149
  export declare const EditInPlaceContext: import("react").Context<EditInPlaceStore | null>;
@@ -1,9 +1,9 @@
1
1
  // Editing in place on a record's view: what a field passes to EntityView
2
2
  // (or to an EditableValue in its header) to become editable where it's
3
3
  // shown. The rules live in edit-in-place-store.ts; the markup in
4
- // editable-value.tsx. Design language: cell-patterns.md "Editable value".
4
+ // editable-value.tsx, and a list's in editable-list.tsx. Design language:
5
+ // cell-patterns.md "Editable value" and "Editable list".
5
6
  import { createContext } from 'react';
6
- /** Marks a field editable in place, typed end to end: draft → schema → save. */
7
7
  export function editInPlace(options) {
8
8
  return options;
9
9
  }
@@ -0,0 +1,6 @@
1
+ import { type EditInPlaceList } from './edit-in-place.js';
2
+ export declare function EditableList({ label, edit, emptyLabel }: {
3
+ label: string;
4
+ edit: EditInPlaceList;
5
+ emptyLabel?: string;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A list of sub-items edited in place on a record's view (cell-patterns.md
3
+ // "Editable list"): editInPlace's `list` kind, on a `content` section of
4
+ // EntityView. Built on ListEditor, so the rows and their buttons are the
5
+ // form's, with the same names ("Move item 2 up", "Remove item 2").
6
+ //
7
+ // - Each item reads as the app's `renderShown`, whose own controls (a
8
+ // done box) keep working. Clicking it, or its pencil, turns it into the
9
+ // form's row (`renderItem`), an EditableValue like any field: leaving or
10
+ // Enter saves, Esc gives up, one field open at a time.
11
+ // - "Add" at the end opens a new item the same way. Enter saves it and
12
+ // opens the next, so a run of items goes in from the keyboard.
13
+ // - Move and remove save at once. Whatever is open is saved first; while
14
+ // the change saves, the buttons wait, "Saving…" shows under the list,
15
+ // and a refusal leaves the list as it was with the reason under it.
16
+ // - Every change is one save of the whole list, built from the record's
17
+ // latest list when it's sent (`save` takes a change, not a list), so it
18
+ // never undoes a tick or another change still saving.
19
+ import { PlusIcon } from 'lucide-react';
20
+ import { useContext, useEffect, useId, useRef, useState } from 'react';
21
+ import { Button } from '../ui/button.js';
22
+ import { FieldError } from '../ui/field.js';
23
+ import { Spinner } from '../ui/spinner.js';
24
+ import { EditInPlaceContext } from './edit-in-place.js';
25
+ import { messageOf } from './edit-in-place-store.js';
26
+ import { EditableField } from './editable-value.js';
27
+ import { ListEditor } from './list-editor.js';
28
+ const sameItem = (a, b) => JSON.stringify(a) === JSON.stringify(b);
29
+ // Where the item shown at `index` is in the latest list: still there, or
30
+ // wherever a change saved before this one moved it, or, if it isn't
31
+ // anywhere as shown (it was itself just changed), the same position.
32
+ function locate(current, item, index) {
33
+ if (index < current.length && sameItem(current[index], item))
34
+ return index;
35
+ const found = current.findIndex((other) => sameItem(other, item));
36
+ return found === -1 ? index : found;
37
+ }
38
+ // The list's schema, run on the list this draft would make. A message
39
+ // about this item comes first, then one about the list as a whole (too
40
+ // many items).
41
+ function checkItem(edit, items, index, draft) {
42
+ if (!edit.schema)
43
+ return { success: true, data: draft };
44
+ const list = index < items.length ? items.map((item, i) => (i === index ? draft : item)) : [...items, draft];
45
+ const result = edit.schema.safeParse(list);
46
+ if (result.success)
47
+ return { success: true, data: result.data[index] };
48
+ const { issues } = result.error;
49
+ const issue = issues.find(({ path }) => path?.[0] === index) ?? issues.find(({ path }) => !path?.length) ?? issues[0];
50
+ return { success: false, error: { issues: issue ? [issue] : [] } };
51
+ }
52
+ export function EditableList({ label, edit, emptyLabel }) {
53
+ const store = useContext(EditInPlaceContext);
54
+ if (!store)
55
+ throw new Error('A list edited in place works inside an <EntityView>, which runs editing in place.');
56
+ const items = edit.value;
57
+ const statusId = useId();
58
+ const [status, setStatus] = useState({ saving: false, error: null });
59
+ // The buttons are disabled while a change saves, which drops the caret.
60
+ // Once the list shows the outcome it goes to the first of these buttons
61
+ // (by name) that's there and enabled, else to Add.
62
+ const root = useRef(null);
63
+ const focusNext = useRef(null);
64
+ useEffect(() => {
65
+ const names = focusNext.current;
66
+ if (status.saving || !names)
67
+ return;
68
+ focusNext.current = null;
69
+ const buttons = [...(root.current?.querySelectorAll('button:not(:disabled)') ?? [])];
70
+ const target = names.map((name) => buttons.find((button) => button.getAttribute('aria-label') === name)).find(Boolean);
71
+ (target ?? root.current?.querySelector('[data-list-add]:not(:disabled)'))?.focus();
72
+ }, [items, status.saving]);
73
+ // Move and remove: save the open field first (if it can't save, it stays
74
+ // and nothing else happens), then the change.
75
+ const act = async (change, focusAfter, focusIfRefused) => {
76
+ if (status.saving)
77
+ return;
78
+ const open = store.open;
79
+ if (open !== null && !(await store.commit(open)))
80
+ return;
81
+ setStatus({ saving: true, error: null });
82
+ try {
83
+ await edit.save(change);
84
+ focusNext.current = focusAfter;
85
+ setStatus({ saving: false, error: null });
86
+ }
87
+ catch (error) {
88
+ focusNext.current = focusIfRefused;
89
+ setStatus({ saving: false, error: messageOf(error) });
90
+ }
91
+ };
92
+ const move = (from, to) => {
93
+ const direction = to < from ? 'up' : 'down';
94
+ const other = direction === 'up' ? 'down' : 'up';
95
+ void act((current) => {
96
+ const at = locate(current, items[from], from);
97
+ const target = at + (to - from);
98
+ if (target < 0 || target >= current.length)
99
+ return current;
100
+ const next = [...current];
101
+ next.splice(target, 0, ...next.splice(at, 1));
102
+ return next;
103
+ }, [`Move ${edit.itemName(to)} ${direction}`, `Move ${edit.itemName(to)} ${other}`], [`Move ${edit.itemName(from)} ${direction}`]);
104
+ };
105
+ const remove = (index) => void act((current) => {
106
+ const at = locate(current, items[index], index);
107
+ return current.filter((_, i) => i !== at);
108
+ }, [`Remove ${edit.itemName(index)}`, `Remove ${edit.itemName(index - 1)}`], [`Remove ${edit.itemName(index)}`]);
109
+ // An item, edited as the form's row.
110
+ const itemEdit = (index) => ({
111
+ kind: 'text',
112
+ value: items[index],
113
+ control: (props) => edit.renderItem({ ...props, index, isNew: false }),
114
+ schema: { safeParse: (draft) => checkItem(edit, items, index, draft) },
115
+ save: (item) => edit.save((current) => {
116
+ const at = locate(current, items[index], index);
117
+ return current.map((other, i) => (i === at ? item : other));
118
+ }),
119
+ });
120
+ // The item being added, at the end.
121
+ const addEdit = {
122
+ kind: 'text',
123
+ value: edit.newItem(),
124
+ control: (props) => edit.renderItem({ ...props, index: items.length, isNew: true }),
125
+ schema: { safeParse: (draft) => checkItem(edit, items, items.length, draft) },
126
+ save: (item) => edit.save((current) => [...current, item]),
127
+ };
128
+ return (_jsxs("div", { ref: root, className: "flex flex-col gap-2", "data-slot": "editable-list", children: [_jsx(ListEditor, { "aria-label": label, items: items.map((_, index) => ({ id: String(index) })), itemName: edit.itemName, addLabel: edit.addLabel, emptyText: emptyLabel, disabled: status.saving, onRemove: remove, onMove: move, renderItem: (index) => (_jsx(EditableField, { label: edit.itemName(index), edit: itemEdit(index), children: edit.renderShown(items[index], index) })), add: _jsx(EditableField, { label: edit.itemName(items.length), edit: addEdit, reopenAfterKeyboardSave: true, children: null, trigger: (start) => (_jsx("div", { children: _jsxs(Button, { "data-list-add": "", type: "button", variant: "outline", size: "sm", disabled: status.saving, onClick: start, children: [_jsx(PlusIcon, {}), edit.addLabel] }) })) }) }), _jsx("span", { id: statusId, className: "type-body", children: status.saving ? (_jsxs("span", { role: "status", className: "inline-flex items-center gap-1.5 type-caption text-muted-foreground", children: [_jsx(Spinner, { "aria-hidden": "true", className: "size-3.5" }), "Saving\u2026"] })) : (status.error && _jsx(FieldError, { className: "type-caption", children: status.error })) })] }));
129
+ }
@@ -11,7 +11,19 @@ export type EditableValueProps = {
11
11
  layout?: 'inline' | 'block';
12
12
  className?: string;
13
13
  };
14
- export declare function EditableValue({ label, edit, children, layout, className }: EditableValueProps): import("react").JSX.Element;
14
+ /** EditableValue's own props, plus what a list's rows and its Add box need (editable-list.tsx). */
15
+ export type EditableFieldProps = EditableValueProps & {
16
+ /**
17
+ * The closed field's own trigger, instead of the value and its pencil:
18
+ * the list's Add button. Given what opens the field; its button is where
19
+ * the caret goes back to.
20
+ */
21
+ trigger?: (start: () => void) => ReactNode;
22
+ /** Opens again, empty, once a save from the keyboard succeeds: adding one item after another. */
23
+ reopenAfterKeyboardSave?: boolean;
24
+ };
25
+ export declare function EditableValue(props: EditableValueProps): import("react").JSX.Element;
26
+ export declare function EditableField({ label, edit, children, layout, className, trigger, reopenAfterKeyboardSave, }: EditableFieldProps): import("react").JSX.Element;
15
27
  /**
16
28
  * While a field holds something unsaved, leaving asks first: an in-app
17
29
  * link or Back (React Router's blocker, so the app needs a data router),
@@ -21,12 +21,14 @@ import { FieldError } from '../ui/field.js';
21
21
  import { Spinner } from '../ui/spinner.js';
22
22
  import { EditInPlaceContext } from './edit-in-place.js';
23
23
  // What a click on the shown value must leave alone: links navigate,
24
- // buttons do their own thing.
25
- const INTERACTIVE = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
24
+ // buttons and boxes do their own thing (a list item's done box ticks it).
25
+ const INTERACTIVE = 'a[href], button, input, select, textarea, label, [role="button"], [role="link"], [role="checkbox"], [role="switch"]';
26
26
  // Where the caret goes once the control shows: the first match, in this
27
- // order (a picker's text box before its chips' remove buttons).
27
+ // order (a picker's text box before its chips' remove buttons). A box's
28
+ // own native input (a checkbox's, hidden and out of the tab order) isn't
29
+ // a place to type: a list item's text box comes first.
28
30
  const FOCUS_ORDER = [
29
- 'input:not([type="hidden"]), textarea, [contenteditable="true"]',
31
+ 'input:not([type="hidden"]):not([tabindex="-1"]):not([aria-hidden="true"]), textarea, [contenteditable="true"]',
30
32
  '[role="combobox"], [role="switch"], button, [tabindex]:not([tabindex="-1"])',
31
33
  ];
32
34
  // Focus moving here doesn't save the field: a link that leaves the page
@@ -42,7 +44,10 @@ function useStore() {
42
44
  throw new Error('<EditableValue> works inside an <EntityView>, which runs editing in place.');
43
45
  return store;
44
46
  }
45
- export function EditableValue({ label, edit, children, layout = 'block', className }) {
47
+ export function EditableValue(props) {
48
+ return _jsx(EditableField, { ...props });
49
+ }
50
+ export function EditableField({ label, edit, children, layout = 'block', className, trigger, reopenAfterKeyboardSave = false, }) {
46
51
  const store = useStore();
47
52
  const key = useId();
48
53
  const controlId = `${key}-control`;
@@ -55,6 +60,8 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
55
60
  useEffect(() => store.register(key, () => config.current), [store, key]);
56
61
  const state = useSyncExternalStore(store.subscribe, () => store.state(key), () => store.state(key));
57
62
  const editButton = useRef(null);
63
+ // Holds a trigger (the list's Add): its button is the one the caret goes back to.
64
+ const triggerBox = useRef(null);
58
65
  const container = useRef(null);
59
66
  // Set by a keyboard save or Esc: the caret goes back to the edit button.
60
67
  const refocus = useRef(false);
@@ -76,7 +83,7 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
76
83
  // from a list), so the keyboard never loses its place.
77
84
  if (!isOpen && wasOpen.current && (refocus.current || document.activeElement === document.body)) {
78
85
  refocus.current = false;
79
- editButton.current?.focus();
86
+ (editButton.current ?? triggerBox.current?.querySelector('button'))?.focus();
80
87
  }
81
88
  wasOpen.current = isOpen;
82
89
  }, [isOpen]);
@@ -102,6 +109,19 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
102
109
  void store.commit(key, value);
103
110
  }, [store, key]);
104
111
  const cancel = useCallback(() => store.cancel(key), [store, key]);
112
+ // Enter or Ctrl/Cmd+Enter: the caret goes back to the edit button, or
113
+ // the field opens again for the next one.
114
+ const commitFromKeyboard = () => {
115
+ window.clearTimeout(blurTimer.current);
116
+ refocus.current = true;
117
+ void store.commit(key).then((closed) => {
118
+ if (!closed || !reopenAfterKeyboardSave)
119
+ return;
120
+ // The caret stays in (or comes back to) the field opening again.
121
+ refocus.current = false;
122
+ void store.start(key);
123
+ });
124
+ };
105
125
  const onShownClick = (event) => {
106
126
  if (event.target.closest(INTERACTIVE))
107
127
  return;
@@ -120,13 +140,11 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
120
140
  }
121
141
  else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
122
142
  event.preventDefault();
123
- refocus.current = true;
124
- commit();
143
+ commitFromKeyboard();
125
144
  }
126
145
  else if (event.key === 'Enter' && edit.kind === 'text' && !event.shiftKey && !event.altKey) {
127
146
  event.preventDefault();
128
- refocus.current = true;
129
- commit();
147
+ commitFromKeyboard();
130
148
  }
131
149
  };
132
150
  // Leaving the field saves it. Focus moving into the control's own list
@@ -142,6 +160,8 @@ export function EditableValue({ label, edit, children, layout = 'block', classNa
142
160
  };
143
161
  const onFocus = () => window.clearTimeout(blurTimer.current);
144
162
  if (state.status === 'closed') {
163
+ if (trigger)
164
+ return (_jsx("span", { ref: triggerBox, className: "contents", children: trigger(start) }));
145
165
  return (_jsxs("span", { "data-slot": "editable-value", onClick: onShownClick, className: cn('group/editable relative -mx-1.5 cursor-text items-start gap-1 rounded-md px-1.5 transition-colors hover:bg-muted has-[[data-edit]:focus-visible]:bg-muted', layout === 'block' ? 'flex w-full' : 'inline-flex max-w-full align-top', className), children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(Button, { ref: editButton, "data-edit": "", type: "button", variant: "ghost", size: "icon-xs", "aria-label": `Edit ${label}`, onClick: start, className: "shrink-0 text-muted-foreground opacity-0 group-hover/editable:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", children: _jsx(PencilIcon, {}) })] }));
146
166
  }
147
167
  const saving = state.status === 'saving';
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { AppError } from '../../api/contracts.js';
3
- import { type EditInPlace } from './edit-in-place.js';
3
+ import { type EditInPlace, type EditInPlaceList } from './edit-in-place.js';
4
4
  export type EntityViewField = {
5
5
  label: string;
6
6
  /**
@@ -29,8 +29,14 @@ export type EntityViewSection = ({
29
29
  title: string;
30
30
  content: ReactNode;
31
31
  emptyLabel?: string;
32
- /** The block is editable in place (long text), named by the section's title. */
33
- edit?: EditInPlace;
32
+ /**
33
+ * The block is editable in place, named by the section's title: long
34
+ * text (`editInPlace({ kind: 'long-text', ... })`), or a list of
35
+ * sub-items (`editInPlace({ kind: 'list', ... })`). A list shows its
36
+ * own items, with `content` above them (a done-count, or nothing),
37
+ * and `emptyLabel` in place of the items when there are none.
38
+ */
39
+ edit?: EditInPlace | EditInPlaceList;
34
40
  }) & {
35
41
  /**
36
42
  * Where the section goes in the `rail` layout: `rail` for the short ones
@@ -23,7 +23,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
23
23
  // saves when you leave it. This component runs it (one field open at a
24
24
  // time, the leave-page prompt); EditableValue is each value's side, and a
25
25
  // header badge uses it directly. A field not marked renders exactly as
26
- // before. Edit, in `actions`, stays for everything else.
26
+ // before. Edit, in `actions`, stays for everything else. A sub-item list
27
+ // edits in place too (editInPlace's `list` kind, editable-list.tsx): its
28
+ // items added, edited, moved and removed on the page.
27
29
  //
28
30
  // Two layouts. `column` (the default): every section in one column capped
29
31
  // at max-w-4xl. `rail`, for a record with long content: the page takes the
@@ -43,6 +45,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTi
43
45
  import { Skeleton } from '../ui/skeleton.js';
44
46
  import { EditInPlaceContext } from './edit-in-place.js';
45
47
  import { EditInPlaceStore } from './edit-in-place-store.js';
48
+ import { EditableList } from './editable-list.js';
46
49
  import { EditableValue, LeaveGuard } from './editable-value.js';
47
50
  const DEFAULT_EMPTY_LABEL = 'Not set';
48
51
  function isEmptyValue(value) {
@@ -72,7 +75,7 @@ function FieldRow({ field, inRail }) {
72
75
  }
73
76
  function ViewSection({ section, inRail = false }) {
74
77
  const headingId = useId();
75
- return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: cn('flex flex-col divide-y divide-border', inRail && '@container/fields'), children: section.fields.map((field) => (_jsx(FieldRow, { field: field, inRail: inRail }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
78
+ return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: cn('flex flex-col divide-y divide-border', inRail && '@container/fields'), children: section.fields.map((field) => (_jsx(FieldRow, { field: field, inRail: inRail }, field.label))) })) : section.edit?.kind === 'list' ? (_jsxs("div", { className: "flex min-w-0 flex-col gap-2 type-body break-words text-foreground", children: [!isEmptyValue(section.content) && section.content, _jsx(EditableList, { label: section.title, edit: section.edit, emptyLabel: section.emptyLabel ?? DEFAULT_EMPTY_LABEL })] })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: section.edit ? (_jsx(EditableValue, { label: section.title, edit: section.edit, children: orEmptyLabel(section.content, section.emptyLabel) })) : (orEmptyLabel(section.content, section.emptyLabel)) })) })] }) }));
76
79
  }
77
80
  // The rail layout's columns. The view is a container, so the switch to
78
81
  // two columns follows the width the page actually has (the sidebar open
@@ -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
+ }
@@ -1,12 +1,13 @@
1
1
  import type { ReactNode } from 'react';
2
- export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, 'aria-label': ariaLabel, }: {
2
+ export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
3
3
  /** One entry per item, with a stable key (useFieldArray's `fields`). */
4
4
  items: readonly {
5
5
  id: string;
6
6
  }[];
7
7
  /** The item's own fields, for the item at this index. */
8
8
  renderItem: (index: number) => ReactNode;
9
- onAdd: () => void;
9
+ /** The Add button's action. Not needed with `add`. */
10
+ onAdd?: () => void;
10
11
  onRemove: (index: number) => void;
11
12
  onMove: (from: number, to: number) => void;
12
13
  /**
@@ -20,6 +21,10 @@ export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove,
20
21
  emptyText?: string;
21
22
  /** The most items allowed; Add is disabled once reached. */
22
23
  max?: number;
24
+ /** In place of the Add button: what adds an item (editing in place's Add box). */
25
+ add?: ReactNode;
26
+ /** The row buttons and Add can't be used (a change is saving). */
27
+ disabled?: boolean;
23
28
  /** Names the list (e.g. the field's label). */
24
29
  'aria-label'?: string;
25
30
  }): import("react").JSX.Element;
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react';
3
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, }) {
4
+ export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
5
5
  const atMax = max !== undefined && items.length >= max;
6
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
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] }) })] }));
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: 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: 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}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }, item.id));
9
+ }) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
10
10
  }
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
- export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
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.24.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": {
package/styles/index.css CHANGED
@@ -57,6 +57,10 @@
57
57
  --color-category-6: var(--category-6);
58
58
  --color-category-7: var(--category-7);
59
59
  --color-category-8: var(--category-8);
60
+ --color-category-9: var(--category-9);
61
+ --color-category-10: var(--category-10);
62
+ --color-category-11: var(--category-11);
63
+ --color-category-12: var(--category-12);
60
64
  --color-accent-foreground: var(--accent-foreground);
61
65
  --color-accent: var(--accent);
62
66
  --color-muted-foreground: var(--muted-foreground);
package/styles/theme.css CHANGED
@@ -72,12 +72,13 @@
72
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
73
  * using only the first three gets three obviously different colours.
74
74
  * Past about five, hue alone stops separating them and the glyph has to
75
- * carry the meaning; see docs/design-language.md.
75
+ * carry the meaning; see docs/design-language.md. Twelve slots in all.
76
76
  *
77
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. Note that
79
- * axe does NOT check icon contrast, so unlike the tones nothing
80
- * re-measures these automatically.
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).
81
82
  */
82
83
  --category-1-700: oklch(0.5 0.15 240);
83
84
  --category-1-400: oklch(0.78 0.12 240);
@@ -95,6 +96,24 @@
95
96
  --category-7-400: oklch(0.78 0.12 120);
96
97
  --category-8-700: oklch(0.5 0.15 215);
97
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.
108
+ */
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);
98
117
  --blue-500: oklch(0.488 0.243 264.376);
99
118
 
100
119
  --radius-base: 0.625rem;
@@ -198,6 +217,10 @@
198
217
  --category-6: var(--category-6-700);
199
218
  --category-7: var(--category-7-700);
200
219
  --category-8: var(--category-8-700);
220
+ --category-9: var(--category-9-700);
221
+ --category-10: var(--category-10-700);
222
+ --category-11: var(--category-11-700);
223
+ --category-12: var(--category-12-700);
201
224
  /* The zebra stripe (table.tsx: bg-muted/40 on even rows) mixed onto the
202
225
  page, opaque: what a pinned table cell paints on an even row, so it
203
226
  matches the row without letting scrolled columns show through. Mixed
@@ -274,6 +297,10 @@
274
297
  --category-6: var(--category-6-400);
275
298
  --category-7: var(--category-7-400);
276
299
  --category-8: var(--category-8-400);
300
+ --category-9: var(--category-9-400);
301
+ --category-10: var(--category-10-400);
302
+ --category-11: var(--category-11-400);
303
+ --category-12: var(--category-12-400);
277
304
  --table-stripe: color-mix(in srgb, var(--muted) 40%, var(--background));
278
305
  --border: var(--white-a10);
279
306
  --input: var(--white-a15);