@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.
- package/conventions/AGENTS.md +2 -1
- package/conventions/docs/add-an-entity.md +19 -10
- package/conventions/docs/cell-patterns.md +111 -32
- package/conventions/docs/design-language.md +82 -21
- package/dist/components/app/data-table.d.ts +61 -4
- package/dist/components/app/data-table.js +217 -17
- package/dist/components/app/entity-view.d.ts +14 -1
- package/dist/components/app/entity-view.js +5 -3
- package/dist/components/app/key-stats.d.ts +14 -0
- package/dist/components/app/key-stats.js +6 -0
- package/dist/components/app/table-groups.d.ts +12 -0
- package/dist/components/app/table-groups.js +37 -0
- package/dist/components/app/widget-card.d.ts +29 -1
- package/dist/components/app/widget-card.js +61 -5
- package/dist/components/app/widget-layout-sheet.d.ts +25 -0
- package/dist/components/app/widget-layout-sheet.js +55 -0
- package/dist/hooks/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +1 -0
- package/dist/hooks/use-table-layout.d.ts +1 -0
- package/dist/hooks/use-table-layout.js +2 -1
- package/dist/hooks/use-table-url-state.d.ts +11 -1
- package/dist/hooks/use-table-url-state.js +26 -2
- package/dist/hooks/use-widget-layout.d.ts +45 -0
- package/dist/hooks/use-widget-layout.js +128 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
232
|
-
|
|
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
|
|
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(
|
|
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))) }),
|
|
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
|
-
|
|
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:
|
|
330
|
-
?
|
|
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; `
|
|
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;
|