datool 0.0.4 → 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,9 +198,11 @@ 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
205
+ rowStyle?: (row: TData) => React.CSSProperties | undefined
187
206
  rowHeight?: number
188
207
  statePersistence?: "localStorage" | "none" | "url"
189
208
  }
@@ -226,6 +245,7 @@ type PersistedTableState = {
226
245
  highlightedColumns?: Record<string, boolean>
227
246
  columnVisibility?: VisibilityState
228
247
  globalFilter?: string
248
+ grouping?: GroupingState
229
249
  sorting?: SortingState
230
250
  }
231
251
 
@@ -401,6 +421,7 @@ function getPersistedUrlParam(id: string) {
401
421
  function isPersistedStateEmpty(state: PersistedTableState) {
402
422
  return (
403
423
  (state.sorting?.length ?? 0) === 0 &&
424
+ (state.grouping?.length ?? 0) === 0 &&
404
425
  (state.columnFilters?.length ?? 0) === 0 &&
405
426
  Object.keys(state.highlightedColumns ?? {}).length === 0 &&
406
427
  Object.keys(state.columnVisibility ?? {}).length === 0 &&
@@ -495,6 +516,119 @@ function inferWidth(kind: DataTableColumnKind) {
495
516
  }
496
517
  }
497
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
+
498
632
  function resolveColumnId<TData extends DataTableRow>(
499
633
  column: DataTableColumnConfig<TData>,
500
634
  index: number
@@ -511,6 +645,7 @@ function resolveColumnId<TData extends DataTableRow>(
511
645
  function buildColumns<TData extends DataTableRow>(
512
646
  data: TData[],
513
647
  columns?: DataTableColumnConfig<TData>[],
648
+ dateFormat?: DatoolDateFormat,
514
649
  showRowSelectionColumn?: boolean,
515
650
  showRowActionButtonsColumn?: boolean,
516
651
  rowActionsColumnSize?: number
@@ -552,18 +687,29 @@ function buildColumns<TData extends DataTableRow>(
552
687
  return {
553
688
  accessorFn: column.accessorFn,
554
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),
555
698
  cell: ({ getValue, row }) =>
556
699
  column.cell
557
700
  ? column.cell({ row: row.original, value: getValue() })
558
701
  : fallbackCellValue(getValue(), kind, {
702
+ dateFormat,
559
703
  enumColors: kind === "enum" ? column.enumColors : undefined,
560
704
  enumOptions: kind === "enum" ? column.enumOptions : undefined,
561
705
  }),
562
706
  enableGlobalFilter: column.enableFiltering ?? true,
707
+ enableGrouping: column.enableGrouping ?? true,
563
708
  filterFn: column.filterFn,
564
709
  enableHiding: column.enableHiding ?? true,
565
710
  enableResizing: column.enableResizing ?? true,
566
711
  enableSorting: column.enableSorting ?? true,
712
+ getGroupingValue: column.getGroupingValue,
567
713
  header: column.header ?? formatHeaderLabel(id),
568
714
  id,
569
715
  maxSize: column.maxWidth ?? 420,
@@ -582,6 +728,7 @@ function buildColumns<TData extends DataTableRow>(
582
728
  ...builtColumns,
583
729
  {
584
730
  cell: () => null,
731
+ enableGrouping: false,
585
732
  enableGlobalFilter: false,
586
733
  enableHiding: false,
587
734
  enableResizing: false,
@@ -603,13 +750,16 @@ function buildColumns<TData extends DataTableRow>(
603
750
  {
604
751
  cell: ({ row }) => (
605
752
  <div className="flex items-center justify-center">
606
- <DataTableCheckbox
607
- ariaLabel={`Select row ${row.index + 1}`}
608
- checked={row.getIsSelected()}
609
- onCheckedChange={(checked) => row.toggleSelected(checked)}
610
- />
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}
611
760
  </div>
612
761
  ),
762
+ enableGrouping: false,
613
763
  enableGlobalFilter: false,
614
764
  enableHiding: false,
615
765
  enableResizing: false,
@@ -648,6 +798,7 @@ function buildColumns<TData extends DataTableRow>(
648
798
  ...withSelectionColumn,
649
799
  {
650
800
  cell: () => null,
801
+ enableGrouping: false,
651
802
  enableGlobalFilter: false,
652
803
  enableHiding: false,
653
804
  enableResizing: false,
@@ -703,6 +854,31 @@ function shouldIgnoreRowSelectionTarget(target: EventTarget | null) {
703
854
  )
704
855
  }
705
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
+
706
882
  function resolveRowActionRows<TData extends DataTableRow>(
707
883
  action: DataTableRowAction<TData>,
708
884
  row: Row<TData>,
@@ -1118,6 +1294,21 @@ function RowActionButtonGroup<TData extends DataTableRow>({
1118
1294
  )
1119
1295
  }
1120
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
+
1121
1312
  function DataTableView<TData extends DataTableRow>({
1122
1313
  autoScrollToBottom = false,
1123
1314
  autoScrollToBottomThreshold = 96,
@@ -1125,10 +1316,12 @@ function DataTableView<TData extends DataTableRow>({
1125
1316
  columnVisibility: controlledColumnVisibility,
1126
1317
  columns,
1127
1318
  data,
1319
+ dateFormat,
1128
1320
  edgeHorizontalPadding = "16px",
1129
1321
  enableRowSelection = false,
1130
1322
  filterPlaceholder = "Search across visible columns",
1131
1323
  globalFilter,
1324
+ grouping: controlledGrouping,
1132
1325
  getRowId,
1133
1326
  height = 620,
1134
1327
  highlightQuery = "",
@@ -1136,9 +1329,11 @@ function DataTableView<TData extends DataTableRow>({
1136
1329
  onColumnFiltersChange,
1137
1330
  onColumnVisibilityChange,
1138
1331
  onGlobalFilterChange,
1332
+ onGroupingChange,
1139
1333
  resolveColumnHighlightTerms,
1140
1334
  rowActions,
1141
1335
  rowClassName,
1336
+ rowStyle,
1142
1337
  rowHeight = 48,
1143
1338
  statePersistence = "localStorage",
1144
1339
  }: DataTableProps<TData>) {
@@ -1161,6 +1356,9 @@ function DataTableView<TData extends DataTableRow>({
1161
1356
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
1162
1357
  () => persistedState.columnFilters ?? []
1163
1358
  )
1359
+ const [grouping, setGrouping] = React.useState<GroupingState>(
1360
+ () => persistedState.grouping ?? []
1361
+ )
1164
1362
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
1165
1363
  const [rowActionStatuses, setRowActionStatuses] = React.useState<
1166
1364
  Record<string, RowActionStatus>
@@ -1183,9 +1381,11 @@ function DataTableView<TData extends DataTableRow>({
1183
1381
  const isColumnFiltersControlled = controlledColumnFilters !== undefined
1184
1382
  const isColumnVisibilityControlled = controlledColumnVisibility !== undefined
1185
1383
  const isGlobalFilterControlled = globalFilter !== undefined
1384
+ const isGroupingControlled = controlledGrouping !== undefined
1186
1385
  const resolvedColumnFilters = controlledColumnFilters ?? columnFilters
1187
1386
  const resolvedColumnVisibility =
1188
1387
  controlledColumnVisibility ?? columnVisibility
1388
+ const resolvedGrouping = controlledGrouping ?? grouping
1189
1389
  const hasSelectionActions = rowActions
1190
1390
  ? hasSelectionScopedAction(rowActions)
1191
1391
  : false
@@ -1232,6 +1432,7 @@ function DataTableView<TData extends DataTableRow>({
1232
1432
  buildColumns(
1233
1433
  data,
1234
1434
  columnsWithEnumOptions,
1435
+ dateFormat,
1235
1436
  showRowSelectionColumn,
1236
1437
  showRowActionButtonsColumn,
1237
1438
  rowActionsColumnSize
@@ -1239,6 +1440,7 @@ function DataTableView<TData extends DataTableRow>({
1239
1440
  [
1240
1441
  columnsWithEnumOptions,
1241
1442
  data,
1443
+ dateFormat,
1242
1444
  rowActionsColumnSize,
1243
1445
  showRowActionButtonsColumn,
1244
1446
  showRowSelectionColumn,
@@ -1312,28 +1514,52 @@ function DataTableView<TData extends DataTableRow>({
1312
1514
  resolvedColumnVisibility,
1313
1515
  ]
1314
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
+ )
1315
1533
 
1316
1534
  const table = useReactTable({
1317
1535
  columnResizeMode: "onChange",
1318
1536
  columns: tableColumns,
1319
1537
  data,
1320
1538
  enableColumnResizing: true,
1321
- enableRowSelection: canSelectRows,
1539
+ enableGrouping: true,
1540
+ enableRowSelection: canSelectRows ? (row) => !row.getIsGrouped() : false,
1541
+ enableSubRowSelection: false,
1322
1542
  getCoreRowModel: getCoreRowModel(),
1543
+ getExpandedRowModel: getExpandedRowModel(),
1323
1544
  getFilteredRowModel: getFilteredRowModel(),
1545
+ getGroupedRowModel: getGroupedRowModel(),
1324
1546
  getRowId,
1325
1547
  getSortedRowModel: getSortedRowModel(),
1548
+ groupedColumnMode: false,
1326
1549
  globalFilterFn: globalFilterFn as FilterFn<TData>,
1327
1550
  onColumnFiltersChange: handleColumnFiltersChange,
1328
1551
  onColumnSizingChange: handleColumnSizingChange,
1329
1552
  onColumnVisibilityChange: handleColumnVisibilityChange,
1553
+ onGroupingChange: handleGroupingChange,
1330
1554
  onRowSelectionChange: setRowSelection,
1331
1555
  onSortingChange: setSorting,
1332
1556
  state: {
1333
1557
  columnFilters: resolvedColumnFilters,
1334
1558
  columnSizing,
1335
1559
  columnVisibility: resolvedColumnVisibility,
1560
+ expanded: expandedState,
1336
1561
  globalFilter: deferredSearch.trim(),
1562
+ grouping: resolvedGrouping,
1337
1563
  rowSelection,
1338
1564
  sorting,
1339
1565
  },
@@ -1344,6 +1570,10 @@ function DataTableView<TData extends DataTableRow>({
1344
1570
  const hasAutoScrolledOnMountRef = React.useRef(false)
1345
1571
  const previousDataLengthRef = React.useRef(0)
1346
1572
  const rows = table.getRowModel().rows
1573
+ const selectableRows = React.useMemo(
1574
+ () => rows.filter((row) => row.getCanSelect()),
1575
+ [rows]
1576
+ )
1347
1577
  const selectedTableRows = table.getSelectedRowModel().rows
1348
1578
  const resolvedHeight = typeof height === "number" ? height : 0
1349
1579
  const initialOffsetRef = React.useRef(
@@ -1385,6 +1615,9 @@ function DataTableView<TData extends DataTableRow>({
1385
1615
  }
1386
1616
  setHighlightedColumns(nextState.highlightedColumns ?? {})
1387
1617
  setColumnFilters(nextState.columnFilters ?? [])
1618
+ if (!isGroupingControlled) {
1619
+ setGrouping(nextState.grouping ?? [])
1620
+ }
1388
1621
  setSearchDraft(nextState.globalFilter ?? "")
1389
1622
  setRowSelection({})
1390
1623
  dragSelectionRef.current = null
@@ -1396,7 +1629,13 @@ function DataTableView<TData extends DataTableRow>({
1396
1629
  }
1397
1630
  rowActionStatusTimersRef.current = {}
1398
1631
  setRowActionStatuses({})
1399
- }, [id, isColumnFiltersControlled, isColumnVisibilityControlled, statePersistence])
1632
+ }, [
1633
+ id,
1634
+ isColumnFiltersControlled,
1635
+ isColumnVisibilityControlled,
1636
+ isGroupingControlled,
1637
+ statePersistence,
1638
+ ])
1400
1639
 
1401
1640
  React.useEffect(() => {
1402
1641
  if (!isColumnFiltersControlled) {
@@ -1414,6 +1653,14 @@ function DataTableView<TData extends DataTableRow>({
1414
1653
  setColumnVisibility(controlledColumnVisibility)
1415
1654
  }, [controlledColumnVisibility, isColumnVisibilityControlled])
1416
1655
 
1656
+ React.useEffect(() => {
1657
+ if (!isGroupingControlled) {
1658
+ return
1659
+ }
1660
+
1661
+ setGrouping(controlledGrouping)
1662
+ }, [controlledGrouping, isGroupingControlled])
1663
+
1417
1664
  React.useEffect(() => {
1418
1665
  if (!isGlobalFilterControlled) {
1419
1666
  return
@@ -1437,6 +1684,9 @@ function DataTableView<TData extends DataTableRow>({
1437
1684
  }
1438
1685
  setHighlightedColumns(nextState.highlightedColumns ?? {})
1439
1686
  setColumnFilters(nextState.columnFilters ?? [])
1687
+ if (!isGroupingControlled) {
1688
+ setGrouping(nextState.grouping ?? [])
1689
+ }
1440
1690
  setSearchDraft(nextState.globalFilter ?? "")
1441
1691
  setRowSelection({})
1442
1692
  dragSelectionRef.current = null
@@ -1448,7 +1698,7 @@ function DataTableView<TData extends DataTableRow>({
1448
1698
  window.addEventListener("popstate", syncFromUrl)
1449
1699
 
1450
1700
  return () => window.removeEventListener("popstate", syncFromUrl)
1451
- }, [id, isColumnVisibilityControlled, statePersistence])
1701
+ }, [id, isColumnVisibilityControlled, isGroupingControlled, statePersistence])
1452
1702
 
1453
1703
  React.useEffect(() => {
1454
1704
  if (isGlobalFilterControlled || isColumnFiltersControlled) {
@@ -1461,6 +1711,7 @@ function DataTableView<TData extends DataTableRow>({
1461
1711
  highlightedColumns,
1462
1712
  columnVisibility: resolvedColumnVisibility,
1463
1713
  globalFilter: deferredSearch.trim(),
1714
+ grouping: resolvedGrouping,
1464
1715
  sorting,
1465
1716
  })
1466
1717
  }, [
@@ -1471,7 +1722,9 @@ function DataTableView<TData extends DataTableRow>({
1471
1722
  id,
1472
1723
  isColumnFiltersControlled,
1473
1724
  isGlobalFilterControlled,
1725
+ isGroupingControlled,
1474
1726
  resolvedColumnVisibility,
1727
+ resolvedGrouping,
1475
1728
  sorting,
1476
1729
  statePersistence,
1477
1730
  ])
@@ -1577,6 +1830,13 @@ function DataTableView<TData extends DataTableRow>({
1577
1830
 
1578
1831
  onGlobalFilterChange?.(value)
1579
1832
  }
1833
+ const clearRowSelection = React.useCallback(() => {
1834
+ setRowSelection({})
1835
+ dragSelectionRef.current = null
1836
+ dragPointerRef.current = null
1837
+ setIsDragSelecting(false)
1838
+ selectionAnchorIdRef.current = null
1839
+ }, [])
1580
1840
 
1581
1841
  const isColumnHighlightEnabled = React.useCallback(
1582
1842
  (columnId: string, meta: DataTableColumnMeta) =>
@@ -1589,10 +1849,10 @@ function DataTableView<TData extends DataTableRow>({
1589
1849
  rowId: string,
1590
1850
  baseSelection: RowSelectionState = {}
1591
1851
  ) => {
1592
- const anchorIndex = rows.findIndex(
1852
+ const anchorIndex = selectableRows.findIndex(
1593
1853
  (candidateRow) => candidateRow.id === anchorId
1594
1854
  )
1595
- const rowIndex = rows.findIndex(
1855
+ const rowIndex = selectableRows.findIndex(
1596
1856
  (candidateRow) => candidateRow.id === rowId
1597
1857
  )
1598
1858
 
@@ -1608,13 +1868,13 @@ function DataTableView<TData extends DataTableRow>({
1608
1868
  setRowSelection({
1609
1869
  ...baseSelection,
1610
1870
  ...Object.fromEntries(
1611
- rows
1871
+ selectableRows
1612
1872
  .slice(start, end + 1)
1613
1873
  .map((candidateRow) => [candidateRow.id, true])
1614
1874
  ),
1615
1875
  })
1616
1876
  },
1617
- [rows]
1877
+ [selectableRows]
1618
1878
  )
1619
1879
  const selectSingleRow = React.useCallback((rowId: string) => {
1620
1880
  selectionAnchorIdRef.current = rowId
@@ -1661,6 +1921,7 @@ function DataTableView<TData extends DataTableRow>({
1661
1921
  if (
1662
1922
  event.button !== 0 ||
1663
1923
  !canSelectRows ||
1924
+ !row.getCanSelect() ||
1664
1925
  shouldIgnoreRowSelectionTarget(event.target)
1665
1926
  ) {
1666
1927
  return
@@ -1722,6 +1983,7 @@ function DataTableView<TData extends DataTableRow>({
1722
1983
  (event: React.MouseEvent<HTMLTableRowElement>, row: Row<TData>) => {
1723
1984
  if (
1724
1985
  !canSelectRows ||
1986
+ !row.getCanSelect() ||
1725
1987
  event.buttons !== 1 ||
1726
1988
  shouldIgnoreRowSelectionTarget(event.target) ||
1727
1989
  !dragSelectionRef.current
@@ -1737,7 +1999,7 @@ function DataTableView<TData extends DataTableRow>({
1737
1999
  )
1738
2000
  const handleRowContextMenu = React.useCallback(
1739
2001
  (row: Row<TData>) => {
1740
- if (!canSelectRows) {
2002
+ if (!canSelectRows || !row.getCanSelect()) {
1741
2003
  return
1742
2004
  }
1743
2005
 
@@ -1817,6 +2079,27 @@ function DataTableView<TData extends DataTableRow>({
1817
2079
  window.cancelAnimationFrame(frameId)
1818
2080
  }
1819
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])
1820
2103
 
1821
2104
  return (
1822
2105
  <section
@@ -1894,99 +2177,224 @@ function DataTableView<TData extends DataTableRow>({
1894
2177
  ) : (
1895
2178
  virtualRows.map((virtualRow) => {
1896
2179
  const row = rows[virtualRow.index]
1897
- const visibleRowActions = resolveVisibleRowActions(
1898
- rowActions ?? [],
1899
- row,
1900
- selectedTableRows
1901
- )
2180
+ const isGroupRow = row.getIsGrouped()
2181
+ const visibleRowActions = isGroupRow
2182
+ ? []
2183
+ : resolveVisibleRowActions(
2184
+ rowActions ?? [],
2185
+ row,
2186
+ selectedTableRows
2187
+ )
1902
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
+ : []
1903
2231
  const rowContent = (
1904
2232
  <tr
1905
2233
  aria-selected={isSelected}
1906
2234
  className={cn(
1907
- "absolute left-0 flex w-full bg-card transition-colors",
1908
- canSelectRows && "cursor-pointer",
1909
- rowClassName?.(row.original),
1910
- 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 &&
1911
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-['']"
1912
2246
  )}
1913
2247
  data-index={virtualRow.index}
1914
2248
  data-row-id={row.id}
1915
- data-state={isSelected ? "selected" : undefined}
2249
+ data-state={!isGroupRow && isSelected ? "selected" : undefined}
1916
2250
  key={row.id}
1917
- onContextMenu={() => handleRowContextMenu(row)}
1918
- onMouseDown={(event) => handleRowMouseDown(event, row)}
1919
- 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
+ }
1920
2264
  ref={(node) => {
1921
2265
  if (node) {
1922
2266
  rowVirtualizer.measureElement(node)
1923
2267
  }
1924
2268
  }}
1925
2269
  style={{
1926
- minHeight: rowHeight,
2270
+ ...(!isGroupRow ? rowStyle?.(row.original) : undefined),
2271
+ minHeight: isGroupRow ? Math.max(rowHeight, 44) : rowHeight,
1927
2272
  transform: `translateY(${virtualRow.start}px)`,
1928
2273
  width: table.getTotalSize(),
1929
2274
  }}
1930
2275
  >
1931
- {row.getVisibleCells().map((cell, index, visibleCells) => {
1932
- const meta = (cell.column.columnDef.meta ??
1933
- {}) as DataTableColumnMeta
1934
- const isActionsCell = cell.column.id === "__actions"
1935
- const highlightTerms =
1936
- meta.kind === "text" &&
1937
- resolveColumnHighlightTerms &&
1938
- isColumnHighlightEnabled(cell.column.id, meta)
1939
- ? resolveColumnHighlightTerms(
1940
- cell.column.id,
1941
- highlightQuery
1942
- )
1943
- : []
1944
-
1945
- 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
+
1946
2380
  return (
1947
- <td
1948
- className={cn(
1949
- "flex shrink-0 border-b border-border px-2 py-1.5 align-middle text-sm text-foreground justify-end text-right",
1950
- meta.cellClassName
1951
- )}
2381
+ <DataTableBodyCell
2382
+ cell={cell}
2383
+ dateFormat={dateFormat}
2384
+ highlightTerms={highlightTerms}
1952
2385
  key={cell.id}
1953
- style={{
1954
- paddingLeft:
1955
- index === 0 ? edgeHorizontalPadding : undefined,
1956
- paddingRight:
1957
- index === visibleCells.length - 1
1958
- ? edgeHorizontalPadding
1959
- : undefined,
1960
- width: cell.column.getSize(),
1961
- }}
1962
- >
1963
- <div className="w-full min-w-0">
1964
- <RowActionButtonGroup
1965
- resolvedActions={visibleRowActions}
1966
- setStatus={setRowActionStatus}
1967
- statuses={rowActionStatuses}
1968
- />
1969
- </div>
1970
- </td>
2386
+ paddingLeft={
2387
+ index === 0 ? edgeHorizontalPadding : undefined
2388
+ }
2389
+ paddingRight={
2390
+ index === visibleCells.length - 1
2391
+ ? edgeHorizontalPadding
2392
+ : undefined
2393
+ }
2394
+ />
1971
2395
  )
1972
- }
1973
-
1974
- return (
1975
- <DataTableBodyCell
1976
- cell={cell}
1977
- highlightTerms={highlightTerms}
1978
- key={cell.id}
1979
- paddingLeft={
1980
- index === 0 ? edgeHorizontalPadding : undefined
1981
- }
1982
- paddingRight={
1983
- index === visibleCells.length - 1
1984
- ? edgeHorizontalPadding
1985
- : undefined
1986
- }
1987
- />
1988
- )
1989
- })}
2396
+ })
2397
+ )}
1990
2398
  </tr>
1991
2399
  )
1992
2400