@tristan2828/ui-foundation 3.23.0 → 3.25.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.
@@ -241,6 +241,13 @@ is a filled-in example while the Widgets demo is still here).
241
241
  section that holds one long text or one sub-record list is a block
242
242
  (`content`) with no label of its own, since a label would only repeat
243
243
  the heading.
244
+ - **Layout.** With the plan's `Layout: rail`, pass `layout="rail"` and
245
+ give each section its Placement (`placement: 'rail'`; `main` is the
246
+ default and needs nothing). Without a `Layout` line, drop `layout`
247
+ and every `placement` from the copied view (one column). Widget's
248
+ view is `rail`, with Details in the rail. The rail's sections come
249
+ first in the page (header, rail, main) at every width, so the
250
+ view's specs find them first too.
244
251
  - **Every value renders the way its table cell does**: badges, names
245
252
  of linked records, a done-count, from the shared lookups in
246
253
  `<entity>-format.ts`. Never re-derive one for the view.
@@ -318,9 +325,16 @@ is a filled-in example while the Widgets demo is still here).
318
325
  ids as a set). A field gets `edit`, a `content` section gets `edit`,
319
326
  the title gets `titleEdit={{ label, ...edit }}`, and a header badge
320
327
  wraps itself in `<EditableValue label edit layout="inline">`. The
321
- shown value stays exactly as before. **With no `Edit in place` line,
322
- nothing is editable in place** and none of this is copied. Never a
323
- computed field or a sub-record list.
328
+ shown value stays exactly as before. A sub-record list the line
329
+ names is `editInPlace({ kind: 'list', ... })` on its `content`
330
+ section (cell pattern 18, "A list of sub-items"; copy `checklist` in
331
+ `widgetEdits`): the form's row as `renderItem`, from one component
332
+ shared with the form (`WidgetChecklistItemFields`), the item as the
333
+ view shows it (its quick action, if any) as `renderShown`, the list's
334
+ schema, and `save: (change) => edit.mutateAsync((current) => ({
335
+ <field>: change(current.<field>) }))`. **With no `Edit in place`
336
+ line, nothing is editable in place** and none of this is copied.
337
+ Never a computed field.
324
338
 
325
339
  **The table opens the view, and the form returns to it.** The title
326
340
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -426,12 +440,21 @@ is a filled-in example while the Widgets demo is still here).
426
440
  sent; a 422 (an MSW override, the reason on the field) and a 500 stay
427
441
  open with the draft and the reason; opening a second field saves the
428
442
  first, or doesn't open if that fails; and leaving the page with
429
- unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
443
+ unsaved text asks first. A list edited in place gets its own block
444
+ (copy `the checklist edited in place`): three items added by keyboard
445
+ alone and still there after leaving and coming back, an item edited
446
+ (Enter) and given up (Esc), move and remove with the PATCH each sends,
447
+ a blank item never sent, a refused change, and a tick plus an add both
448
+ landing. Add it to `mobile-chrome`'s `testMatch`. If
430
449
  the plan has Markdown long text, a test forces a record whose text has
431
450
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
432
451
  shows as text. Add the view's spec to `playwright.config.ts`'s
433
452
  `mobile-chrome` `testMatch`, with a test that at phone width each
434
- label sits above its value and nothing scrolls sideways.
453
+ label sits above its value and nothing scrolls sideways. With
454
+ `Layout: rail`, that test also checks the rail's sections come before
455
+ the main ones, and another that on a wide screen the main column
456
+ starts beside the rail (copy `wide screens: …` in
457
+ `e2e/widget-view.spec.ts`).
435
458
  States forced via a first-load init script (loading, load-time error)
436
459
  need `page.addInitScript` before navigation; states forced after the
437
460
  page is already up (a mutation's error response) can use a
@@ -449,8 +472,9 @@ is a filled-in example while the Widgets demo is still here).
449
472
  page is picked up from the sidebar automatically; the form and the view
450
473
  aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
451
474
  "editing in place" block for them: a field open, saving and refused,
452
- the rich-text editor, an open list, and the leave prompt, in both
453
- themes.
475
+ the rich-text editor, an open list, a list edited in place (an item
476
+ open, one being added, a refused change), and the leave prompt, in
477
+ both themes.
454
478
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
455
479
  anything beyond what this list covers; note ideas in the app's
456
480
  backlog instead.
@@ -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,56 @@ 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
+
772
822
  ## 19. Stage circle for an ordered status
773
823
 
774
824
  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
  |---|---|---|---|---|
@@ -123,15 +124,27 @@ except the quick actions, if any.
123
124
  linked from the table: `title`.
124
125
  - Badges beside the title: a few status-like fields, as badges: `status`,
125
126
  `priority`.
127
+ - Layout (optional; delete for one column): `rail` for a record with long
128
+ content (notes written as Markdown, long lists). The page takes the
129
+ screen's whole width, the short sections (a summary, label/value fields,
130
+ a few links) sit in a narrow column on the right that stays in view, and
131
+ the long ones fill the main column beside it. On a phone: one column,
132
+ the rail's sections first. Without this line, every section is in one
133
+ column, as wide as a long read.
134
+
135
+ Layout: rail
136
+
126
137
  - Sections, in order. Each lists its fields in the order they show. A
127
138
  section holding a single long text or a single sub-records list shows
128
- it under the section's heading, with no label of its own.
139
+ it under the section's heading, with no label of its own. With
140
+ `Layout: rail`, Placement says which column each goes in (`rail` or
141
+ `main`); otherwise delete the column.
129
142
 
130
- | Section | Fields |
131
- |---|---|
132
- | Details | dueDate, project, tags |
133
- | Checklist | checklist |
134
- | Notes | notes |
143
+ | Section | Fields | Placement |
144
+ |---|---|---|
145
+ | Details | dueDate, project, tags | rail |
146
+ | Checklist | checklist | main |
147
+ | Notes | notes | main |
135
148
 
136
149
  - "Not set" labels: what an empty optional field reads as on the view
137
150
  (`dueDate`: No due date; `checklist`: No items). A field not listed here
@@ -150,12 +163,12 @@ except the quick actions, if any.
150
163
  - Edit in place (optional; delete if none): the fields that turn into
151
164
  their form control where the view shows them and save when you leave
152
165
  them (cell pattern 18): the title, a badge field, a field row, a long
153
- text. Single values only: text, long text, a single choice, an integer
154
- or a rating, a yes/no, a single or multi reference. Never a computed
155
- field or a sub-records list (those stay on the form). Every value not
156
- named here, or under Quick actions, stays read-only. Say any rule the
157
- server enforces on one (its refusal shows under the field) and any
158
- side effect:
166
+ text, a sub-records list (its items added, edited, moved and removed on
167
+ the view). Text, long text, a single choice, an integer or a rating, a
168
+ yes/no, a single or multi reference, a sub-records list. Never a
169
+ computed field. Every value not named here, or under Quick actions,
170
+ stays read-only. Say any rule the server enforces on one (its refusal
171
+ shows under the field) and any side effect:
159
172
 
160
173
  Edit in place: title, status (the server refuses Finished while the
161
174
  checklist has open items), dueDate, notes
@@ -43,15 +43,18 @@ function TableSkeleton({ columnCount }) {
43
43
  // bleed-through a solid background avoids. On an even row it's the stripe
44
44
  // pre-mixed onto the page (--table-stripe), so the stripe runs the whole
45
45
  // 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)]`;
46
+ // background's, so the first column's hover below still wins on every row.
47
+ const PINNED_CELL_CLASS = 'z-10 bg-background [:where(tbody>tr:nth-child(even))>&]:bg-(--table-stripe)';
48
+ // The pinned first cell carries the hover highlight. --accent is only ~3%
49
+ // off the page background, so a solid left edge marker carries the signal;
50
+ // an inset shadow rather than a border, so nothing reflows.
51
+ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-hover/row:bg-accent ` +
52
+ 'group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]';
53
+ // The right-edge mirror, opaque for the same reason, but with no hover
54
+ // highlight (issue #103): it holds the row's actions, which name nothing,
55
+ // so lighting it too read as a second selected thing. Only the first
56
+ // column marks the hovered row.
57
+ const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
55
58
  // A pinned first column's content, capped below md at 45% of the table's
56
59
  // scroll area (`cqw`: the container is an inline-size query container)
57
60
  // and wrapping, so a long title can't widen the column past the screen
@@ -189,7 +192,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
189
192
  : sortDirection === 'desc'
190
193
  ? 'descending'
191
194
  : '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 &&
195
+ }) }, 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
196
  index === row.getVisibleCells().length - 1 &&
194
197
  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, {}) })] })] })] }))] }));
195
198
  }
@@ -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
  /**
@@ -21,7 +21,7 @@ export type EntityViewField = {
21
21
  * only repeat the heading. A block that's empty shows `emptyLabel`, the
22
22
  * same rule as a field.
23
23
  */
24
- export type EntityViewSection = {
24
+ export type EntityViewSection = ({
25
25
  /** The section's heading (an <h2>). Unique on the page. */
26
26
  title: string;
27
27
  fields: EntityViewField[];
@@ -29,9 +29,29 @@ 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;
40
+ }) & {
41
+ /**
42
+ * Where the section goes in the `rail` layout: `rail` for the short ones
43
+ * (a summary, label/value fields, a few links), `main` (the default) for
44
+ * long text and long lists. The `column` layout ignores it.
45
+ */
46
+ placement?: EntityViewPlacement;
34
47
  };
48
+ export type EntityViewPlacement = 'main' | 'rail';
49
+ /**
50
+ * `column` (the default): one column, at most max-w-4xl wide. `rail`: the
51
+ * content area's whole width, with the sections placed `rail` in a narrow
52
+ * column on the right (one column on a narrow screen, rail first).
53
+ */
54
+ export type EntityViewLayout = 'column' | 'rail';
35
55
  export type EntityViewProps = {
36
56
  /** The record's name or title, the page's <h1>. */
37
57
  title?: string;
@@ -48,6 +68,10 @@ export type EntityViewProps = {
48
68
  /** The header's actions: an Edit button, a Delete with its confirm dialog. */
49
69
  actions?: ReactNode;
50
70
  sections: EntityViewSection[];
71
+ /** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
72
+ layout?: EntityViewLayout;
73
+ /** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
74
+ railLabel?: string;
51
75
  isLoading: boolean;
52
76
  /** A `notfound` error (including another user's record) shows "Not found" and a way back, never a retry. */
53
77
  error?: AppError | null;
@@ -58,4 +82,4 @@ export type EntityViewProps = {
58
82
  label: string;
59
83
  };
60
84
  };
61
- export declare function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
85
+ export declare function EntityView({ title, titleEdit, badges, actions, sections, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Generic read-only view of one record, the read-side partner of
3
3
  // EntityForm: the record's title with a few status badges beside it, an
4
4
  // actions area (Edit, Delete), and sections of label/value rows laid out to
@@ -23,10 +23,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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.
27
- import { useId, useState, useSyncExternalStore } from 'react';
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.
29
+ //
30
+ // Two layouts. `column` (the default): every section in one column capped
31
+ // at max-w-4xl. `rail`, for a record with long content: the page takes the
32
+ // content area's whole width, sections placed `rail` go in a narrow column
33
+ // on the right that stays in view while the main column scrolls (an issue
34
+ // tracker's shape), and the rest fill the main column. Below the width two
35
+ // columns need, one column: header, rail, main. The DOM is in that order at
36
+ // every width, so reading and tab order match the screen.
37
+ import { useEffect, useId, useState, useSyncExternalStore } from 'react';
28
38
  import { Link } from 'react-router';
29
39
  import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
40
+ import { cn } from 'cn';
30
41
  import { ErrorState } from './error-state.js';
31
42
  import { Button } from '../ui/button.js';
32
43
  import { Card, CardContent, CardHeader } from '../ui/card.js';
@@ -34,6 +45,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTi
34
45
  import { Skeleton } from '../ui/skeleton.js';
35
46
  import { EditInPlaceContext } from './edit-in-place.js';
36
47
  import { EditInPlaceStore } from './edit-in-place-store.js';
48
+ import { EditableList } from './editable-list.js';
37
49
  import { EditableValue, LeaveGuard } from './editable-value.js';
38
50
  const DEFAULT_EMPTY_LABEL = 'Not set';
39
51
  function isEmptyValue(value) {
@@ -50,18 +62,79 @@ function orEmptyLabel(value, emptyLabel) {
50
62
  return isEmptyValue(value) ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel ?? DEFAULT_EMPTY_LABEL })) : (value);
51
63
  }
52
64
  // Label beside value from `sm` up; below it (a phone) the label sits
53
- // above its value, so nothing is squeezed into a narrow second column.
54
- function FieldRow({ field }) {
55
- return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
65
+ // above its value, so nothing is squeezed into a narrow second column. In
66
+ // the rail the list's own width decides instead (a container query on the
67
+ // <dl>): label above value in the narrow rail, beside it once the rail's
68
+ // sections stack full width under the header.
69
+ const FIELD_ROW = {
70
+ page: 'sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6',
71
+ rail: '@lg/fields:grid @lg/fields:grid-cols-[minmax(8rem,12rem)_1fr] @lg/fields:gap-6',
72
+ };
73
+ function FieldRow({ field, inRail }) {
74
+ return (_jsxs("div", { className: cn('flex flex-col gap-1 py-3 first:pt-0 last:pb-0', FIELD_ROW[inRail ? 'rail' : 'page']), children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
56
75
  }
57
- function ViewSection({ section }) {
76
+ function ViewSection({ section, inRail = false }) {
58
77
  const headingId = useId();
59
- 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: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, 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)) })) })] }) }));
79
+ }
80
+ // The rail layout's columns. The view is a container, so the switch to
81
+ // two columns follows the width the page actually has (the sidebar open
82
+ // or not), not the window's: two from 56rem (896px), where the main
83
+ // column still gets ~32rem beside a 22rem rail. Below that, one column in
84
+ // DOM order (header, rail, main); from it, the grid puts the rail on the
85
+ // right of row 1 and main on its left, both starting under the header.
86
+ const RAIL_LAYOUT = {
87
+ page: '@container/view flex min-w-0 flex-col gap-6',
88
+ columns: 'flex flex-col gap-6 @4xl/view:grid @4xl/view:grid-cols-[minmax(0,1fr)_22rem] @4xl/view:items-start',
89
+ rail: '@4xl/view:col-start-2 @4xl/view:row-start-1',
90
+ main: 'flex min-w-0 flex-col gap-6 @4xl/view:col-start-1 @4xl/view:row-start-1',
91
+ // Main alone (no rail sections): the whole row.
92
+ mainAlone: '@4xl/view:col-span-2',
93
+ // Sticky only beside main, and only while the whole rail fits on screen
94
+ // (`data-fits`, measured): a taller one scrolls with the page, so its end
95
+ // is never stuck below the fold.
96
+ sticky: '@4xl/view:data-[fits]:sticky @4xl/view:data-[fits]:top-6',
97
+ };
98
+ function SkeletonCard({ rows, stacked }) {
99
+ return (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: rows }, (_, row) => (_jsxs("div", { className: cn('flex flex-col gap-1', !stacked && 'sm:flex-row sm:gap-6'), children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: cn('h-5', stacked ? 'w-full' : 'flex-1') })] }, row))) })] }));
100
+ }
101
+ // The page's shape before the record arrives, in the layout it will have,
102
+ // so nothing jumps when it loads.
103
+ function EntityViewSkeleton({ layout }) {
104
+ const header = (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] })] }));
105
+ if (layout === 'rail') {
106
+ return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [_jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), _jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) })] })] }));
107
+ }
108
+ return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [header, Array.from({ length: 2 }, (_, section) => (_jsx(SkeletonCard, { rows: 4, stacked: false }, section)))] }));
109
+ }
110
+ // The rail's top offset (top-6) plus as much again below it.
111
+ const RAIL_MARGIN_PX = 48;
112
+ // Whether `element` fits in the window with room around it, re-measured
113
+ // whenever it or the window changes size (a field opening in place, a
114
+ // rotated phone).
115
+ function useFitsWindow() {
116
+ const [element, setElement] = useState(null);
117
+ const [fits, setFits] = useState(false);
118
+ useEffect(() => {
119
+ if (!element)
120
+ return;
121
+ const measure = () => setFits(element.offsetHeight + RAIL_MARGIN_PX <= window.innerHeight);
122
+ // A ResizeObserver reports once as soon as it observes: the first measure.
123
+ const observer = new ResizeObserver(measure);
124
+ observer.observe(element);
125
+ window.addEventListener('resize', measure);
126
+ return () => {
127
+ observer.disconnect();
128
+ window.removeEventListener('resize', measure);
129
+ };
130
+ }, [element]);
131
+ return [setElement, fits];
60
132
  }
61
- function EntityViewSkeleton() {
62
- return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
133
+ function Rail({ label, sections }) {
134
+ const [ref, fits] = useFitsWindow();
135
+ return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
63
136
  }
64
- export function EntityView({ title, titleEdit, badges, actions, sections, isLoading, error, onRetry, back, }) {
137
+ export function EntityView({ title, titleEdit, badges, actions, sections, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
65
138
  // Editing in place: one store per view, and the leave-page prompt only
66
139
  // while a field is open.
67
140
  const [store] = useState(() => new EditInPlaceStore());
@@ -74,6 +147,8 @@ export function EntityView({ title, titleEdit, badges, actions, sections, isLoad
74
147
  if (error)
75
148
  return _jsx(ErrorState, { error: error, onRetry: onRetry });
76
149
  if (isLoading)
77
- return _jsx(EntityViewSkeleton, {});
78
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [openField !== null && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }) }));
150
+ return _jsx(EntityViewSkeleton, { layout: layout });
151
+ const railSections = sections.filter((section) => section.placement === 'rail');
152
+ const mainSections = sections.filter((section) => section.placement !== 'rail');
153
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [openField !== null && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title)))] }) }));
79
154
  }
@@ -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
@@ -2,8 +2,8 @@ export { AppShell, type AppShellProps, type NavItem } from './components/app/app
2
2
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
3
3
  export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
4
4
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
5
- export { EntityView, type EntityViewField, 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';
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 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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.23.0",
3
+ "version": "3.25.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);