@tristan2828/ui-foundation 4.44.0 → 4.46.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.
@@ -14,16 +14,18 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } fr
14
14
  import { flexRender } from '@tanstack/react-table';
15
15
  import { cn } from 'cn';
16
16
  import { getCoreRowModel, useLegacyTable } from '@tanstack/react-table/legacy';
17
- import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, TableIcon } from 'lucide-react';
17
+ import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ListTreeIcon, TableIcon, XIcon, } from 'lucide-react';
18
18
  import { ErrorState } from './error-state.js';
19
19
  import { Button } from '../ui/button.js';
20
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
20
21
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
22
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet.js';
21
23
  import { Skeleton } from '../ui/skeleton.js';
22
24
  import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
23
25
  import { EditInPlaceContext } from './edit-in-place.js';
24
26
  import { EditInPlaceStore } from './edit-in-place-store.js';
25
27
  import { LeaveGuard } from './editable-value.js';
26
- import { groupRuns } from './table-groups.js';
28
+ import { groupByValues, groupRuns } from './table-groups.js';
27
29
  import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '../ui/table.js';
28
30
  // A column def's id as TanStack resolves it (constructColumn): `id`, then
29
31
  // `accessorKey` with dots as underscores, then a string header. Group
@@ -74,6 +76,35 @@ function LayoutSwitch({ options, value, onValueChange, }) {
74
76
  onValueChange(chosen);
75
77
  }, children: options.map(({ id, label, icon: Icon }) => (_jsxs(ToggleGroupItem, { value: id, className: LAYOUT_ITEM_CLASS, title: label, children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4" }), _jsx("span", { className: "max-sm:sr-only", children: label })] }, id))) }));
76
78
  }
79
+ // The Group menu (`groupings`): a radio list, None first, that closes on
80
+ // a pick. Base UI's radio group needs a value for None, so it's this
81
+ // stand-in, never '' (the grouping's own word for None).
82
+ const NO_GROUPING = '__none__';
83
+ function GroupMenu({ groupings, value, onValueChange, }) {
84
+ const current = groupings.find((grouping) => grouping.id === value);
85
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", "data-slot": "data-table-group-menu" }), children: [_jsx(ListTreeIcon, { "aria-hidden": "true" }), current ? `Group: ${current.label}` : 'Group'] }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Group by" }), _jsxs(DropdownMenuRadioGroup, { value: current?.id ?? NO_GROUPING, onValueChange: (next) => onValueChange(next === NO_GROUPING ? '' : String(next)), children: [_jsx(DropdownMenuRadioItem, { value: NO_GROUPING, closeOnClick: true, children: "None" }), groupings.map((grouping) => (_jsx(DropdownMenuRadioItem, { value: grouping.id, closeOnClick: true, children: grouping.label }, grouping.id)))] })] }) })] }));
86
+ }
87
+ // Whether the detail panel (`detailPanel`) fits beside the table, or opens
88
+ // as a bottom sheet. Wide on the server and wherever matchMedia is missing
89
+ // (a test's DOM), so markup never depends on it.
90
+ const WIDE_QUERY = '(min-width: 64rem)';
91
+ const canMatchMedia = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function';
92
+ function subscribeWide(listener) {
93
+ if (!canMatchMedia())
94
+ return () => { };
95
+ const query = window.matchMedia(WIDE_QUERY);
96
+ query.addEventListener('change', listener);
97
+ return () => query.removeEventListener('change', listener);
98
+ }
99
+ const useIsWide = () => useSyncExternalStore(subscribeWide, () => !canMatchMedia() || window.matchMedia(WIDE_QUERY).matches, () => true);
100
+ // A click on one of these inside a row is the control's own, not a pick of
101
+ // the row. A link is the exception: a plain click picks the row instead of
102
+ // following it (a modified one still opens it, in a new tab).
103
+ const ROW_CONTROL_SELECTOR = 'a[href],button,input,select,textarea,label,summary,[contenteditable="true"],' +
104
+ '[role="button"],[role="checkbox"],[role="switch"],[role="combobox"],[role="link"],[role="slider"]';
105
+ // Keys typed into one of these are its own: ↑/↓ and Escape stay with it.
106
+ const TYPING_SELECTOR = 'input,select,textarea,[contenteditable="true"],[role="combobox"],[role="listbox"],[role="menu"],' +
107
+ '[role="slider"],[role="spinbutton"]';
77
108
  function TableSkeleton({ columnCount }) {
78
109
  return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
79
110
  }
@@ -140,6 +171,25 @@ const HIDDEN_HEAD_CLASS = 'h-0 p-0';
140
171
  const ALIGN_CLASS = { center: 'text-center', right: 'text-right' };
141
172
  const alignClass = (align) => align && ALIGN_CLASS[align];
142
173
  const GROUP_HEADING_CLASS = 'h-auto bg-background pt-4 pb-1.5 align-bottom';
174
+ // A grouping's heading (`groupings`) is a button that folds its rows: the
175
+ // label, then the count, a chevron that turns when folded.
176
+ const GROUP_TOGGLE_CLASS = '-mx-1 inline-flex items-baseline gap-2 rounded-sm px-1 outline-none hover:underline ' +
177
+ 'focus-visible:ring-3 focus-visible:ring-ring/50';
178
+ // `detailPanel`: a row that can be picked shows a pointer and its focus
179
+ // ring inside it (a row has no outline of its own room); the picked one
180
+ // takes the muted fill on every cell, over the zebra stripe and the pinned
181
+ // cells' opaque backgrounds as highlightRow's wash does, with a bar in
182
+ // --primary at its start, so it reads without colour too.
183
+ const SELECTABLE_ROW_CLASS = 'cursor-pointer outline-none focus-visible:[&>td]:shadow-[inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
184
+ const SELECTED_ROW_CLASS = '[&>td]:bg-muted [&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary)] ' +
185
+ 'focus-visible:[&>td:first-child]:shadow-[inset_3px_0_0_0_var(--primary),inset_0_2px_0_0_var(--ring),inset_0_-2px_0_0_var(--ring)]';
186
+ // The table and the panel side by side once one is picked on a wide
187
+ // screen. The grid is there from the start, so opening the panel never
188
+ // remounts the table (and the focused row with it).
189
+ const WITH_DETAIL_OPEN_CLASS = 'grid-cols-[minmax(0,1fr)_minmax(18rem,26rem)]';
190
+ const DETAIL_ASIDE_CLASS = 'sticky top-4 flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col gap-3 overflow-y-auto rounded-lg border ' +
191
+ 'border-border bg-card p-4 text-card-foreground';
192
+ const EMPTY_KEYS = new Set();
143
193
  // `width="content"`: from a table area 80rem wide, as wide as the columns
144
194
  // need (`w-fit` over the table's own `w-full`), at least 36rem, or the
145
195
  // width it last had loaded while it shows another state
@@ -208,7 +258,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
208
258
  }, [containerRef, isSuccessView, isOverflowing]);
209
259
  return { barRef, isOverflowing, scrollWidth };
210
260
  }
211
- 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, layouts, layout: layoutProp, onLayoutChange, tableLayout, }) {
261
+ 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, layouts, layout: layoutProp, onLayoutChange, tableLayout, groupings, grouping: groupingProp, onGroupingChange, detailPanel, detailLabel = 'Details', }) {
212
262
  // The switch's own state, for a table that doesn't pass `layout`.
213
263
  const [ownLayout, setOwnLayout] = useState('table');
214
264
  const requestedLayout = layoutProp ?? ownLayout;
@@ -219,6 +269,22 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
219
269
  setOwnLayout(next);
220
270
  onLayoutChange?.(next);
221
271
  };
272
+ // The grouping on (`groupings`), the menu's own state for a table that
273
+ // doesn't pass `grouping`.
274
+ const [ownGrouping, setOwnGrouping] = useState('');
275
+ const activeGrouping = groupings?.find((candidate) => candidate.id === (groupingProp ?? ownGrouping));
276
+ const setGrouping = (next) => {
277
+ if (groupingProp === undefined)
278
+ setOwnGrouping(next);
279
+ onGroupingChange?.(next);
280
+ };
281
+ // The folded headings, for the grouping they were folded in: picking
282
+ // another starts with every group open.
283
+ const [folds, setFolds] = useState({ grouping: '', keys: EMPTY_KEYS });
284
+ const folded = activeGrouping && folds.grouping === activeGrouping.id ? folds.keys : EMPTY_KEYS;
285
+ // The picked row (`detailPanel`) and the place it was picked in.
286
+ const [selection, setSelection] = useState(null);
287
+ const isWide = useIsWide();
222
288
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
223
289
  // a cell that renders an <EditableValue> edits where it's shown, one at
224
290
  // a time, with the leave-page prompt while one is open, the same rules
@@ -228,9 +294,13 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
228
294
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
229
295
  // TanStack treats a column missing from this map as visible, so every
230
296
  // column gets an entry once a list is given.
231
- const columnVisibility = visibleColumns
232
- ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
233
- : {};
297
+ // While grouped, the grouped column is hidden: its value is the heading.
298
+ const columnVisibility = {
299
+ ...(visibleColumns
300
+ ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
301
+ : {}),
302
+ ...(activeGrouping ? { [activeGrouping.id]: false } : {}),
303
+ };
234
304
  const table = useLegacyTable({
235
305
  data,
236
306
  columns,
@@ -279,14 +349,124 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
279
349
  const rows = table.getRowModel().rows;
280
350
  const visibleLeafColumns = table.getVisibleLeafColumns();
281
351
  const visibleColumnCount = visibleLeafColumns.length;
352
+ // The groups, if any: a grouping's (folding, with counts), else groupBy's.
353
+ const runs = activeGrouping
354
+ ? groupByValues(rows, (row) => activeGrouping.value(row.original), activeGrouping.options, activeGrouping.emptyLabel ?? 'Not set').map((run) => ({ ...run, foldable: true }))
355
+ : groupBy
356
+ ? groupRuns(rows, (row) => groupBy(row.original)).map((run) => ({ ...run, foldable: false }))
357
+ : null;
358
+ const allFolded = runs !== null && runs.length > 0 && runs.every((run) => folded.has(run.key));
359
+ const toggleFold = (key) => {
360
+ if (!activeGrouping)
361
+ return;
362
+ const keys = new Set(folded);
363
+ if (!keys.delete(key))
364
+ keys.add(key);
365
+ setFolds({ grouping: activeGrouping.id, keys });
366
+ };
367
+ const foldAll = () => {
368
+ if (!activeGrouping || !runs)
369
+ return;
370
+ setFolds({ grouping: activeGrouping.id, keys: allFolded ? EMPTY_KEYS : new Set(runs.map((run) => run.key)) });
371
+ };
372
+ // Every row's place in the list as shown (folded groups left out), in
373
+ // order, for ↑/↓. Under a grouping a place is the group and the row,
374
+ // since a multi-value field shows a row in more than one.
375
+ const placeKey = (runKey, rowId) => activeGrouping ? `${encodeURIComponent(runKey)}:${rowId}` : rowId;
376
+ const places = runs
377
+ ? runs.flatMap((run) => folded.has(run.key) ? [] : run.rows.map((row) => ({ key: placeKey(run.key, row.id), rowId: row.id })))
378
+ : rows.map((row) => ({ key: row.id, rowId: row.id }));
379
+ const selectedRow = detailPanel && selection ? rows.find((row) => row.id === selection.rowId) : undefined;
380
+ // The place marked picked: where it was picked, or (regrouped since) the
381
+ // row's first place. None while its group is folded.
382
+ const selectedKey = selectedRow
383
+ ? places.some((place) => place.key === selection?.key)
384
+ ? selection?.key
385
+ : places.find((place) => place.rowId === selectedRow.id)?.key
386
+ : undefined;
387
+ // The list's one tab stop: the picked row, else the first.
388
+ const tabStopKey = selectedKey ?? places[0]?.key;
389
+ const focusRow = (key) => {
390
+ if (key === undefined)
391
+ return;
392
+ containerRef.current?.querySelector(`tr[data-row-key="${CSS.escape(key)}"]`)?.focus();
393
+ };
394
+ const closeDetail = () => {
395
+ setSelection(null);
396
+ focusRow(selectedKey);
397
+ };
398
+ const onRowClick = (event, place) => {
399
+ const target = event.target;
400
+ // A click from a portal (a picker's popup opened from a cell) reaches
401
+ // the row through React, not the DOM: it's that control's.
402
+ if (!event.currentTarget.contains(target))
403
+ return;
404
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
405
+ return;
406
+ const control = target.closest(ROW_CONTROL_SELECTOR);
407
+ if (control && event.currentTarget.contains(control)) {
408
+ if (!control.matches('a[href]'))
409
+ return;
410
+ // In the capture phase, so react-router's Link sees it prevented and
411
+ // doesn't navigate.
412
+ event.preventDefault();
413
+ }
414
+ setSelection(place);
415
+ };
416
+ const onListKeyDown = (event) => {
417
+ const target = event.target;
418
+ if (event.defaultPrevented || !event.currentTarget.contains(target) || target.closest(TYPING_SELECTOR))
419
+ return;
420
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
421
+ if (places.length === 0)
422
+ return;
423
+ event.preventDefault();
424
+ const down = event.key === 'ArrowDown';
425
+ const index = places.findIndex((place) => place.key === selectedKey);
426
+ const nextIndex = index === -1 ? (down ? 0 : places.length - 1) : Math.min(places.length - 1, Math.max(0, index + (down ? 1 : -1)));
427
+ const next = places[nextIndex];
428
+ if (!next)
429
+ return;
430
+ setSelection(next);
431
+ focusRow(next.key);
432
+ }
433
+ else if (event.key === 'Enter' && target.dataset.rowKey !== undefined) {
434
+ const place = places.find((candidate) => candidate.key === target.dataset.rowKey);
435
+ if (!place)
436
+ return;
437
+ event.preventDefault();
438
+ setSelection(place);
439
+ }
440
+ else if (event.key === 'Escape' && selectedRow) {
441
+ event.preventDefault();
442
+ closeDetail();
443
+ }
444
+ };
282
445
  // One body row, the same in a plain table and a grouped one.
283
- 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, alignClass(cell.column.columnDef.meta?.align)), 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));
446
+ const renderRow = (row, key = row.id) => {
447
+ const isSelected = key === selectedKey;
448
+ return (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, ...(detailPanel
449
+ ? {
450
+ 'data-row-key': key,
451
+ 'data-selected': isSelected || undefined,
452
+ 'aria-selected': isSelected,
453
+ tabIndex: key === tabStopKey ? 0 : -1,
454
+ onClickCapture: (event) => onRowClick(event, { key, rowId: row.id }),
455
+ }
456
+ : {}), className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS, detailPanel && SELECTABLE_ROW_CLASS, isSelected && SELECTED_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, alignClass(cell.column.columnDef.meta?.align)), 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))) }, key));
457
+ };
284
458
  const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, "data-layout": layouts?.length ? currentLayout : undefined, style: width === 'content' && !isSuccessView && lastWidth !== null
285
459
  ? { '--data-table-last-width': `${lastWidth}px` }
286
- : undefined, children: [layouts?.length ? (_jsxs("div", { className: "flex flex-wrap items-end gap-x-4 gap-y-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: toolbar }), _jsx(LayoutSwitch, { options: [
460
+ : undefined, children: [layouts?.length || groupings?.length ? (_jsxs("div", { className: "flex flex-wrap items-end gap-x-4 gap-y-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: toolbar }), groupings?.length ? (_jsxs("div", { className: "flex items-center gap-2", children: [activeGrouping && (_jsx(Button, { variant: "ghost", size: "sm", "data-slot": "data-table-fold-all", disabled: !runs?.length, onClick: foldAll, children: allFolded ? 'Unfold all' : 'Fold all' })), _jsx(GroupMenu, { groupings: groupings, value: activeGrouping?.id ?? '', onValueChange: setGrouping })] })) : null, layouts?.length ? (_jsx(LayoutSwitch, { options: [
287
461
  { id: 'table', label: tableLayout?.label ?? 'Table', icon: tableLayout?.icon ?? TableIcon },
288
462
  ...layouts,
289
- ], value: currentLayout, onValueChange: setLayout })] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, rows: rows, groupBy: groupBy, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef,
463
+ ], value: currentLayout, onValueChange: setLayout })) : null] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(DetailFrame, { enabled: Boolean(detailPanel) && !cardLayout, aside: selectedRow && detailPanel && isWide ? (_jsxs("aside", { "aria-label": detailLabel, "data-slot": "data-table-detail", className: DETAIL_ASIDE_CLASS, onKeyDown: (event) => {
464
+ const target = event.target;
465
+ if (event.key !== 'Escape' || event.defaultPrevented || target.closest(TYPING_SELECTOR))
466
+ return;
467
+ event.preventDefault();
468
+ closeDetail();
469
+ }, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h2", { className: "type-label text-muted-foreground", children: detailLabel }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Close", onClick: closeDetail, children: _jsx(XIcon, { "aria-hidden": "true" }) })] }), detailPanel(selectedRow.original)] })) : null, children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, runs: runs, rows: rows, folded: folded, onToggleFold: toggleFold, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef, onKeyDown: detailPanel ? onListKeyDown : undefined,
290
470
  // With the sticky bar below as the visible scrollbar, hide the
291
471
  // container's own. It stays overflow-x-auto and fully
292
472
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -315,18 +495,38 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
315
495
  : sortDirection === 'desc'
316
496
  ? 'descending'
317
497
  : '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));
318
- }) }, 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) => {
498
+ }) }, headerGroup.id))) }), runs ? (runs.map((run, runIndex) => {
499
+ const isFolded = folded.has(run.key);
500
+ return (_jsxs(TableBody, { "data-slot": "table-group", "data-folded": isFolded || undefined, children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent has-aria-expanded: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(GroupLabel, { run: run, isFolded: isFolded, onToggle: () => toggleFold(run.key) }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original), { key: run.key, label: run.label }) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), !isFolded && run.rows.map((row) => renderRow(row, placeKey(run.key, row.id)))] }, `${runIndex}:${run.key}`));
501
+ })) : (_jsx(TableBody, { children: rows.map((row) => renderRow(row)) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
319
502
  const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
320
503
  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));
321
504
  }) }) }))] })), pinFirstColumn && isOverflowing && !cardLayout && (_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, {}) })] })] })] }))] }));
322
- return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
505
+ return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content, detailPanel && (_jsx(Sheet, { open: Boolean(selectedRow) && !isWide && isSuccessView && !cardLayout, onOpenChange: (open) => {
506
+ if (!open)
507
+ setSelection(null);
508
+ }, children: _jsxs(SheetContent, { side: "bottom", className: "max-h-[85dvh] overflow-y-auto", "data-slot": "data-table-detail-sheet", children: [_jsx(SheetHeader, { className: "pb-0", children: _jsx(SheetTitle, { children: detailLabel }) }), _jsx("div", { className: "px-4 pb-4", children: selectedRow && detailPanel(selectedRow.original) })] }) }))] }));
509
+ }
510
+ // The list and, once a row is picked on a wide screen, the detail panel
511
+ // beside it (`detailPanel`). Without a panel it adds nothing.
512
+ function DetailFrame({ enabled, aside, children }) {
513
+ if (!enabled)
514
+ return _jsx(_Fragment, { children: children });
515
+ return (_jsxs("div", { "data-slot": "data-table-with-detail", className: cn('grid items-start gap-4', aside && WITH_DETAIL_OPEN_CLASS), children: [_jsx("div", { className: "flex min-w-0 flex-col gap-4", children: children }), aside] }));
516
+ }
517
+ // A group's heading text: under a grouping, a button that folds the group,
518
+ // with its row count; under `groupBy`, the label alone.
519
+ function GroupLabel({ run, isFolded, onToggle, }) {
520
+ if (!run.foldable)
521
+ return _jsx("span", { className: "type-label text-foreground", children: run.label });
522
+ return (_jsxs("button", { type: "button", "aria-expanded": !isFolded, onClick: onToggle, className: GROUP_TOGGLE_CLASS, children: [_jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('size-4 self-center text-muted-foreground transition-transform', isFolded && '-rotate-90') }), _jsx("span", { className: "type-label text-foreground", children: run.label }), _jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: run.rows.length })] }));
323
523
  }
324
- // A card layout's success view: the rows as cards in a grid or a list,
325
- // each run of one group (`groupBy`) under its own heading, as the table's
326
- // heading rows are, with the same label and summary.
327
- function Cards({ layout, rows, groupBy, groupSummary, }) {
524
+ function Cards({ layout, runs, rows, folded, onToggleFold, groupSummary, }) {
328
525
  const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0", children: layout.renderCard(row.original) }, row.id))) }));
329
- return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: groupBy
330
- ? groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs("section", { "data-slot": "table-group", className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", 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)) }))] }), list(run.rows)] }, `${runIndex}:${run.key}`)))
526
+ return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: runs
527
+ ? runs.map((run, runIndex) => {
528
+ const isFolded = folded.has(run.key);
529
+ return (_jsxs("section", { "data-slot": "table-group", "data-folded": isFolded || undefined, className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", children: [_jsx(GroupLabel, { run: run, isFolded: isFolded, onToggle: () => onToggleFold(run.key) }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original), { key: run.key, label: run.label }) }))] }), !isFolded && list(run.rows)] }, `${runIndex}:${run.key}`));
530
+ })
331
531
  : list(rows) }));
332
532
  }
@@ -66,6 +66,12 @@ export type EntityViewProps = {
66
66
  titleEdit?: EditInPlace & {
67
67
  label: string;
68
68
  };
69
+ /**
70
+ * A line under the title, in muted body type: what the record is for (a
71
+ * goal, a one-line summary). Any markup, an `<EditableValue>` included,
72
+ * so it can be edited in place where it's shown.
73
+ */
74
+ subtitle?: ReactNode;
69
75
  /**
70
76
  * A few status-like values beside the title, as `<Badge>`s, or as the
71
77
  * control that changes one when the plan makes it a quick action (a
@@ -80,6 +86,13 @@ export type EntityViewProps = {
80
86
  * every width. Announced when it appears or changes after the page loads.
81
87
  */
82
88
  notice?: ReactNode;
89
+ /**
90
+ * A row of the record's key numbers (`<KeyStats>`) under the header and
91
+ * its notice, across the page at every width: in the rail layout above
92
+ * both columns, so on a phone it comes before the rail and the main
93
+ * column.
94
+ */
95
+ strip?: ReactNode;
83
96
  sections: EntityViewSection[];
84
97
  /**
85
98
  * Below every section (both columns in the rail layout), in small muted
@@ -101,4 +114,4 @@ export type EntityViewProps = {
101
114
  label: string;
102
115
  };
103
116
  };
104
- export declare function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
117
+ export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -29,8 +29,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
29
29
  // autosave as it's typed (`autosave`), and be the editor itself, with no
30
30
  // click to open (`alwaysEditable`).
31
31
  //
32
+ // In the header, `subtitle` under the title (a goal, a one-line summary).
32
33
  // Around the sections: `notice` (a <Notice>) under the header, across the
33
- // page, for a state that decides what you do with the record; `footer`
34
+ // page, for a state that decides what you do with the record; `strip` (a
35
+ // <KeyStats>) under that, the record's key numbers; `footer`
34
36
  // (a <SourceCredit>) after them all, for crediting the data's source.
35
37
  //
36
38
  // Two layouts. `column` (the default): every section in one column capped
@@ -141,7 +143,7 @@ function Rail({ label, sections }) {
141
143
  const [ref, fits] = useFitsWindow();
142
144
  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))) }));
143
145
  }
144
- export function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
146
+ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
145
147
  // Editing in place: one store per view, and the leave-page prompt only
146
148
  // while a field is open (or one is always editable).
147
149
  const [store] = useState(() => new EditInPlaceStore());
@@ -158,5 +160,5 @@ export function EntityView({ title, documentTitle, titleEdit, badges, actions, n
158
160
  return _jsx(EntityViewSkeleton, { layout: layout });
159
161
  const railSections = sections.filter((section) => section.placement === 'rail');
160
162
  const mainSections = sections.filter((section) => section.placement !== 'rail');
161
- 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: [editing && _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 })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), 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))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
163
+ 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: [editing && _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) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), 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 })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), 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))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
162
164
  }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+ export type KeyStat = {
3
+ label: string;
4
+ value: ReactNode;
5
+ /** A few words under the value ("of 12", "since May"), in caption type. */
6
+ hint?: ReactNode;
7
+ };
8
+ export type KeyStatsProps = {
9
+ stats: readonly KeyStat[];
10
+ /** What an empty value reads as. Defaults to "Not set". */
11
+ emptyLabel?: string;
12
+ className?: string;
13
+ };
14
+ export declare function KeyStats({ stats, emptyLabel, className }: KeyStatsProps): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from 'cn';
3
+ const isEmpty = (value) => value === null || value === undefined || value === false || value === '';
4
+ export function KeyStats({ stats, emptyLabel = 'Not set', className }) {
5
+ return (_jsx("dl", { "data-slot": "key-stats", className: cn('flex flex-wrap gap-x-8 gap-y-3 rounded-lg border border-border bg-card px-4 py-3 text-card-foreground', className), children: stats.map((stat) => (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("dt", { className: "type-caption text-muted-foreground", children: stat.label }), _jsx("dd", { className: "type-section-title tabular-nums break-words text-foreground", children: isEmpty(stat.value) ? _jsx("span", { className: "type-body text-muted-foreground", children: emptyLabel }) : stat.value }), stat.hint && _jsx("dd", { className: "type-caption text-muted-foreground", children: stat.hint })] }, stat.label))) }));
6
+ }
@@ -7,3 +7,15 @@ export type GroupRun<T> = RowGroup & {
7
7
  rows: T[];
8
8
  };
9
9
  export declare function groupRuns<T>(rows: readonly T[], groupOf: (row: T) => RowGroup): GroupRun<T>[];
10
+ /**
11
+ * Rows grouped by a choice field's value (DataTable's `groupings`): one
12
+ * group per option, in the options' order, then any value the options
13
+ * don't name (in the order first met), then the rows with none under
14
+ * `emptyLabel`. A multi-value field (an array) puts the row under each of
15
+ * its values. Rows keep the server's order inside a group, and a group
16
+ * with no rows on this page never exists.
17
+ */
18
+ export declare function groupByValues<T>(rows: readonly T[], valueOf: (row: T) => string | readonly string[] | null | undefined, options: readonly {
19
+ value: string;
20
+ label: string;
21
+ }[], emptyLabel: string): GroupRun<T>[];
@@ -16,3 +16,40 @@ export function groupRuns(rows, groupOf) {
16
16
  }
17
17
  return runs;
18
18
  }
19
+ /**
20
+ * Rows grouped by a choice field's value (DataTable's `groupings`): one
21
+ * group per option, in the options' order, then any value the options
22
+ * don't name (in the order first met), then the rows with none under
23
+ * `emptyLabel`. A multi-value field (an array) puts the row under each of
24
+ * its values. Rows keep the server's order inside a group, and a group
25
+ * with no rows on this page never exists.
26
+ */
27
+ export function groupByValues(rows, valueOf, options, emptyLabel) {
28
+ const byKey = new Map();
29
+ const unknown = [];
30
+ const known = new Set(options.map((option) => option.value));
31
+ // The empty group's key can't collide with a value: values are non-empty.
32
+ const EMPTY = '';
33
+ for (const row of rows) {
34
+ const raw = valueOf(row);
35
+ const values = [...new Set((typeof raw === 'string' ? [raw] : (raw ?? [])).filter((value) => value !== ''))];
36
+ for (const value of values.length > 0 ? values : [EMPTY]) {
37
+ if (value !== EMPTY && !known.has(value) && !byKey.has(value))
38
+ unknown.push(value);
39
+ const group = byKey.get(value);
40
+ if (group)
41
+ group.push(row);
42
+ else
43
+ byKey.set(value, [row]);
44
+ }
45
+ }
46
+ const run = (key, label) => {
47
+ const groupRows = byKey.get(key);
48
+ return groupRows ? [{ key, label, rows: groupRows }] : [];
49
+ };
50
+ return [
51
+ ...options.flatMap((option) => run(option.value, option.label)),
52
+ ...unknown.flatMap((value) => run(value, value)),
53
+ ...run(EMPTY, emptyLabel),
54
+ ];
55
+ }
@@ -4,6 +4,11 @@ import type { AppError } from '../../api/contracts.js';
4
4
  import type { IconComponent } from './icon-component.js';
5
5
  export interface WidgetCardProps {
6
6
  title: string;
7
+ /**
8
+ * The card's id, on its region: the one WidgetCardGroup's `defaultOpen`
9
+ * names and useWidgetLayout orders (`"ready"`). Unique on the page.
10
+ */
11
+ id?: string;
7
12
  /**
8
13
  * An icon shown before the title: a Lucide icon, or an app's own mark (an
9
14
  * outside service's logo on a card about that service). Decorative: the
@@ -39,6 +44,14 @@ export interface WidgetCardProps {
39
44
  * Left out, it starts at `defaultCollapsed` on every visit.
40
45
  */
41
46
  storageKey?: string;
47
+ /**
48
+ * One short value shown in the header while the card is folded: a count
49
+ * (`3`), or a word or two (`"Due today"`). A skeleton while loading, a
50
+ * muted dash on an error. Ignored while open.
51
+ */
52
+ summary?: ReactNode;
53
+ /** What a folded card's summary says when it's empty (`"None"`); nothing when left out. */
54
+ emptySummary?: ReactNode;
42
55
  children?: ReactNode;
43
56
  className?: string;
44
57
  }
@@ -51,7 +64,22 @@ export type WidgetCardLink = {
51
64
  label: string;
52
65
  to?: never;
53
66
  };
54
- export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
67
+ export declare function WidgetCard({ id, title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, summary, emptySummary, children, className, }: WidgetCardProps): import("react").JSX.Element;
68
+ export interface WidgetCardGroupProps {
69
+ /**
70
+ * One card open at a time: `true` always, `"mobile"` only below the
71
+ * sidebar's breakpoint (a desktop hub keeps each card's own
72
+ * `collapsible`), `false` never (the group is a plain wrapper).
73
+ */
74
+ accordion?: boolean | 'mobile';
75
+ /** The `id` of the card open at first; all folded (showing their summaries) when left out. */
76
+ defaultOpen?: string | null;
77
+ children: ReactNode;
78
+ /** The wrapper's classes: the hub's grid of cards, usually. */
79
+ className?: string;
80
+ }
81
+ /** Wraps a hub's WidgetCards so that opening one folds the others (on a phone only, with `accordion="mobile"`). */
82
+ export declare function WidgetCardGroup({ accordion, defaultOpen, children, className }: WidgetCardGroupProps): import("react").JSX.Element;
55
83
  /** The rows of a WidgetCard: a list with a divider between rows. */
56
84
  export declare function WidgetList({ children, className }: {
57
85
  children: ReactNode;