@tristan2828/ui-foundation 4.54.0 → 4.55.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.
@@ -117,6 +117,21 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
117
117
  * layout's alone: a card layout's cards are as they are.
118
118
  */
119
119
  detailPanel?: (row: TData) => ReactNode;
120
+ /**
121
+ * What a click on a row (or Enter on it) does instead of picking it for
122
+ * the `detailPanel`: open its page, say. A link or control inside the row
123
+ * keeps its own click, and the panel is then opened from the app's own
124
+ * control through `detailRow`. Left out, a row click picks the row.
125
+ */
126
+ onRowClick?: (row: TData) => void;
127
+ /**
128
+ * The row the `detailPanel` shows, controlled: its row id (`getRowId`), or
129
+ * null for none. With `onDetailRowChange` it lets a control of the app's
130
+ * own (a button in a row) open the panel. Left out, the table keeps the
131
+ * pick itself.
132
+ */
133
+ detailRow?: string | null;
134
+ onDetailRowChange?: (rowId: string | null) => void;
120
135
  /** The panel's heading and landmark name: "Details" unless set. */
121
136
  detailLabel?: string;
122
137
  /**
@@ -178,6 +193,7 @@ export type DataTableGrouping<TData> = {
178
193
  options: readonly {
179
194
  value: string;
180
195
  label: string;
196
+ icon?: ReactNode;
181
197
  }[];
182
198
  /**
183
199
  * The row's value: one option's value, or a list of them for a
@@ -224,4 +240,4 @@ export type DataTableTotals = {
224
240
  */
225
241
  cells?: Partial<Record<string, ReactNode>>;
226
242
  };
227
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, groupings, grouping: groupingProp, onGroupingChange, detailPanel, detailLabel, }: DataTableProps<TData>): import("react").JSX.Element;
243
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, groupings, grouping: groupingProp, onGroupingChange, detailPanel, onRowClick: onRowClickProp, detailRow, onDetailRowChange, detailLabel, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -260,7 +260,7 @@ function useBottomScrollbar(containerRef, isSuccessView) {
260
260
  }, [containerRef, isSuccessView, isOverflowing]);
261
261
  return { barRef, isOverflowing, scrollWidth };
262
262
  }
263
- 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', }) {
263
+ 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, onRowClick: onRowClickProp, detailRow, onDetailRowChange, detailLabel = 'Details', }) {
264
264
  // The switch's own state, for a table that doesn't pass `layout`.
265
265
  const [ownLayout, setOwnLayout] = useState('table');
266
266
  const requestedLayout = layoutProp ?? ownLayout;
@@ -275,6 +275,10 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
275
275
  // doesn't pass `grouping`.
276
276
  const [ownGrouping, setOwnGrouping] = useState('');
277
277
  const activeGrouping = groupings?.find((candidate) => candidate.id === (groupingProp ?? ownGrouping));
278
+ // Each group's icon (an option's `icon`), by the group's key.
279
+ const groupIcons = activeGrouping
280
+ ? new Map(activeGrouping.options.flatMap((option) => (option.icon ? [[option.value, option.icon]] : [])))
281
+ : undefined;
278
282
  const setGrouping = (next) => {
279
283
  if (groupingProp === undefined)
280
284
  setOwnGrouping(next);
@@ -285,7 +289,20 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
285
289
  const [folds, setFolds] = useState({ grouping: '', keys: EMPTY_KEYS });
286
290
  const folded = activeGrouping && folds.grouping === activeGrouping.id ? folds.keys : EMPTY_KEYS;
287
291
  // The picked row (`detailPanel`) and the place it was picked in.
288
- const [selection, setSelection] = useState(null);
292
+ const [ownSelection, setOwnSelection] = useState(null);
293
+ // Controlled (`detailRow`): the app's row id, at the place picked in when
294
+ // that's the same row, else the row's first place.
295
+ const selection = detailRow === undefined
296
+ ? ownSelection
297
+ : detailRow === null
298
+ ? null
299
+ : ownSelection?.rowId === detailRow
300
+ ? ownSelection
301
+ : { key: detailRow, rowId: detailRow };
302
+ const setSelection = (next) => {
303
+ setOwnSelection(next);
304
+ onDetailRowChange?.(next?.rowId ?? null);
305
+ };
289
306
  const isWide = useIsWide();
290
307
  // Editing in place in the rows (cell pattern 18, "In a table's rows"):
291
308
  // a cell that renders an <EditableValue> edits where it's shown, one at
@@ -407,12 +424,18 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
407
424
  return;
408
425
  const control = target.closest(ROW_CONTROL_SELECTOR);
409
426
  if (control && event.currentTarget.contains(control)) {
410
- if (!control.matches('a[href]'))
427
+ if (!control.matches('a[href]') || onRowClickProp)
411
428
  return;
412
429
  // In the capture phase, so react-router's Link sees it prevented and
413
430
  // doesn't navigate.
414
431
  event.preventDefault();
415
432
  }
433
+ if (onRowClickProp) {
434
+ const row = rows.find((candidate) => candidate.id === place.rowId);
435
+ if (row)
436
+ onRowClickProp(row.original);
437
+ return;
438
+ }
416
439
  setSelection(place);
417
440
  };
418
441
  const onListKeyDown = (event) => {
@@ -437,6 +460,12 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
437
460
  if (!place)
438
461
  return;
439
462
  event.preventDefault();
463
+ if (onRowClickProp) {
464
+ const row = rows.find((candidate) => candidate.id === place.rowId);
465
+ if (row)
466
+ onRowClickProp(row.original);
467
+ return;
468
+ }
440
469
  setSelection(place);
441
470
  }
442
471
  else if (event.key === 'Escape' && selectedRow) {
@@ -468,7 +497,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
468
497
  return;
469
498
  event.preventDefault();
470
499
  closeDetail();
471
- }, 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,
500
+ }, 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, groupIcons: groupIcons })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef, onKeyDown: detailPanel ? onListKeyDown : undefined,
472
501
  // With the sticky bar below as the visible scrollbar, hide the
473
502
  // container's own. It stays overflow-x-auto and fully
474
503
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -499,7 +528,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
499
528
  : '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));
500
529
  }) }, headerGroup.id))) }), runs ? (runs.map((run, runIndex) => {
501
530
  const isFolded = folded.has(run.key);
502
- 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}`));
531
+ 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, icon: groupIcons?.get(run.key), 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}`));
503
532
  })) : (_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) => {
504
533
  const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
505
534
  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));
@@ -518,17 +547,19 @@ function DetailFrame({ enabled, aside, children }) {
518
547
  }
519
548
  // A group's heading text: under a grouping, a button that folds the group,
520
549
  // with its row count; under `groupBy`, the label alone.
521
- function GroupLabel({ run, isFolded, onToggle, }) {
522
- if (!run.foldable)
523
- return _jsx("span", { className: "type-label text-foreground", children: run.label });
524
- 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 })] }));
550
+ function GroupLabel({ run, icon, isFolded, onToggle, }) {
551
+ const label = (_jsxs(_Fragment, { children: [icon ? (_jsx("span", { "aria-hidden": "true", className: "inline-flex shrink-0 items-center self-center", children: icon })) : null, _jsx("span", { className: "type-label text-foreground", children: run.label })] }));
552
+ if (!run.foldable) {
553
+ return icon ? (_jsx("span", { className: "inline-flex items-baseline gap-1.5", children: label })) : (_jsx("span", { className: "type-label text-foreground", children: run.label }));
554
+ }
555
+ 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') }), label, _jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: run.rows.length })] }));
525
556
  }
526
- function Cards({ layout, runs, rows, folded, onToggleFold, groupSummary, }) {
557
+ function Cards({ layout, runs, rows, folded, onToggleFold, groupSummary, groupIcons, }) {
527
558
  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))) }));
528
559
  return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: runs
529
560
  ? runs.map((run, runIndex) => {
530
561
  const isFolded = folded.has(run.key);
531
- 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}`));
562
+ 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, icon: groupIcons?.get(run.key), 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}`));
532
563
  })
533
564
  : list(rows) }));
534
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.54.0",
3
+ "version": "4.55.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {