datool 0.0.5 → 0.0.6

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.
@@ -1,13 +1,19 @@
1
1
  /* eslint-disable react-hooks/incompatible-library, react-refresh/only-export-components */
2
2
  import {
3
+ getExpandedRowModel,
4
+ getGroupedRowModel,
3
5
  functionalUpdate,
4
6
  getCoreRowModel,
5
7
  getFilteredRowModel,
6
8
  getSortedRowModel,
7
9
  useReactTable,
10
+ type AggregationFnOption,
11
+ type Column,
8
12
  type ColumnDef,
9
13
  type ColumnFiltersState,
14
+ type ExpandedState,
10
15
  type FilterFn,
16
+ type GroupingState,
11
17
  type OnChangeFn,
12
18
  type Row,
13
19
  type RowData,
@@ -19,6 +25,8 @@ import {
19
25
  import { useVirtualizer } from "@tanstack/react-virtual"
20
26
  import {
21
27
  Check,
28
+ ChevronDown,
29
+ ChevronRight,
22
30
  CircleAlert,
23
31
  EyeOff,
24
32
  LayoutGrid,
@@ -46,7 +54,10 @@ import {
46
54
  inferDataTableColumnKind,
47
55
  type DataTableColumnKind,
48
56
  } from "./data-table-col-icon"
49
- import type { DatoolEnumColorMap } from "../../shared/types"
57
+ import type {
58
+ DatoolDateFormat,
59
+ DatoolEnumColorMap,
60
+ } from "../../shared/types"
50
61
  import { Button } from "@/components/ui/button"
51
62
  import {
52
63
  ContextMenu,
@@ -142,10 +153,13 @@ export type DataTableRowAction<TData extends DataTableRow> = {
142
153
  export type DataTableColumnConfig<TData extends DataTableRow> = {
143
154
  accessorFn?: (row: TData) => unknown
144
155
  accessorKey?: Extract<keyof TData, string>
156
+ aggregatedCell?: ColumnDef<TData>["aggregatedCell"]
157
+ aggregationFn?: AggregationFnOption<TData>
145
158
  align?: DataTableAlign
146
159
  cell?: (args: { row: TData; value: unknown }) => React.ReactNode
147
160
  cellClassName?: string
148
161
  enableFiltering?: boolean
162
+ enableGrouping?: boolean
149
163
  enableHiding?: boolean
150
164
  enableResizing?: boolean
151
165
  enableSorting?: boolean
@@ -161,6 +175,7 @@ export type DataTableColumnConfig<TData extends DataTableRow> = {
161
175
  minWidth?: number
162
176
  truncate?: boolean
163
177
  width?: number
178
+ getGroupingValue?: (row: TData) => unknown
164
179
  }
165
180
 
166
181
  export type DataTableProps<TData extends DataTableRow> = {
@@ -170,10 +185,12 @@ export type DataTableProps<TData extends DataTableRow> = {
170
185
  columnVisibility?: VisibilityState
171
186
  columns?: DataTableColumnConfig<TData>[]
172
187
  data: TData[]
188
+ dateFormat?: DatoolDateFormat
173
189
  edgeHorizontalPadding?: React.CSSProperties["paddingLeft"]
174
190
  enableRowSelection?: boolean
175
191
  filterPlaceholder?: string
176
192
  globalFilter?: string
193
+ grouping?: GroupingState
177
194
  getRowId?: (row: TData, index: number) => string
178
195
  height?: React.CSSProperties["height"]
179
196
  highlightQuery?: string
@@ -181,6 +198,7 @@ export type DataTableProps<TData extends DataTableRow> = {
181
198
  onColumnFiltersChange?: (value: ColumnFiltersState) => void
182
199
  onColumnVisibilityChange?: (value: VisibilityState) => void
183
200
  onGlobalFilterChange?: (value: string) => void
201
+ onGroupingChange?: (value: GroupingState) => void
184
202
  resolveColumnHighlightTerms?: (columnId: string, query: string) => string[]
185
203
  rowActions?: DataTableRowAction<TData>[]
186
204
  rowClassName?: (row: TData) => string | undefined
@@ -227,6 +245,7 @@ type PersistedTableState = {
227
245
  highlightedColumns?: Record<string, boolean>
228
246
  columnVisibility?: VisibilityState
229
247
  globalFilter?: string
248
+ grouping?: GroupingState
230
249
  sorting?: SortingState
231
250
  }
232
251
 
@@ -402,6 +421,7 @@ function getPersistedUrlParam(id: string) {
402
421
  function isPersistedStateEmpty(state: PersistedTableState) {
403
422
  return (
404
423
  (state.sorting?.length ?? 0) === 0 &&
424
+ (state.grouping?.length ?? 0) === 0 &&
405
425
  (state.columnFilters?.length ?? 0) === 0 &&
406
426
  Object.keys(state.highlightedColumns ?? {}).length === 0 &&
407
427
  Object.keys(state.columnVisibility ?? {}).length === 0 &&
@@ -496,6 +516,119 @@ function inferWidth(kind: DataTableColumnKind) {
496
516
  }
497
517
  }
498
518
 
519
+ type DateRangeAggregate = {
520
+ durationMs: number
521
+ endMs: number
522
+ startMs: number
523
+ }
524
+
525
+ function toTimestamp(value: unknown) {
526
+ if (value instanceof Date) {
527
+ return Number.isNaN(value.getTime()) ? null : value.getTime()
528
+ }
529
+
530
+ if (typeof value === "number") {
531
+ return Number.isFinite(value) ? value : null
532
+ }
533
+
534
+ if (typeof value === "string") {
535
+ const timestamp = Date.parse(value)
536
+
537
+ return Number.isNaN(timestamp) ? null : timestamp
538
+ }
539
+
540
+ return null
541
+ }
542
+
543
+ function buildDateRangeAggregate<TData extends DataTableRow>(
544
+ columnId: string,
545
+ leafRows: Row<TData>[]
546
+ ) {
547
+ let startMs = Number.POSITIVE_INFINITY
548
+ let endMs = Number.NEGATIVE_INFINITY
549
+
550
+ for (const row of leafRows) {
551
+ const timestamp = toTimestamp(row.getValue(columnId))
552
+
553
+ if (timestamp === null) {
554
+ continue
555
+ }
556
+
557
+ startMs = Math.min(startMs, timestamp)
558
+ endMs = Math.max(endMs, timestamp)
559
+ }
560
+
561
+ if (
562
+ startMs === Number.POSITIVE_INFINITY ||
563
+ endMs === Number.NEGATIVE_INFINITY
564
+ ) {
565
+ return null
566
+ }
567
+
568
+ return {
569
+ durationMs: Math.max(0, endMs - startMs),
570
+ endMs,
571
+ startMs,
572
+ } satisfies DateRangeAggregate
573
+ }
574
+
575
+ function isDateRangeAggregate(value: unknown): value is DateRangeAggregate {
576
+ return (
577
+ value !== null &&
578
+ typeof value === "object" &&
579
+ "durationMs" in value &&
580
+ "endMs" in value &&
581
+ "startMs" in value
582
+ )
583
+ }
584
+
585
+ function formatSummaryNumber(value: number) {
586
+ return new Intl.NumberFormat(undefined, {
587
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
588
+ }).format(value)
589
+ }
590
+
591
+ function formatDuration(durationMs: number) {
592
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000))
593
+ const days = Math.floor(totalSeconds / 86_400)
594
+ const hours = Math.floor((totalSeconds % 86_400) / 3_600)
595
+ const minutes = Math.floor((totalSeconds % 3_600) / 60)
596
+ const seconds = totalSeconds % 60
597
+ const parts = [
598
+ days > 0 ? `${days}d` : null,
599
+ hours > 0 ? `${hours}h` : null,
600
+ minutes > 0 ? `${minutes}m` : null,
601
+ seconds > 0 || totalSeconds === 0 ? `${seconds}s` : null,
602
+ ].filter(Boolean)
603
+
604
+ return parts.join(" ")
605
+ }
606
+
607
+ function getColumnHeaderLabel<TData extends DataTableRow>(
608
+ column: Column<TData, unknown>
609
+ ) {
610
+ return typeof column.columnDef.header === "string"
611
+ ? column.columnDef.header
612
+ : formatHeaderLabel(column.id)
613
+ }
614
+
615
+ function resolveGroupedPadding(
616
+ padding: React.CSSProperties["paddingLeft"],
617
+ depth: number
618
+ ) {
619
+ const indent = depth * 16
620
+
621
+ if (typeof padding === "number") {
622
+ return padding + indent
623
+ }
624
+
625
+ if (typeof padding === "string") {
626
+ return `calc(${padding} + ${indent}px)`
627
+ }
628
+
629
+ return indent
630
+ }
631
+
499
632
  function resolveColumnId<TData extends DataTableRow>(
500
633
  column: DataTableColumnConfig<TData>,
501
634
  index: number
@@ -512,6 +645,7 @@ function resolveColumnId<TData extends DataTableRow>(
512
645
  function buildColumns<TData extends DataTableRow>(
513
646
  data: TData[],
514
647
  columns?: DataTableColumnConfig<TData>[],
648
+ dateFormat?: DatoolDateFormat,
515
649
  showRowSelectionColumn?: boolean,
516
650
  showRowActionButtonsColumn?: boolean,
517
651
  rowActionsColumnSize?: number
@@ -553,18 +687,29 @@ function buildColumns<TData extends DataTableRow>(
553
687
  return {
554
688
  accessorFn: column.accessorFn,
555
689
  accessorKey: column.accessorKey,
690
+ aggregatedCell: column.aggregatedCell,
691
+ aggregationFn:
692
+ column.aggregationFn ??
693
+ (kind === "date"
694
+ ? buildDateRangeAggregate
695
+ : kind === "number"
696
+ ? "sum"
697
+ : undefined),
556
698
  cell: ({ getValue, row }) =>
557
699
  column.cell
558
700
  ? column.cell({ row: row.original, value: getValue() })
559
701
  : fallbackCellValue(getValue(), kind, {
702
+ dateFormat,
560
703
  enumColors: kind === "enum" ? column.enumColors : undefined,
561
704
  enumOptions: kind === "enum" ? column.enumOptions : undefined,
562
705
  }),
563
706
  enableGlobalFilter: column.enableFiltering ?? true,
707
+ enableGrouping: column.enableGrouping ?? true,
564
708
  filterFn: column.filterFn,
565
709
  enableHiding: column.enableHiding ?? true,
566
710
  enableResizing: column.enableResizing ?? true,
567
711
  enableSorting: column.enableSorting ?? true,
712
+ getGroupingValue: column.getGroupingValue,
568
713
  header: column.header ?? formatHeaderLabel(id),
569
714
  id,
570
715
  maxSize: column.maxWidth ?? 420,
@@ -583,6 +728,7 @@ function buildColumns<TData extends DataTableRow>(
583
728
  ...builtColumns,
584
729
  {
585
730
  cell: () => null,
731
+ enableGrouping: false,
586
732
  enableGlobalFilter: false,
587
733
  enableHiding: false,
588
734
  enableResizing: false,
@@ -604,13 +750,16 @@ function buildColumns<TData extends DataTableRow>(
604
750
  {
605
751
  cell: ({ row }) => (
606
752
  <div className="flex items-center justify-center">
607
- <DataTableCheckbox
608
- ariaLabel={`Select row ${row.index + 1}`}
609
- checked={row.getIsSelected()}
610
- onCheckedChange={(checked) => row.toggleSelected(checked)}
611
- />
753
+ {row.getCanSelect() ? (
754
+ <DataTableCheckbox
755
+ ariaLabel={`Select row ${row.index + 1}`}
756
+ checked={row.getIsSelected()}
757
+ onCheckedChange={(checked) => row.toggleSelected(checked)}
758
+ />
759
+ ) : null}
612
760
  </div>
613
761
  ),
762
+ enableGrouping: false,
614
763
  enableGlobalFilter: false,
615
764
  enableHiding: false,
616
765
  enableResizing: false,
@@ -649,6 +798,7 @@ function buildColumns<TData extends DataTableRow>(
649
798
  ...withSelectionColumn,
650
799
  {
651
800
  cell: () => null,
801
+ enableGrouping: false,
652
802
  enableGlobalFilter: false,
653
803
  enableHiding: false,
654
804
  enableResizing: false,
@@ -704,6 +854,31 @@ function shouldIgnoreRowSelectionTarget(target: EventTarget | null) {
704
854
  )
705
855
  }
706
856
 
857
+ function shouldIgnoreSelectionShortcutTarget(target: EventTarget | null) {
858
+ return (
859
+ target instanceof Element &&
860
+ Boolean(
861
+ target.closest(
862
+ [
863
+ "a",
864
+ "button",
865
+ "input",
866
+ "select",
867
+ "textarea",
868
+ "[contenteditable=true]",
869
+ '[role="button"]',
870
+ '[role="combobox"]',
871
+ '[role="dialog"]',
872
+ '[role="menu"]',
873
+ '[role="menuitem"]',
874
+ '[role="searchbox"]',
875
+ '[role="textbox"]',
876
+ ].join(",")
877
+ )
878
+ )
879
+ )
880
+ }
881
+
707
882
  function resolveRowActionRows<TData extends DataTableRow>(
708
883
  action: DataTableRowAction<TData>,
709
884
  row: Row<TData>,
@@ -1119,6 +1294,21 @@ function RowActionButtonGroup<TData extends DataTableRow>({
1119
1294
  )
1120
1295
  }
1121
1296
 
1297
+ function GroupSummaryBadge({
1298
+ label,
1299
+ value,
1300
+ }: {
1301
+ label: string
1302
+ value: string
1303
+ }) {
1304
+ return (
1305
+ <span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
1306
+ <span className="text-foreground">{value}</span>
1307
+ <span>{label}</span>
1308
+ </span>
1309
+ )
1310
+ }
1311
+
1122
1312
  function DataTableView<TData extends DataTableRow>({
1123
1313
  autoScrollToBottom = false,
1124
1314
  autoScrollToBottomThreshold = 96,
@@ -1126,10 +1316,12 @@ function DataTableView<TData extends DataTableRow>({
1126
1316
  columnVisibility: controlledColumnVisibility,
1127
1317
  columns,
1128
1318
  data,
1319
+ dateFormat,
1129
1320
  edgeHorizontalPadding = "16px",
1130
1321
  enableRowSelection = false,
1131
1322
  filterPlaceholder = "Search across visible columns",
1132
1323
  globalFilter,
1324
+ grouping: controlledGrouping,
1133
1325
  getRowId,
1134
1326
  height = 620,
1135
1327
  highlightQuery = "",
@@ -1137,6 +1329,7 @@ function DataTableView<TData extends DataTableRow>({
1137
1329
  onColumnFiltersChange,
1138
1330
  onColumnVisibilityChange,
1139
1331
  onGlobalFilterChange,
1332
+ onGroupingChange,
1140
1333
  resolveColumnHighlightTerms,
1141
1334
  rowActions,
1142
1335
  rowClassName,
@@ -1163,6 +1356,9 @@ function DataTableView<TData extends DataTableRow>({
1163
1356
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
1164
1357
  () => persistedState.columnFilters ?? []
1165
1358
  )
1359
+ const [grouping, setGrouping] = React.useState<GroupingState>(
1360
+ () => persistedState.grouping ?? []
1361
+ )
1166
1362
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
1167
1363
  const [rowActionStatuses, setRowActionStatuses] = React.useState<
1168
1364
  Record<string, RowActionStatus>
@@ -1185,9 +1381,11 @@ function DataTableView<TData extends DataTableRow>({
1185
1381
  const isColumnFiltersControlled = controlledColumnFilters !== undefined
1186
1382
  const isColumnVisibilityControlled = controlledColumnVisibility !== undefined
1187
1383
  const isGlobalFilterControlled = globalFilter !== undefined
1384
+ const isGroupingControlled = controlledGrouping !== undefined
1188
1385
  const resolvedColumnFilters = controlledColumnFilters ?? columnFilters
1189
1386
  const resolvedColumnVisibility =
1190
1387
  controlledColumnVisibility ?? columnVisibility
1388
+ const resolvedGrouping = controlledGrouping ?? grouping
1191
1389
  const hasSelectionActions = rowActions
1192
1390
  ? hasSelectionScopedAction(rowActions)
1193
1391
  : false
@@ -1234,6 +1432,7 @@ function DataTableView<TData extends DataTableRow>({
1234
1432
  buildColumns(
1235
1433
  data,
1236
1434
  columnsWithEnumOptions,
1435
+ dateFormat,
1237
1436
  showRowSelectionColumn,
1238
1437
  showRowActionButtonsColumn,
1239
1438
  rowActionsColumnSize
@@ -1241,6 +1440,7 @@ function DataTableView<TData extends DataTableRow>({
1241
1440
  [
1242
1441
  columnsWithEnumOptions,
1243
1442
  data,
1443
+ dateFormat,
1244
1444
  rowActionsColumnSize,
1245
1445
  showRowActionButtonsColumn,
1246
1446
  showRowSelectionColumn,
@@ -1314,28 +1514,52 @@ function DataTableView<TData extends DataTableRow>({
1314
1514
  resolvedColumnVisibility,
1315
1515
  ]
1316
1516
  )
1517
+ const handleGroupingChange = React.useCallback<OnChangeFn<GroupingState>>(
1518
+ (updater) => {
1519
+ const nextValue = functionalUpdate(updater, resolvedGrouping)
1520
+
1521
+ if (!isGroupingControlled) {
1522
+ setGrouping(nextValue)
1523
+ }
1524
+
1525
+ onGroupingChange?.(nextValue)
1526
+ },
1527
+ [isGroupingControlled, onGroupingChange, resolvedGrouping]
1528
+ )
1529
+ const expandedState = React.useMemo<ExpandedState>(
1530
+ () => (resolvedGrouping.length > 0 ? true : {}),
1531
+ [resolvedGrouping.length]
1532
+ )
1317
1533
 
1318
1534
  const table = useReactTable({
1319
1535
  columnResizeMode: "onChange",
1320
1536
  columns: tableColumns,
1321
1537
  data,
1322
1538
  enableColumnResizing: true,
1323
- enableRowSelection: canSelectRows,
1539
+ enableGrouping: true,
1540
+ enableRowSelection: canSelectRows ? (row) => !row.getIsGrouped() : false,
1541
+ enableSubRowSelection: false,
1324
1542
  getCoreRowModel: getCoreRowModel(),
1543
+ getExpandedRowModel: getExpandedRowModel(),
1325
1544
  getFilteredRowModel: getFilteredRowModel(),
1545
+ getGroupedRowModel: getGroupedRowModel(),
1326
1546
  getRowId,
1327
1547
  getSortedRowModel: getSortedRowModel(),
1548
+ groupedColumnMode: false,
1328
1549
  globalFilterFn: globalFilterFn as FilterFn<TData>,
1329
1550
  onColumnFiltersChange: handleColumnFiltersChange,
1330
1551
  onColumnSizingChange: handleColumnSizingChange,
1331
1552
  onColumnVisibilityChange: handleColumnVisibilityChange,
1553
+ onGroupingChange: handleGroupingChange,
1332
1554
  onRowSelectionChange: setRowSelection,
1333
1555
  onSortingChange: setSorting,
1334
1556
  state: {
1335
1557
  columnFilters: resolvedColumnFilters,
1336
1558
  columnSizing,
1337
1559
  columnVisibility: resolvedColumnVisibility,
1560
+ expanded: expandedState,
1338
1561
  globalFilter: deferredSearch.trim(),
1562
+ grouping: resolvedGrouping,
1339
1563
  rowSelection,
1340
1564
  sorting,
1341
1565
  },
@@ -1346,6 +1570,10 @@ function DataTableView<TData extends DataTableRow>({
1346
1570
  const hasAutoScrolledOnMountRef = React.useRef(false)
1347
1571
  const previousDataLengthRef = React.useRef(0)
1348
1572
  const rows = table.getRowModel().rows
1573
+ const selectableRows = React.useMemo(
1574
+ () => rows.filter((row) => row.getCanSelect()),
1575
+ [rows]
1576
+ )
1349
1577
  const selectedTableRows = table.getSelectedRowModel().rows
1350
1578
  const resolvedHeight = typeof height === "number" ? height : 0
1351
1579
  const initialOffsetRef = React.useRef(
@@ -1387,6 +1615,9 @@ function DataTableView<TData extends DataTableRow>({
1387
1615
  }
1388
1616
  setHighlightedColumns(nextState.highlightedColumns ?? {})
1389
1617
  setColumnFilters(nextState.columnFilters ?? [])
1618
+ if (!isGroupingControlled) {
1619
+ setGrouping(nextState.grouping ?? [])
1620
+ }
1390
1621
  setSearchDraft(nextState.globalFilter ?? "")
1391
1622
  setRowSelection({})
1392
1623
  dragSelectionRef.current = null
@@ -1398,7 +1629,13 @@ function DataTableView<TData extends DataTableRow>({
1398
1629
  }
1399
1630
  rowActionStatusTimersRef.current = {}
1400
1631
  setRowActionStatuses({})
1401
- }, [id, isColumnFiltersControlled, isColumnVisibilityControlled, statePersistence])
1632
+ }, [
1633
+ id,
1634
+ isColumnFiltersControlled,
1635
+ isColumnVisibilityControlled,
1636
+ isGroupingControlled,
1637
+ statePersistence,
1638
+ ])
1402
1639
 
1403
1640
  React.useEffect(() => {
1404
1641
  if (!isColumnFiltersControlled) {
@@ -1416,6 +1653,14 @@ function DataTableView<TData extends DataTableRow>({
1416
1653
  setColumnVisibility(controlledColumnVisibility)
1417
1654
  }, [controlledColumnVisibility, isColumnVisibilityControlled])
1418
1655
 
1656
+ React.useEffect(() => {
1657
+ if (!isGroupingControlled) {
1658
+ return
1659
+ }
1660
+
1661
+ setGrouping(controlledGrouping)
1662
+ }, [controlledGrouping, isGroupingControlled])
1663
+
1419
1664
  React.useEffect(() => {
1420
1665
  if (!isGlobalFilterControlled) {
1421
1666
  return
@@ -1439,6 +1684,9 @@ function DataTableView<TData extends DataTableRow>({
1439
1684
  }
1440
1685
  setHighlightedColumns(nextState.highlightedColumns ?? {})
1441
1686
  setColumnFilters(nextState.columnFilters ?? [])
1687
+ if (!isGroupingControlled) {
1688
+ setGrouping(nextState.grouping ?? [])
1689
+ }
1442
1690
  setSearchDraft(nextState.globalFilter ?? "")
1443
1691
  setRowSelection({})
1444
1692
  dragSelectionRef.current = null
@@ -1450,7 +1698,7 @@ function DataTableView<TData extends DataTableRow>({
1450
1698
  window.addEventListener("popstate", syncFromUrl)
1451
1699
 
1452
1700
  return () => window.removeEventListener("popstate", syncFromUrl)
1453
- }, [id, isColumnVisibilityControlled, statePersistence])
1701
+ }, [id, isColumnVisibilityControlled, isGroupingControlled, statePersistence])
1454
1702
 
1455
1703
  React.useEffect(() => {
1456
1704
  if (isGlobalFilterControlled || isColumnFiltersControlled) {
@@ -1463,6 +1711,7 @@ function DataTableView<TData extends DataTableRow>({
1463
1711
  highlightedColumns,
1464
1712
  columnVisibility: resolvedColumnVisibility,
1465
1713
  globalFilter: deferredSearch.trim(),
1714
+ grouping: resolvedGrouping,
1466
1715
  sorting,
1467
1716
  })
1468
1717
  }, [
@@ -1473,7 +1722,9 @@ function DataTableView<TData extends DataTableRow>({
1473
1722
  id,
1474
1723
  isColumnFiltersControlled,
1475
1724
  isGlobalFilterControlled,
1725
+ isGroupingControlled,
1476
1726
  resolvedColumnVisibility,
1727
+ resolvedGrouping,
1477
1728
  sorting,
1478
1729
  statePersistence,
1479
1730
  ])
@@ -1579,6 +1830,13 @@ function DataTableView<TData extends DataTableRow>({
1579
1830
 
1580
1831
  onGlobalFilterChange?.(value)
1581
1832
  }
1833
+ const clearRowSelection = React.useCallback(() => {
1834
+ setRowSelection({})
1835
+ dragSelectionRef.current = null
1836
+ dragPointerRef.current = null
1837
+ setIsDragSelecting(false)
1838
+ selectionAnchorIdRef.current = null
1839
+ }, [])
1582
1840
 
1583
1841
  const isColumnHighlightEnabled = React.useCallback(
1584
1842
  (columnId: string, meta: DataTableColumnMeta) =>
@@ -1591,10 +1849,10 @@ function DataTableView<TData extends DataTableRow>({
1591
1849
  rowId: string,
1592
1850
  baseSelection: RowSelectionState = {}
1593
1851
  ) => {
1594
- const anchorIndex = rows.findIndex(
1852
+ const anchorIndex = selectableRows.findIndex(
1595
1853
  (candidateRow) => candidateRow.id === anchorId
1596
1854
  )
1597
- const rowIndex = rows.findIndex(
1855
+ const rowIndex = selectableRows.findIndex(
1598
1856
  (candidateRow) => candidateRow.id === rowId
1599
1857
  )
1600
1858
 
@@ -1610,13 +1868,13 @@ function DataTableView<TData extends DataTableRow>({
1610
1868
  setRowSelection({
1611
1869
  ...baseSelection,
1612
1870
  ...Object.fromEntries(
1613
- rows
1871
+ selectableRows
1614
1872
  .slice(start, end + 1)
1615
1873
  .map((candidateRow) => [candidateRow.id, true])
1616
1874
  ),
1617
1875
  })
1618
1876
  },
1619
- [rows]
1877
+ [selectableRows]
1620
1878
  )
1621
1879
  const selectSingleRow = React.useCallback((rowId: string) => {
1622
1880
  selectionAnchorIdRef.current = rowId
@@ -1663,6 +1921,7 @@ function DataTableView<TData extends DataTableRow>({
1663
1921
  if (
1664
1922
  event.button !== 0 ||
1665
1923
  !canSelectRows ||
1924
+ !row.getCanSelect() ||
1666
1925
  shouldIgnoreRowSelectionTarget(event.target)
1667
1926
  ) {
1668
1927
  return
@@ -1724,6 +1983,7 @@ function DataTableView<TData extends DataTableRow>({
1724
1983
  (event: React.MouseEvent<HTMLTableRowElement>, row: Row<TData>) => {
1725
1984
  if (
1726
1985
  !canSelectRows ||
1986
+ !row.getCanSelect() ||
1727
1987
  event.buttons !== 1 ||
1728
1988
  shouldIgnoreRowSelectionTarget(event.target) ||
1729
1989
  !dragSelectionRef.current
@@ -1739,7 +1999,7 @@ function DataTableView<TData extends DataTableRow>({
1739
1999
  )
1740
2000
  const handleRowContextMenu = React.useCallback(
1741
2001
  (row: Row<TData>) => {
1742
- if (!canSelectRows) {
2002
+ if (!canSelectRows || !row.getCanSelect()) {
1743
2003
  return
1744
2004
  }
1745
2005
 
@@ -1819,6 +2079,27 @@ function DataTableView<TData extends DataTableRow>({
1819
2079
  window.cancelAnimationFrame(frameId)
1820
2080
  }
1821
2081
  }, [isDragSelecting, updateDragSelectionFromPointer])
2082
+ React.useEffect(() => {
2083
+ if (!canSelectRows) {
2084
+ return
2085
+ }
2086
+
2087
+ const handleWindowKeyDown = (event: KeyboardEvent) => {
2088
+ if (
2089
+ event.key !== "Escape" ||
2090
+ Object.keys(rowSelection).length === 0 ||
2091
+ shouldIgnoreSelectionShortcutTarget(event.target)
2092
+ ) {
2093
+ return
2094
+ }
2095
+
2096
+ clearRowSelection()
2097
+ }
2098
+
2099
+ window.addEventListener("keydown", handleWindowKeyDown)
2100
+
2101
+ return () => window.removeEventListener("keydown", handleWindowKeyDown)
2102
+ }, [canSelectRows, clearRowSelection, rowSelection])
1822
2103
 
1823
2104
  return (
1824
2105
  <section
@@ -1896,100 +2177,224 @@ function DataTableView<TData extends DataTableRow>({
1896
2177
  ) : (
1897
2178
  virtualRows.map((virtualRow) => {
1898
2179
  const row = rows[virtualRow.index]
1899
- const visibleRowActions = resolveVisibleRowActions(
1900
- rowActions ?? [],
1901
- row,
1902
- selectedTableRows
1903
- )
2180
+ const isGroupRow = row.getIsGrouped()
2181
+ const visibleRowActions = isGroupRow
2182
+ ? []
2183
+ : resolveVisibleRowActions(
2184
+ rowActions ?? [],
2185
+ row,
2186
+ selectedTableRows
2187
+ )
1904
2188
  const isSelected = row.getIsSelected()
2189
+ const groupVisibleCells = row.getVisibleCells()
2190
+ const groupingColumn = row.groupingColumnId
2191
+ ? table.getColumn(row.groupingColumnId)
2192
+ : undefined
2193
+ const groupingMeta = groupingColumn?.columnDef.meta as
2194
+ | DataTableColumnMeta
2195
+ | undefined
2196
+ const groupingValue = groupingColumn
2197
+ ? fallbackCellValue(row.groupingValue, groupingMeta?.kind, {
2198
+ dateFormat,
2199
+ enumColors: groupingMeta?.enumColors,
2200
+ enumOptions: groupingMeta?.enumOptions,
2201
+ })
2202
+ : null
2203
+ const groupSummaries = isGroupRow
2204
+ ? groupVisibleCells.flatMap((cell) => {
2205
+ const meta = (cell.column.columnDef.meta ??
2206
+ {}) as DataTableColumnMeta
2207
+ const value = cell.getValue()
2208
+ const label = getColumnHeaderLabel(cell.column)
2209
+
2210
+ if (meta.kind === "date" && isDateRangeAggregate(value)) {
2211
+ return [
2212
+ {
2213
+ label: `${label} span`,
2214
+ value: formatDuration(value.durationMs),
2215
+ },
2216
+ ]
2217
+ }
2218
+
2219
+ if (meta.kind === "number" && typeof value === "number") {
2220
+ return [
2221
+ {
2222
+ label: `${label} sum`,
2223
+ value: formatSummaryNumber(value),
2224
+ },
2225
+ ]
2226
+ }
2227
+
2228
+ return []
2229
+ })
2230
+ : []
1905
2231
  const rowContent = (
1906
2232
  <tr
1907
2233
  aria-selected={isSelected}
1908
2234
  className={cn(
1909
- "absolute left-0 flex w-full bg-card transition-colors",
1910
- canSelectRows && "cursor-pointer",
1911
- rowClassName?.(row.original),
1912
- isSelected &&
2235
+ "absolute left-0 flex w-full transition-colors",
2236
+ isGroupRow
2237
+ ? "bg-transparent"
2238
+ : "bg-card",
2239
+ canSelectRows &&
2240
+ row.getCanSelect() &&
2241
+ "cursor-pointer",
2242
+ !isGroupRow && rowClassName?.(row.original),
2243
+ !isGroupRow &&
2244
+ isSelected &&
1913
2245
  "bg-primary/10 before:absolute before:-top-px before:left-0 before:h-px before:w-full before:bg-primary before:content-[''] after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:bg-primary after:content-['']"
1914
2246
  )}
1915
2247
  data-index={virtualRow.index}
1916
2248
  data-row-id={row.id}
1917
- data-state={isSelected ? "selected" : undefined}
2249
+ data-state={!isGroupRow && isSelected ? "selected" : undefined}
1918
2250
  key={row.id}
1919
- onContextMenu={() => handleRowContextMenu(row)}
1920
- onMouseDown={(event) => handleRowMouseDown(event, row)}
1921
- onMouseEnter={(event) => handleRowMouseEnter(event, row)}
2251
+ onContextMenu={
2252
+ isGroupRow ? undefined : () => handleRowContextMenu(row)
2253
+ }
2254
+ onMouseDown={
2255
+ isGroupRow
2256
+ ? undefined
2257
+ : (event) => handleRowMouseDown(event, row)
2258
+ }
2259
+ onMouseEnter={
2260
+ isGroupRow
2261
+ ? undefined
2262
+ : (event) => handleRowMouseEnter(event, row)
2263
+ }
1922
2264
  ref={(node) => {
1923
2265
  if (node) {
1924
2266
  rowVirtualizer.measureElement(node)
1925
2267
  }
1926
2268
  }}
1927
2269
  style={{
1928
- ...rowStyle?.(row.original),
1929
- minHeight: rowHeight,
2270
+ ...(!isGroupRow ? rowStyle?.(row.original) : undefined),
2271
+ minHeight: isGroupRow ? Math.max(rowHeight, 44) : rowHeight,
1930
2272
  transform: `translateY(${virtualRow.start}px)`,
1931
2273
  width: table.getTotalSize(),
1932
2274
  }}
1933
2275
  >
1934
- {row.getVisibleCells().map((cell, index, visibleCells) => {
1935
- const meta = (cell.column.columnDef.meta ??
1936
- {}) as DataTableColumnMeta
1937
- const isActionsCell = cell.column.id === "__actions"
1938
- const highlightTerms =
1939
- meta.kind === "text" &&
1940
- resolveColumnHighlightTerms &&
1941
- isColumnHighlightEnabled(cell.column.id, meta)
1942
- ? resolveColumnHighlightTerms(
1943
- cell.column.id,
1944
- highlightQuery
1945
- )
1946
- : []
1947
-
1948
- if (isActionsCell) {
2276
+ {isGroupRow ? (
2277
+ <td
2278
+ className="flex shrink-0 items-center border-y border-border/70 px-2 py-2 align-middle text-xs text-muted-foreground"
2279
+ style={{
2280
+ background:
2281
+ "var(--color-table-gap, color-mix(in oklab, var(--color-muted) 84%, transparent))",
2282
+ paddingLeft: resolveGroupedPadding(
2283
+ edgeHorizontalPadding,
2284
+ row.depth
2285
+ ),
2286
+ paddingRight: edgeHorizontalPadding,
2287
+ width: table.getTotalSize(),
2288
+ }}
2289
+ >
2290
+ <div className="flex w-full min-w-0 flex-wrap items-center gap-2">
2291
+ {row.getCanExpand() ? (
2292
+ <button
2293
+ aria-label={
2294
+ row.getIsExpanded()
2295
+ ? "Collapse group"
2296
+ : "Expand group"
2297
+ }
2298
+ className="inline-flex size-6 items-center justify-center rounded-md border border-border/70 bg-background/80 text-foreground transition-colors hover:bg-background"
2299
+ onClick={() => row.toggleExpanded()}
2300
+ type="button"
2301
+ >
2302
+ {row.getIsExpanded() ? (
2303
+ <ChevronDown className="size-3.5" />
2304
+ ) : (
2305
+ <ChevronRight className="size-3.5" />
2306
+ )}
2307
+ </button>
2308
+ ) : null}
2309
+ {groupingColumn ? (
2310
+ <span className="inline-flex items-center gap-2 text-xs">
2311
+ <span className="font-medium text-foreground">
2312
+ {getColumnHeaderLabel(groupingColumn)}
2313
+ </span>
2314
+ <span className="min-w-0 truncate text-foreground">
2315
+ {groupingValue}
2316
+ </span>
2317
+ </span>
2318
+ ) : (
2319
+ <span className="font-medium text-foreground">
2320
+ Group
2321
+ </span>
2322
+ )}
2323
+ <GroupSummaryBadge
2324
+ label="rows"
2325
+ value={formatSummaryNumber(row.getLeafRows().length)}
2326
+ />
2327
+ {groupSummaries.map((summary) => (
2328
+ <GroupSummaryBadge
2329
+ key={`${row.id}-${summary.label}`}
2330
+ label={summary.label}
2331
+ value={summary.value}
2332
+ />
2333
+ ))}
2334
+ </div>
2335
+ </td>
2336
+ ) : (
2337
+ row.getVisibleCells().map((cell, index, visibleCells) => {
2338
+ const meta = (cell.column.columnDef.meta ??
2339
+ {}) as DataTableColumnMeta
2340
+ const isActionsCell = cell.column.id === "__actions"
2341
+ const highlightTerms =
2342
+ meta.kind === "text" &&
2343
+ resolveColumnHighlightTerms &&
2344
+ isColumnHighlightEnabled(cell.column.id, meta)
2345
+ ? resolveColumnHighlightTerms(
2346
+ cell.column.id,
2347
+ highlightQuery
2348
+ )
2349
+ : []
2350
+
2351
+ if (isActionsCell) {
2352
+ return (
2353
+ <td
2354
+ className={cn(
2355
+ "flex shrink-0 border-b border-border px-2 py-1.5 align-middle text-sm text-foreground justify-end text-right",
2356
+ meta.cellClassName
2357
+ )}
2358
+ key={cell.id}
2359
+ style={{
2360
+ paddingLeft:
2361
+ index === 0 ? edgeHorizontalPadding : undefined,
2362
+ paddingRight:
2363
+ index === visibleCells.length - 1
2364
+ ? edgeHorizontalPadding
2365
+ : undefined,
2366
+ width: cell.column.getSize(),
2367
+ }}
2368
+ >
2369
+ <div className="w-full min-w-0">
2370
+ <RowActionButtonGroup
2371
+ resolvedActions={visibleRowActions}
2372
+ setStatus={setRowActionStatus}
2373
+ statuses={rowActionStatuses}
2374
+ />
2375
+ </div>
2376
+ </td>
2377
+ )
2378
+ }
2379
+
1949
2380
  return (
1950
- <td
1951
- className={cn(
1952
- "flex shrink-0 border-b border-border px-2 py-1.5 align-middle text-sm text-foreground justify-end text-right",
1953
- meta.cellClassName
1954
- )}
2381
+ <DataTableBodyCell
2382
+ cell={cell}
2383
+ dateFormat={dateFormat}
2384
+ highlightTerms={highlightTerms}
1955
2385
  key={cell.id}
1956
- style={{
1957
- paddingLeft:
1958
- index === 0 ? edgeHorizontalPadding : undefined,
1959
- paddingRight:
1960
- index === visibleCells.length - 1
1961
- ? edgeHorizontalPadding
1962
- : undefined,
1963
- width: cell.column.getSize(),
1964
- }}
1965
- >
1966
- <div className="w-full min-w-0">
1967
- <RowActionButtonGroup
1968
- resolvedActions={visibleRowActions}
1969
- setStatus={setRowActionStatus}
1970
- statuses={rowActionStatuses}
1971
- />
1972
- </div>
1973
- </td>
2386
+ paddingLeft={
2387
+ index === 0 ? edgeHorizontalPadding : undefined
2388
+ }
2389
+ paddingRight={
2390
+ index === visibleCells.length - 1
2391
+ ? edgeHorizontalPadding
2392
+ : undefined
2393
+ }
2394
+ />
1974
2395
  )
1975
- }
1976
-
1977
- return (
1978
- <DataTableBodyCell
1979
- cell={cell}
1980
- highlightTerms={highlightTerms}
1981
- key={cell.id}
1982
- paddingLeft={
1983
- index === 0 ? edgeHorizontalPadding : undefined
1984
- }
1985
- paddingRight={
1986
- index === visibleCells.length - 1
1987
- ? edgeHorizontalPadding
1988
- : undefined
1989
- }
1990
- />
1991
- )
1992
- })}
2396
+ })
2397
+ )}
1993
2398
  </tr>
1994
2399
  )
1995
2400