@tristan2828/ui-foundation 4.31.0 → 4.33.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.
Files changed (32) hide show
  1. package/conventions/AGENTS.md +2 -1
  2. package/conventions/docs/add-an-entity.md +13 -2
  3. package/conventions/docs/cell-patterns.md +8 -0
  4. package/conventions/docs/design-language.md +97 -6
  5. package/dist/components/app/data-table.d.ts +21 -1
  6. package/dist/components/app/data-table.js +20 -4
  7. package/dist/components/app/list-editor-rows.d.ts +18 -0
  8. package/dist/components/app/list-editor-rows.js +23 -0
  9. package/dist/components/app/list-editor.d.ts +25 -3
  10. package/dist/components/app/list-editor.js +61 -5
  11. package/dist/components/app/markdown.d.ts +10 -2
  12. package/dist/components/app/markdown.js +80 -11
  13. package/dist/components/app/milkdown-editor.d.ts +1 -1
  14. package/dist/components/app/milkdown-editor.js +19 -10
  15. package/dist/components/app/module-card.d.ts +53 -0
  16. package/dist/components/app/module-card.js +95 -0
  17. package/dist/components/app/prose-look.d.ts +26 -6
  18. package/dist/components/app/prose-look.js +24 -6
  19. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  20. package/dist/components/app/prose-table-scroll.js +87 -0
  21. package/dist/components/app/rich-text-editor.d.ts +10 -0
  22. package/dist/components/app/rich-text-editor.js +5 -0
  23. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  24. package/dist/components/app/rich-text-node-views.js +108 -5
  25. package/dist/components/app/widget-card.js +13 -4
  26. package/dist/components/ui/collapsible.d.ts +5 -0
  27. package/dist/components/ui/collapsible.js +12 -0
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.js +2 -1
  30. package/package.json +1 -1
  31. package/styles/index.css +14 -0
  32. package/styles/theme.css +5 -0
@@ -84,7 +84,8 @@ Every data view handles: loading, empty, error, and success. Use
84
84
  `<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
85
85
  and `EntityView` (one record) adds not found: a plain 404, never a retry.
86
86
  A small widget on a hub page is a `WidgetCard`, which holds all four for
87
- its own data, its error inside the card.
87
+ its own data, its error inside the card; a hub's place (a module, with
88
+ one live number) is a `ModuleCard` in a `ModuleStrip`, the same way.
88
89
  Every screen has one Playwright test per state, forced via MSW overrides
89
90
  (`@tristan2828/ui-foundation/testing`).
90
91
 
@@ -152,7 +152,15 @@ is a filled-in example while the Widgets demo is still here).
152
152
  `<ListEditor>` inside a `FieldSet`/`FieldLegend` on the form, each row's
153
153
  controls named by position ("Item 2 text"), a per-item `FieldError`
154
154
  (a server 422 at `["body", "checklist", 2, "text"]` arrives as
155
- `fieldErrors["checklist.2.text"]` and binds to that row), and a summary
155
+ `fieldErrors["checklist.2.text"]` and binds to that row). A list whose
156
+ items are rich (five or six fields each) and run to ten or more also
157
+ passes `summary={(i) => …}`, one line per item that opens to its
158
+ fields, one at a time, and `invalid={(i) => !!errors.<list>?.[i]}` so
159
+ an item the save refuses opens to show why. A list whose items repeat
160
+ with small changes passes
161
+ ``onDuplicate={(i) => insert(i + 1, { ...getValues(`<list>.${i}`) })}``,
162
+ leaving out any saved id; the copy lands after its original and opens.
163
+ Then a summary
156
164
  in the table, never the items (an item with a `reference` field
157
165
  copies Pairings, below) — and a `computed` field copies
158
166
  Progress (`checklistState`): `readOnly` in `openapi.yaml` and absent
@@ -438,7 +446,10 @@ is a filled-in example while the Widgets demo is still here).
438
446
  (`> [!NOTE]`) and links, links opening in a new tab; raw HTML shows
439
447
  as the characters typed, never as markup. The app needs no Markdown dependency of its own. Plain
440
448
  long text renders as text (`whitespace-pre-line` keeps its line
441
- breaks).
449
+ breaks). Give `<Markdown>` and `RichTextEditor` a `headingLevel` for
450
+ where they sit: the default (3) in an `EntityView` section,
451
+ `headingLevel={2}` for a `RichTextEditor` on a form straight under
452
+ the page's `<h1>`.
442
453
  - **Quick actions: only the fields the plan's `Quick actions` line
443
454
  names**, each a control that saves on its own (cell pattern 17,
444
455
  `widget-quick-actions.tsx`). A `yes/no` is a
@@ -388,6 +388,14 @@ cell: ({ getValue }) => {
388
388
  - An API `format: decimal` arrives as a **string**. `Number()` is exact
389
389
  enough to display (about 15 significant digits). Never add up money in
390
390
  floats on the client: totals come from the backend.
391
+ - **A column's total goes in `DataTable`'s `totals`**:
392
+ ``totals={data && { label: `Total · ${data.total} items`, cells: { price: usd(data.totals.price) } }}``.
393
+ One formatted value per column id, from the list endpoint beside
394
+ `total`, so it covers every matching row, not only the page. It renders
395
+ as a footer row: the label in a row header in the first visible column,
396
+ each value under its column in that column's alignment and tabular
397
+ figures, above a 2px border. A hidden column's value isn't shown, and
398
+ the row is hidden while loading, on error and when empty.
391
399
  - **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
392
400
  supports `'center'`, and a right-aligned cell under a left-aligned header
393
401
  looks broken. Stay left-aligned until a real column needs
@@ -221,7 +221,7 @@ room that stays readable.
221
221
 
222
222
  ## Typography
223
223
 
224
- Five roles cover every screen. Each is one class that sets size, line
224
+ Six roles cover every screen. Each is one class that sets size, line
225
225
  height and weight together, from tokens in the package's `styles/theme.css`:
226
226
 
227
227
  | Class | For | Size / weight |
@@ -231,12 +231,17 @@ height and weight together, from tokens in the package's `styles/theme.css`:
231
231
  | `type-body` | running text, table cells, form text, the pagination summary | 14px / 400 |
232
232
  | `type-label` | column headers, field labels, a small heading over a group | 14px / 500 |
233
233
  | `type-caption` | counts, metadata, helper text under a field | 12px / 400 |
234
+ | `type-numeric` | the one live number on a card (`ModuleCard`'s count), in tabular figures | 22px / 500 |
234
235
 
235
236
  - **Use a role instead of size and weight classes,** not as well as them.
236
237
  `type-page-title` replaces `text-lg font-semibold`. Combining a role with
237
238
  `text-sm`, `font-medium` or `leading-*` makes two classes set the same
238
239
  property, and which one wins depends on the order of rules in the
239
240
  stylesheet, not on the order in `className`.
241
+ - **`type-numeric` sets tabular figures** (`font-variant-numeric:
242
+ tabular-nums`), so counts line up from card to card and don't change
243
+ width as they tick. A number in running text or a table cell keeps its
244
+ row's role; add `tabular-nums` there if a column of them should align.
240
245
  - **Colour stays separate:** `type-caption text-muted-foreground`. A role
241
246
  never sets a colour, so any text token pairs with it.
242
247
  - **Why `type-*` and not `text-*`:** `cn` (tailwind-merge) treats an
@@ -264,6 +269,20 @@ In text with a band, the rest (paragraphs, lists, tables, callouts,
264
269
  words in the band rather than its edge; text with no band stays flush. The level reads at a glance: two bands of
265
270
  different strength, then a label.
266
271
 
272
+ Headings in the text sit under where the text is in the page's outline.
273
+ `headingLevel` on `<Markdown>` and `RichTextEditor` is the HTML level `#`
274
+ renders as: 2 straight under the page's `<h1>` (a form, or a page that is
275
+ the note), 3 under a section's `<h2>` (the default). `##` is one below it
276
+ and `###` two below, never past `<h6>`, so the outline never skips a
277
+ level (axe's heading-order). Only the element moves: each level keeps its
278
+ look, and the editor saves `#` as `#`.
279
+
280
+ A table wider than the text scrolls sideways. While it does, its box is a
281
+ tab stop and a region named "Table, scrolls sideways: " and its first
282
+ columns ("Item, Size, Colour, …"), so two such tables keep distinct
283
+ names; the arrow keys scroll it (in the editor Home and End too), with
284
+ the `ring` focus ring. A table that fits gets no tab stop and no region.
285
+
267
286
  The same text has four colour tokens of its own: `--link` (`text-link`), a
268
287
  link's colour, blue as well as underlined (5.59:1 on white, 8.90:1 on the
269
288
  dark page); `--heading-band` (`bg-heading-band`), the `#` band, one step
@@ -294,13 +313,18 @@ set, not a `DataTable` prop:
294
313
 
295
314
  | `data-density` | Changes | Table cell padding (vertical) | Header height | For |
296
315
  |---|---|---|---|---|
297
- | `compact` | table rows only | 4px | 32px | wide tables people scan, many rows per screen |
316
+ | `compact` | table rows, and a hub's cards (`WidgetCard`, `WidgetList` rows, `ModuleCard`) | 4px | 32px | wide tables people scan, many rows per screen; a dense hub |
298
317
  | *(unset)* | — | 8px | 40px | the default |
299
318
  | `comfortable` | table rows only | 12px | 48px | short tables, or rows people read rather than scan |
300
319
  | `large` | **everything**: type, spacing, control heights, icons, the sidebar, table rows | 12px | 56px | a screen read from a distance: a TV used as the desktop monitor, with a mouse and keyboard |
301
320
 
302
321
  - `compact` and `comfortable` change padding only. Text keeps
303
322
  `type-body`, so compact never drops below a readable size.
323
+ - On a hub, `compact` takes a `WidgetCard`'s padding from 16px to 12px,
324
+ a `WidgetList` row from 36px to 32px (6px around its line) and a
325
+ `ModuleCard`'s band and body by 4px each. On a phone a compact row is
326
+ 44px instead, its link filling that height, so it stays a finger's
327
+ target.
304
328
  - `large` grows the whole UI by about a quarter: body text 14 → 18px,
305
329
  controls 32 → 40px tall, icons 16 → 20px, the sidebar 256 → 320px.
306
330
  Breakpoints and `max-w-*` containers stay where they are, which is
@@ -308,7 +332,8 @@ set, not a `DataTable` prop:
308
332
  a wide screen can fall into the phone layout).
309
333
  - For `large`, set it on **`<html>`**. Menus, selects, dialogs and
310
334
  tooltips are portaled to `<body>`, so a wrapper `<div>` doesn't reach
311
- them. `compact` and `comfortable` only touch tables, so a wrapper is fine.
335
+ them. `compact` and `comfortable` only touch tables and hub cards, so a
336
+ wrapper is fine.
312
337
  - Pick **one density per screen**. Two tables at different densities side
313
338
  by side look like a mistake.
314
339
  - Size things the way the package does and `large` reaches them for free:
@@ -319,11 +344,17 @@ set, not a `DataTable` prop:
319
344
  - Under the hood, the table primitive's cells read `--table-cell-px`,
320
345
  `--table-cell-py` and `--table-head-height`. `large` also redefines
321
346
  Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
322
- roles and `--control-text-sm` (the small Button and Toggle). Each
347
+ roles and `--control-text-sm` (the small Button and Toggle).
348
+ `DataTable`'s totals row reads the body cells' tokens, so it is a row's
349
+ height at every density. Each
323
350
  default is the value it always had, so a screen without `data-density`
324
- doesn't change. The selectors are in `styles/theme.css`. Storybook's
351
+ doesn't change. The selectors are in `styles/theme.css`. A composite
352
+ reaches `compact` with the `density-compact:` variant (`styles/index.css`:
353
+ any ancestor with `data-density="compact"`), the way `WidgetCard` and
354
+ `ModuleCard` do; an app's own card can use it too. Storybook's
325
355
  Density toolbar shows any story at a density, and `patterns/Density`
326
- shows the primitives, `DataTable` and the sidebar at each. Its checks
356
+ shows the primitives, `DataTable`, the sidebar and the hub cards at
357
+ each. Its checks
327
358
  measure that every step changes what it should, and that the default
328
359
  keeps today's sizes.
329
360
 
@@ -398,6 +429,66 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
398
429
  one in each state; its checks run axe and the token and glyph checks in
399
430
  both themes and at `large`.
400
431
 
432
+ ## Module cards (a hub's places)
433
+
434
+ Above its widgets, a hub shows the app's places: its modules, and the
435
+ sites outside it that it links to. Each is a `ModuleCard` in a
436
+ `ModuleStrip`: a small card that names the place, shows one live number,
437
+ and offers one action.
438
+
439
+ ```tsx
440
+ const { data, isLoading, error, refetch } = useProjectsSummaryQuery()
441
+
442
+ <ModuleStrip aria-label="Places">
443
+ <ModuleCard
444
+ title="Projects"
445
+ icon={ListTodoIcon}
446
+ category={1}
447
+ to="/projects"
448
+ value={data?.openCount}
449
+ note="open"
450
+ action={{ to: '/projects?view=today', label: 'Today' }}
451
+ isLoading={isLoading}
452
+ error={error}
453
+ onRetry={() => refetch()}
454
+ />
455
+ <ModuleCard title="Recipes" icon={RecipesMark} category={6} href="https://recipes.example.com" note="Opens its own app" />
456
+ </ModuleStrip>
457
+ ```
458
+
459
+ - **The band names the place and links to it.** The icon takes the
460
+ place's categorical slot (`category`, 1–12), and the band behind the
461
+ icon and the name is a 10% wash of the same slot over the card, with
462
+ `foreground` text on it (axe measures every slot's wash in both themes).
463
+ Give each place the slot it has elsewhere in the app (its sidebar entry,
464
+ its badges), so a place is one colour everywhere. Without a `category`
465
+ the icon is muted and the band plain.
466
+ - **One number, with a short note.** `value` is the one count that says
467
+ what's live there ("5" open, "2" due today), in `type-numeric`; `note`
468
+ is a few words under it. A card with no `value` shows no number, and
469
+ that is not an empty state: a place outside the app usually has none,
470
+ and its note says what it is ("Opens its own app").
471
+ - **One action** at the foot: the thing most often done next ("Today",
472
+ "Start", "Add one"). It's described by the place's name, so several
473
+ "Open" links on one page stay distinct.
474
+ - **A place outside the app** takes `href` instead of `to` (an action
475
+ can too): it opens in a new tab with the external-link mark. Its icon
476
+ can be the service's own mark ("Logos and brand marks" above).
477
+ - **States are the number's.** While loading, a skeleton the number's
478
+ size (the note stays, it doesn't load); on an error, "Couldn't load"
479
+ and Retry inside the card, so one failing place never hides the
480
+ others. The band and the action work in every state.
481
+ - **The strip** is a navigation landmark (`aria-label` names it) of
482
+ equal columns, 170px or wider, that wrap: six to eight across a
483
+ desktop. On a phone it's three columns of smaller tiles with no action,
484
+ each tile one link to its place (at least 44px tall); Retry stays its
485
+ own button above it. Six places make the phone's 3 × 2 grid; past
486
+ that, put the rest behind one tile ("More") rather than a third row.
487
+ - Storybook's `app/ModuleCard` shows a hub with a strip and a widget,
488
+ every state, and both at `compact`; its checks run axe and the token and
489
+ glyph checks in both themes and at `large`, and measure the strip at a
490
+ desktop and a phone width.
491
+
401
492
  ## Notices
402
493
 
403
494
  A `Notice` says, across the page, a state that decides what you do with
@@ -97,6 +97,26 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
97
97
  * Colour isn't the only signal: say why in the row too (a badge, a word).
98
98
  */
99
99
  highlightRow?: (row: TData) => boolean;
100
+ /**
101
+ * A totals row under the columns it adds up, the way a spreadsheet's
102
+ * is: `label` in the first visible column ("Total · 18 items"), then
103
+ * each of `cells` under the column with that id, in its alignment. The
104
+ * values come from the server, for every row the filters match, never
105
+ * summed from the page on the client. Shown only with rows: never while
106
+ * loading, on error or empty. Leave undefined (or null, before the data
107
+ * has come) for no row.
108
+ */
109
+ totals?: DataTableTotals | null;
100
110
  };
101
111
  export type DataTableWidth = 'full' | 'content';
102
- 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, groupBy, groupSummary, header: showHeader, highlightRow, }: DataTableProps<TData>): import("react").JSX.Element;
112
+ export type DataTableTotals = {
113
+ /** Goes in the first visible column, as the row's header. */
114
+ label: ReactNode;
115
+ /**
116
+ * One value per column id, already formatted. A column left out, or
117
+ * hidden by `visibleColumns`, gets an empty cell or none. The first
118
+ * visible column holds the label, so a value for it isn't shown.
119
+ */
120
+ cells?: Partial<Record<string, ReactNode>>;
121
+ };
122
+ 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, groupBy, groupSummary, header: showHeader, highlightRow, totals, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -23,7 +23,7 @@ import { EditInPlaceContext } from './edit-in-place.js';
23
23
  import { EditInPlaceStore } from './edit-in-place-store.js';
24
24
  import { LeaveGuard } from './editable-value.js';
25
25
  import { groupRuns } from './table-groups.js';
26
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
26
+ import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '../ui/table.js';
27
27
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
28
28
  // `accessorKey` with dots as underscores, then a string header. Group
29
29
  // columns are looked through to their leaves, which visibility is keyed by.
@@ -59,6 +59,9 @@ const PINNED_COLUMN_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS} group-h
59
59
  // so lighting it too read as a second selected thing. Only the first
60
60
  // column marks the hovered row.
61
61
  const PINNED_LAST_COLUMN_CLASS = `sticky right-0 border-l ${PINNED_CELL_CLASS}`;
62
+ // The totals row's label, pinned with the first column (`totals`): the
63
+ // same opaque cell, with no hover, since the row isn't one to pick.
64
+ const PINNED_TOTALS_LABEL_CLASS = `sticky left-0 border-r ${PINNED_CELL_CLASS}`;
62
65
  // A pinned first column's content, capped below md at 45% of the table's
63
66
  // scroll area (`cqw`: the container is an inline-size query container)
64
67
  // and wrapping, so a long title can't widen the column past the screen
@@ -82,6 +85,15 @@ const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
82
85
  // Inside, muted text takes the darker grey the wash needs (theme.css).
83
86
  const HIGHLIGHTED_ROW_CLASS = '[--muted-foreground:var(--table-highlight-muted-foreground)] [&>td]:bg-(--table-highlight) ' +
84
87
  '[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--info-text)]';
88
+ // `totals`: a footer row the way a spreadsheet's totals are, set apart
89
+ // from the rows by a border twice a row divider's width and medium type,
90
+ // on the page's background (table.tsx's footer tint is translucent, so a
91
+ // pinned cell over it couldn't match without bleed-through). Its cells
92
+ // read the body's density tokens, so it's a row's height, not the
93
+ // header's; tabular figures line its numbers up with the column's.
94
+ const TOTALS_FOOTER_CLASS = 'border-t-2 border-border bg-background tabular-nums';
95
+ // Its label is a row header (<th scope="row">), padded as a body cell is.
96
+ const TOTALS_LABEL_CLASS = 'h-auto py-(--table-cell-py)';
85
97
  // `header={false}`: the header row stays in the table, so every cell keeps
86
98
  // its column header for a screen reader, but takes no room: each header
87
99
  // is its name in an sr-only span, in a cell with no height or padding.
@@ -157,7 +169,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
157
169
  }, [containerRef, isSuccessView, isOverflowing]);
158
170
  return { barRef, isOverflowing, scrollWidth };
159
171
  }
160
- 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', groupBy, groupSummary, header: showHeader = true, highlightRow, }) {
172
+ 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', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, }) {
161
173
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
162
174
  // a cell that renders an <EditableValue> edits where it's shown, one at
163
175
  // a time, with the leave-page prompt while one is open, the same rules
@@ -216,7 +228,8 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
216
228
  const containerRef = useRef(null);
217
229
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
218
230
  const rows = table.getRowModel().rows;
219
- const visibleColumnCount = table.getVisibleLeafColumns().length;
231
+ const visibleLeafColumns = table.getVisibleLeafColumns();
232
+ const visibleColumnCount = visibleLeafColumns.length;
220
233
  // One body row, the same in a plain table and a grouped one.
221
234
  const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && 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));
222
235
  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
@@ -247,6 +260,9 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
247
260
  : sortDirection === 'desc'
248
261
  ? 'descending'
249
262
  : 'none', children: !showHeader ? (_jsx("span", { className: "sr-only", children: content })) : pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: content })) : (content) }, header.id));
250
- }) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) }))] }), 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, {}) })] })] })] }))] }));
263
+ }) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
264
+ const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, column.columnDef.meta?.align === 'center' && 'text-center');
265
+ return index === 0 ? (_jsx(TableHead, { scope: "row", className: cn(TOTALS_LABEL_CLASS, className), children: pinFirstColumn ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: totals.label })) : (totals.label) }, column.id)) : (_jsx(TableCell, { className: className, children: totals.cells?.[column.id] }, column.id));
266
+ }) }) }))] }), 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, {}) })] })] })] }))] }));
251
267
  return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
252
268
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The open item once the list's ids have changed: the item that just
3
+ * appeared, when an add or a duplicate asked for it (`opening`), else the
4
+ * one that was open, if it's still there. A list replaced whole (a form's
5
+ * `reset`) gives every item a new id, and nothing asked, so nothing opens.
6
+ */
7
+ export declare function followOpenItem({ before, after, open, opening, }: {
8
+ before: readonly string[];
9
+ after: readonly string[];
10
+ open: string | null;
11
+ opening: boolean;
12
+ }): string | null;
13
+ /** Whether an item shows its fields: it's the open one, or it has an error to show. */
14
+ export declare function isItemOpen({ id, open, invalid }: {
15
+ id: string;
16
+ open: string | null;
17
+ invalid: boolean;
18
+ }): boolean;
@@ -0,0 +1,23 @@
1
+ // Which of ListEditor's items are open, when its items show as summary
2
+ // lines (`summary`, #222). One item is open at a time, held by its stable
3
+ // id (useFieldArray's `field.id`), so it stays open while rows move and
4
+ // closes when it's removed. An item that was just added or duplicated
5
+ // opens, and one with a validation error is open whatever else is.
6
+ /**
7
+ * The open item once the list's ids have changed: the item that just
8
+ * appeared, when an add or a duplicate asked for it (`opening`), else the
9
+ * one that was open, if it's still there. A list replaced whole (a form's
10
+ * `reset`) gives every item a new id, and nothing asked, so nothing opens.
11
+ */
12
+ export function followOpenItem({ before, after, open, opening, }) {
13
+ if (opening) {
14
+ const added = after.find((id) => !before.includes(id));
15
+ if (added !== undefined)
16
+ return added;
17
+ }
18
+ return open !== null && after.includes(open) ? open : null;
19
+ }
20
+ /** Whether an item shows its fields: it's the open one, or it has an error to show. */
21
+ export function isItemOpen({ id, open, invalid }) {
22
+ return invalid || id === open;
23
+ }
@@ -1,25 +1,47 @@
1
- import type { ReactNode } from 'react';
2
- export declare function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText, max, add, disabled, 'aria-label': ariaLabel, }: {
1
+ import { type ReactNode } from 'react';
2
+ export declare function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, 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
+ /**
10
+ * Shows each item as one line, this text (e.g. "Squat · 3 × 8 at 60 kg"),
11
+ * that opens to its `renderItem` fields on a click, Enter or Space. One
12
+ * item is open at a time; one just added or duplicated opens. For a long
13
+ * list of rich items. Without it, every item shows its fields.
14
+ */
15
+ summary?: (index: number) => ReactNode;
16
+ /**
17
+ * With `summary`: the item at this index has an error to show, e.g.
18
+ * (i) => !!errors.steps?.[i]. It opens, beside the open one, until the
19
+ * error clears, so a refused save never hides why. react-hook-form's
20
+ * focus on the first error then makes that item the open one.
21
+ */
22
+ invalid?: (index: number) => boolean;
9
23
  /** The Add button's action. Not needed with `add`. */
10
24
  onAdd?: () => void;
11
25
  onRemove: (index: number) => void;
12
26
  onMove: (from: number, to: number) => void;
27
+ /**
28
+ * Puts a Duplicate button beside Remove. The app inserts the copy right
29
+ * after the original, e.g. (i) => insert(i + 1, { ...getValues(`steps.${i}`) }),
30
+ * leaving out anything a copy mustn't share (a saved id). With `summary`,
31
+ * the copy opens. Disabled once `max` is reached, as Add is.
32
+ */
33
+ onDuplicate?: (index: number) => void;
13
34
  /**
14
35
  * How the row buttons name an item for a screen reader, e.g.
15
36
  * (i) => `item ${i + 1}` gives "Move item 2 up", "Remove item 2".
37
+ * A summary line is named by it too: "item 2: Squat · 3 × 8 at 60 kg".
16
38
  */
17
39
  itemName: (index: number) => string;
18
40
  /** The Add button's text, e.g. "Add item". */
19
41
  addLabel: string;
20
42
  /** Shown instead of the list when it's empty. */
21
43
  emptyText?: string;
22
- /** The most items allowed; Add is disabled once reached. */
44
+ /** The most items allowed; Add and Duplicate are disabled once reached. */
23
45
  max?: number;
24
46
  /** In place of the Add button: what adds an item (editing in place's Add box). */
25
47
  add?: ReactNode;
@@ -1,10 +1,66 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Edits a list of sub-records inside a parent's form: one row per item,
3
+ // each with move up / move down / remove, and an Add button below. The one
4
+ // control for every "sub-records" section of an entity plan
5
+ // (conventions/docs/entity-plan-template.md): Widget's checklist, a task's
6
+ // checklist and links.
7
+ //
8
+ // It owns the row chrome only. Each item's fields are the app's (rendered
9
+ // by `renderItem`), and so is the state: pass react-hook-form's
10
+ // useFieldArray `fields`, `append`, `remove` and `move`, so the items save
11
+ // with the parent's form and their errors bind like any other field.
12
+ //
13
+ // A long list of rich items (#222) can show each item as one line instead
14
+ // (`summary`): the line is a button that opens the item to its fields,
15
+ // one item at a time (list-editor-rows.ts), with the row buttons beside
16
+ // it. And `onDuplicate` puts a Duplicate button beside Remove, for lists
17
+ // whose items repeat with small changes. Without either, every item shows
18
+ // its fields, as before.
19
+ //
20
+ // Editing a list in place on a view (editable-list.tsx) is built on it
21
+ // too, so the rows and their buttons match the form's: there, `add` is the
22
+ // in-place Add box, and `disabled` holds the buttons while a change saves.
23
+ import { useState } from 'react';
24
+ import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon, CopyIcon, PlusIcon, Trash2Icon } from 'lucide-react';
3
25
  import { Button } from '../ui/button.js';
4
- export function ListEditor({ items, renderItem, onAdd, onRemove, onMove, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
26
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
27
+ import { followOpenItem, isItemOpen } from './list-editor-rows.js';
28
+ export function ListEditor({ items, renderItem, summary, invalid, onAdd, onRemove, onMove, onDuplicate, itemName, addLabel, emptyText = 'Nothing added yet.', max, add, disabled = false, 'aria-label': ariaLabel, }) {
5
29
  const atMax = max !== undefined && items.length >= max;
30
+ // The open item, by id. When the ids change (an add, a move, a remove),
31
+ // it follows them here, in the render that shows the change, so a new
32
+ // item's fields are already there when react-hook-form focuses them.
33
+ const ids = items.map((item) => item.id);
34
+ const [open, setOpen] = useState(null);
35
+ const [opening, setOpening] = useState(false);
36
+ const [seen, setSeen] = useState(ids);
37
+ if (seen.join('\n') !== ids.join('\n')) {
38
+ setSeen(ids);
39
+ setOpen(followOpenItem({ before: seen, after: ids, open, opening }));
40
+ setOpening(false);
41
+ }
42
+ // An add or a duplicate asks for the item it makes to open. The app's
43
+ // action runs in the same click, so its new id arrives with the ask.
44
+ const addItem = () => {
45
+ setOpening(true);
46
+ onAdd?.();
47
+ };
48
+ const duplicate = (index) => {
49
+ setOpening(true);
50
+ onDuplicate?.(index);
51
+ };
52
+ const rowButtons = (index, name) => (_jsxs(_Fragment, { children: [_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, {}) }), onDuplicate && (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Duplicate ${name}`, disabled: disabled || atMax, onClick: () => duplicate(index), children: _jsx(CopyIcon, {}) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": `Remove ${name}`, disabled: disabled, onClick: () => onRemove(index), children: _jsx(Trash2Icon, {}) })] }));
6
53
  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
54
  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: 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] }) }))] }));
55
+ if (!summary) {
56
+ 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) }), rowButtons(index, name)] }, item.id));
57
+ }
58
+ // A summary line: the whole line is the button (named by the
59
+ // item's name, then its summary), the row buttons beside it,
60
+ // and the fields under it while it's open. Clicking the line
61
+ // again closes it; an item with an error stays open. Going into
62
+ // an item's fields makes it the open one, so one opened by its
63
+ // error doesn't close under the caret once the error clears.
64
+ return (_jsx("li", { children: _jsxs(Collapsible, { open: isItemOpen({ id: item.id, open, invalid: invalid?.(index) ?? false }), onOpenChange: (next) => setOpen((current) => (next ? item.id : current === item.id ? null : current)), className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-start gap-1", children: [_jsxs(CollapsibleTrigger, { className: "group/summary flex min-h-7 min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1.5 py-1 text-left type-body text-foreground outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx(ChevronRightIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]/summary:rotate-90 motion-reduce:transition-none" }), _jsxs("span", { className: "sr-only", children: [name, ": "] }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: summary(index) })] }), rowButtons(index, name)] }), _jsx(CollapsibleContent, { className: "flex min-w-0 items-start gap-2 pl-7", onFocus: () => setOpen(item.id), children: renderItem(index) })] }) }, item.id));
65
+ }) })), add ?? (_jsx("div", { children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, disabled: disabled || atMax, children: [_jsx(PlusIcon, {}), addLabel] }) }))] }));
10
66
  }
@@ -1,7 +1,15 @@
1
- import type { ComponentProps } from 'react';
1
+ import { type ComponentProps } from 'react';
2
+ import { type HeadingLevel } from './prose-look.js';
2
3
  export type MarkdownProps = {
3
4
  /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
4
5
  children: string;
6
+ /**
7
+ * The HTML level the text's `#` renders as, where the text sits in the
8
+ * page's outline: 2 straight under the page's <h1>, 3 (the default)
9
+ * under a section's <h2>. `##` is one below it, `###` two below, never
10
+ * past <h6>. Only the element changes: each level looks the same.
11
+ */
12
+ headingLevel?: HeadingLevel;
5
13
  className?: string;
6
14
  } & Omit<ComponentProps<'div'>, 'children'>;
7
- export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
15
+ export declare function Markdown({ children, headingLevel, className, ...rest }: MarkdownProps): import("react").JSX.Element;