@tristan2828/ui-foundation 4.45.0 → 4.47.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/docs/add-an-entity.md +25 -11
- package/conventions/docs/cell-patterns.md +111 -32
- package/conventions/docs/design-language.md +58 -22
- 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 +27 -1
- package/dist/components/app/entity-view.js +16 -9
- package/dist/components/app/key-stats.d.ts +14 -0
- package/dist/components/app/key-stats.js +6 -0
- package/dist/components/app/module-card.d.ts +11 -2
- package/dist/components/app/module-card.js +45 -5
- package/dist/components/app/table-groups.d.ts +12 -0
- package/dist/components/app/table-groups.js +37 -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/index.d.ts +5 -4
- package/dist/index.js +1 -0
- 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
|
}
|
|
@@ -52,6 +52,11 @@ export type EntityViewPlacement = 'main' | 'rail';
|
|
|
52
52
|
* column on the right (one column on a narrow screen, rail first).
|
|
53
53
|
*/
|
|
54
54
|
export type EntityViewLayout = 'column' | 'rail';
|
|
55
|
+
/**
|
|
56
|
+
* Which column comes first where the rail layout is one column (a phone,
|
|
57
|
+
* or a narrow content area): `rail-first` (the default) or `main-first`.
|
|
58
|
+
*/
|
|
59
|
+
export type EntityViewMobileOrder = 'rail-first' | 'main-first';
|
|
55
60
|
export type EntityViewProps = {
|
|
56
61
|
/** The record's name or title, the page's <h1>, and the browser tab's once it loads (`documentTitle`). */
|
|
57
62
|
title?: string;
|
|
@@ -66,6 +71,12 @@ export type EntityViewProps = {
|
|
|
66
71
|
titleEdit?: EditInPlace & {
|
|
67
72
|
label: string;
|
|
68
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* A line under the title, in muted body type: what the record is for (a
|
|
76
|
+
* goal, a one-line summary). Any markup, an `<EditableValue>` included,
|
|
77
|
+
* so it can be edited in place where it's shown.
|
|
78
|
+
*/
|
|
79
|
+
subtitle?: ReactNode;
|
|
69
80
|
/**
|
|
70
81
|
* A few status-like values beside the title, as `<Badge>`s, or as the
|
|
71
82
|
* control that changes one when the plan makes it a quick action (a
|
|
@@ -80,6 +91,13 @@ export type EntityViewProps = {
|
|
|
80
91
|
* every width. Announced when it appears or changes after the page loads.
|
|
81
92
|
*/
|
|
82
93
|
notice?: ReactNode;
|
|
94
|
+
/**
|
|
95
|
+
* A row of the record's key numbers (`<KeyStats>`) under the header and
|
|
96
|
+
* its notice, across the page at every width: in the rail layout above
|
|
97
|
+
* both columns, so on a phone it comes before the rail and the main
|
|
98
|
+
* column.
|
|
99
|
+
*/
|
|
100
|
+
strip?: ReactNode;
|
|
83
101
|
sections: EntityViewSection[];
|
|
84
102
|
/**
|
|
85
103
|
* Below every section (both columns in the rail layout), in small muted
|
|
@@ -89,6 +107,14 @@ export type EntityViewProps = {
|
|
|
89
107
|
footer?: ReactNode;
|
|
90
108
|
/** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
|
|
91
109
|
layout?: EntityViewLayout;
|
|
110
|
+
/**
|
|
111
|
+
* In the `rail` layout, where its columns stack (a phone): the rail
|
|
112
|
+
* before the main column (`rail-first`, the default), or the main column
|
|
113
|
+
* straight after the header and its `strip` (`main-first`). Two columns
|
|
114
|
+
* wide, the rail stays on the right either way. The `column` layout
|
|
115
|
+
* ignores it.
|
|
116
|
+
*/
|
|
117
|
+
mobileOrder?: EntityViewMobileOrder;
|
|
92
118
|
/** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
|
|
93
119
|
railLabel?: string;
|
|
94
120
|
isLoading: boolean;
|
|
@@ -101,4 +127,4 @@ export type EntityViewProps = {
|
|
|
101
127
|
label: string;
|
|
102
128
|
};
|
|
103
129
|
};
|
|
104
|
-
export declare function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
130
|
+
export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, 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
|
|
@@ -38,8 +40,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
38
40
|
// content area's whole width, sections placed `rail` go in a narrow column
|
|
39
41
|
// on the right that stays in view while the main column scrolls (an issue
|
|
40
42
|
// tracker's shape), and the rest fill the main column. Below the width two
|
|
41
|
-
// columns need, one column: header, rail, main
|
|
42
|
-
//
|
|
43
|
+
// columns need, one column: header, rail, main, or header, main, rail with
|
|
44
|
+
// `mobileOrder="main-first"` (a record whose long content is what you open
|
|
45
|
+
// it for: notes, a checklist). The DOM is in that order at every width, so
|
|
46
|
+
// reading and tab order match the screen: beside each other, the columns'
|
|
47
|
+
// places come from the grid, not the DOM.
|
|
43
48
|
import { useEffect, useId, useState, useSyncExternalStore } from 'react';
|
|
44
49
|
import { Link } from 'react-router';
|
|
45
50
|
import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
|
|
@@ -88,7 +93,7 @@ function ViewSection({ section, inRail = false }) {
|
|
|
88
93
|
// two columns follows the width the page actually has (the sidebar open
|
|
89
94
|
// or not), not the window's: two from 56rem (896px), where the main
|
|
90
95
|
// column still gets ~32rem beside a 22rem rail. Below that, one column in
|
|
91
|
-
// DOM order (header, rail, main); from it, the grid puts the rail on the
|
|
96
|
+
// DOM order (header, rail, main, or main first); from it, the grid puts the rail on the
|
|
92
97
|
// right of row 1 and main on its left, both starting under the header.
|
|
93
98
|
const RAIL_LAYOUT = {
|
|
94
99
|
page: '@container/view flex min-w-0 flex-col gap-6',
|
|
@@ -107,10 +112,11 @@ function SkeletonCard({ rows, stacked }) {
|
|
|
107
112
|
}
|
|
108
113
|
// The page's shape before the record arrives, in the layout it will have,
|
|
109
114
|
// so nothing jumps when it loads.
|
|
110
|
-
function EntityViewSkeleton({ layout }) {
|
|
115
|
+
function EntityViewSkeleton({ layout, mobileOrder }) {
|
|
111
116
|
const header = (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] })] }));
|
|
112
117
|
if (layout === 'rail') {
|
|
113
|
-
|
|
118
|
+
const main = (_jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) }));
|
|
119
|
+
return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, _jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), mobileOrder === 'rail-first' && main] })] }));
|
|
114
120
|
}
|
|
115
121
|
return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [header, Array.from({ length: 2 }, (_, section) => (_jsx(SkeletonCard, { rows: 4, stacked: false }, section)))] }));
|
|
116
122
|
}
|
|
@@ -141,7 +147,7 @@ function Rail({ label, sections }) {
|
|
|
141
147
|
const [ref, fits] = useFitsWindow();
|
|
142
148
|
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
149
|
}
|
|
144
|
-
export function EntityView({ title, documentTitle, titleEdit, badges, actions, notice, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
|
|
150
|
+
export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, isLoading, error, onRetry, back, }) {
|
|
145
151
|
// Editing in place: one store per view, and the leave-page prompt only
|
|
146
152
|
// while a field is open (or one is always editable).
|
|
147
153
|
const [store] = useState(() => new EditInPlaceStore());
|
|
@@ -155,8 +161,9 @@ export function EntityView({ title, documentTitle, titleEdit, badges, actions, n
|
|
|
155
161
|
if (error)
|
|
156
162
|
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
157
163
|
if (isLoading)
|
|
158
|
-
return _jsx(EntityViewSkeleton, { layout: layout });
|
|
164
|
+
return _jsx(EntityViewSkeleton, { layout: layout, mobileOrder: mobileOrder });
|
|
159
165
|
const railSections = sections.filter((section) => section.placement === 'rail');
|
|
160
166
|
const mainSections = sections.filter((section) => section.placement !== 'rail');
|
|
161
|
-
|
|
167
|
+
const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
|
|
168
|
+
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: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (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
169
|
}
|
|
@@ -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
|
+
}
|
|
@@ -38,16 +38,25 @@ export type ModuleCardProps = ModuleCardPlace & {
|
|
|
38
38
|
onRetry?: () => void;
|
|
39
39
|
className?: string;
|
|
40
40
|
};
|
|
41
|
+
/** How a ModuleStrip shows its places on a phone: `tiles` (the default), or one sideways `row` of chips. */
|
|
42
|
+
export type ModuleStripMobile = 'tiles' | 'row';
|
|
41
43
|
export declare function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading, error, onRetry, className, }: ModuleCardProps): import("react").JSX.Element;
|
|
42
44
|
export interface ModuleStripProps {
|
|
43
45
|
/** Names the strip's navigation landmark: "Places". */
|
|
44
46
|
'aria-label': string;
|
|
45
47
|
/** ModuleCards, one per place. */
|
|
46
48
|
children: ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* On a phone: three columns of `tiles` (the default), or one `row` of
|
|
51
|
+
* chips (icon, name, number) that scrolls sideways, below the sidebar's
|
|
52
|
+
* breakpoint. A desktop shows the cards either way.
|
|
53
|
+
*/
|
|
54
|
+
mobile?: ModuleStripMobile;
|
|
47
55
|
className?: string;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
58
|
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
51
|
-
* wrap, and three columns of tiles
|
|
59
|
+
* wrap, and on a phone three columns of tiles, or one sideways row of
|
|
60
|
+
* chips (`mobile="row"`).
|
|
52
61
|
*/
|
|
53
|
-
export declare function ModuleStrip({ 'aria-label': label, children, className }: ModuleStripProps): import("react").JSX.Element;
|
|
62
|
+
export declare function ModuleStrip({ 'aria-label': label, children, mobile, className }: ModuleStripProps): import("react").JSX.Element;
|
|
@@ -21,7 +21,16 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
21
21
|
// smaller tiles with no action, each tile a whole link (the band link's
|
|
22
22
|
// target stretched over the card) at least 44px tall. Retry sits above
|
|
23
23
|
// that target. data-density="compact" tightens the band and the body.
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// `mobile="row"` makes the strip one sideways-scrolling row of chips below
|
|
26
|
+
// the sidebar's breakpoint (md) instead, so a hub's widgets start on the
|
|
27
|
+
// first screen: each place's icon in its slot's colour, its name and its
|
|
28
|
+
// number, a skeleton while that loads and a dash when it fails (Retry is
|
|
29
|
+
// the card's; the place's own page says what went wrong). Each ModuleCard
|
|
30
|
+
// renders its chip beside its card, one shown at each width. The row
|
|
31
|
+
// scrolls inside itself and is `relative`, so a chip's sr-only text
|
|
32
|
+
// (absolutely placed) scrolls with it instead of widening the page.
|
|
33
|
+
import { Children, createContext, useContext, useId } from 'react';
|
|
25
34
|
import { Link } from 'react-router';
|
|
26
35
|
import { AlertCircleIcon, ExternalLinkIcon } from 'lucide-react';
|
|
27
36
|
import { cn } from 'cn';
|
|
@@ -67,29 +76,60 @@ const BAND_LINK_CLASS = 'group/band flex min-h-9 items-center gap-2 px-3 py-1.5
|
|
|
67
76
|
'max-sm:gap-1.5 max-sm:px-2 max-sm:after:absolute max-sm:after:inset-0 max-sm:after:rounded-xl ' +
|
|
68
77
|
'max-sm:focus-visible:ring-0 max-sm:focus-visible:after:ring-3 max-sm:focus-visible:after:ring-inset ' +
|
|
69
78
|
'max-sm:focus-visible:after:ring-ring/50';
|
|
79
|
+
// The chip on a phone in a `mobile="row"` strip. Its focus ring is inset:
|
|
80
|
+
// the row clips anything drawn outside it.
|
|
81
|
+
const CHIP_CLASS = 'flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 type-label ' +
|
|
82
|
+
'whitespace-nowrap text-foreground outline-none hover:bg-accent focus-visible:ring-3 ' +
|
|
83
|
+
'focus-visible:ring-inset focus-visible:ring-ring/50';
|
|
70
84
|
const ACTION_CLASS = 'flex items-center gap-1 border-t border-border px-3 py-2 type-label text-foreground underline ' +
|
|
71
85
|
'underline-offset-4 outline-none hover:decoration-2 focus-visible:ring-3 focus-visible:ring-inset ' +
|
|
72
86
|
'focus-visible:ring-ring/50 density-compact:py-1.5 max-sm:hidden';
|
|
73
87
|
function NewTabMark() {
|
|
74
88
|
return (_jsxs(_Fragment, { children: [_jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
|
|
75
89
|
}
|
|
90
|
+
const StripMobileContext = createContext('tiles');
|
|
91
|
+
function ChipValue({ state, value }) {
|
|
92
|
+
if (state === 'loading')
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Loading" })] }));
|
|
94
|
+
if (state === 'error')
|
|
95
|
+
return (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "sr-only", children: "Couldn't load" })] }));
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return null;
|
|
98
|
+
return (_jsx("span", { "data-slot": "module-card-chip-value", className: "font-semibold tabular-nums", children: value }));
|
|
99
|
+
}
|
|
100
|
+
function ModuleChip({ title, icon: Icon, category, to, href, value, state, }) {
|
|
101
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), title, _jsx(ChipValue, { state: state, value: value })] }));
|
|
102
|
+
const chipProps = { 'data-slot': 'module-card-chip', 'data-state': state, className: cn(CHIP_CLASS, 'md:hidden') };
|
|
103
|
+
return href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...chipProps, children: [content, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...chipProps, children: content }));
|
|
104
|
+
}
|
|
76
105
|
export function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading = false, error, onRetry, className, }) {
|
|
77
106
|
const titleId = useId();
|
|
78
107
|
const bodyId = useId();
|
|
79
108
|
const state = error ? 'error' : isLoading ? 'loading' : 'success';
|
|
109
|
+
const mobile = useContext(StripMobileContext);
|
|
80
110
|
const band = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), _jsx("span", { id: titleId, className: "min-w-0 flex-1 truncate underline-offset-4 group-hover/band:underline", children: title })] }));
|
|
81
111
|
const bandProps = {
|
|
82
112
|
'aria-describedby': bodyId,
|
|
83
113
|
'data-slot': 'module-card-band',
|
|
84
114
|
className: cn(BAND_LINK_CLASS, category && BAND_CLASS[category]),
|
|
85
115
|
};
|
|
86
|
-
|
|
116
|
+
const card = (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('relative h-full min-w-0 gap-0 py-0', mobile === 'row' && 'max-md:hidden', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
|
|
87
117
|
(action.href !== undefined ? (_jsxs("a", { href: action.href, target: "_blank", rel: "noreferrer", "aria-describedby": titleId, className: ACTION_CLASS, children: [_jsx("span", { className: "truncate", children: action.label }), _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: action.to, "aria-describedby": titleId, className: ACTION_CLASS, children: _jsx("span", { className: "truncate", children: action.label }) })))] }));
|
|
118
|
+
if (mobile === 'tiles')
|
|
119
|
+
return card;
|
|
120
|
+
return (_jsxs(_Fragment, { children: [card, _jsx(ModuleChip, { title: title, icon: Icon, category: category, to: to, href: href, value: value, state: state })] }));
|
|
88
121
|
}
|
|
122
|
+
// The list's columns: whole class names, so Tailwind generates them.
|
|
123
|
+
const STRIP_LIST = {
|
|
124
|
+
tiles: 'grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
|
|
125
|
+
row: 'flex gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
|
|
126
|
+
};
|
|
89
127
|
/**
|
|
90
128
|
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
91
|
-
* wrap, and three columns of tiles
|
|
129
|
+
* wrap, and on a phone three columns of tiles, or one sideways row of
|
|
130
|
+
* chips (`mobile="row"`).
|
|
92
131
|
*/
|
|
93
|
-
export function ModuleStrip({ 'aria-label': label, children, className }) {
|
|
94
|
-
|
|
132
|
+
export function ModuleStrip({ 'aria-label': label, children, mobile = 'tiles', className }) {
|
|
133
|
+
const row = mobile === 'row';
|
|
134
|
+
return (_jsx(StripMobileContext.Provider, { value: mobile, children: _jsx("nav", { "aria-label": label, "data-slot": "module-strip", "data-mobile": mobile, className: cn(row && 'relative min-w-0 max-md:overflow-x-auto max-md:[scrollbar-width:none]', className), children: _jsx("ul", { className: STRIP_LIST[mobile], children: Children.map(children, (child) => child && _jsx("li", { className: cn('min-w-0', row && 'max-md:shrink-0'), children: child })) }) }) }));
|
|
95
135
|
}
|