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