basalt-ui 1.30.2 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +54 -0
- package/MIGRATING.md +256 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +5 -5
- package/configs/oxlint-plugin.js +371 -4
- package/configs/oxlint-plugin.test.ts +327 -2
- package/configs/oxlint.json +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +41 -43
- package/dist/agent-chat/thread-workspace.d.ts +2 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +3 -4
- package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
- package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
- package/dist/agent-chat/use-keyboard-inset.js +34 -0
- package/dist/charts/cursor/touch-pin.d.ts +45 -0
- package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
- package/dist/charts/cursor/touch-pin.js +79 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
- package/dist/charts/hooks/use-discrete-cursor.js +198 -0
- package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
- package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
- package/dist/charts/hooks/use-hysteresis.js +12 -0
- package/dist/charts/hooks/useBandPlot.d.ts +7 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
- package/dist/charts/hooks/useBandPlot.js +62 -18
- package/dist/charts/hooks/useChartCursor.d.ts +40 -11
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +102 -11
- package/dist/charts/index.d.ts +0 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +0 -10
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +47 -82
- package/dist/charts/kinds/Donut.d.ts +9 -3
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +110 -48
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +138 -145
- package/dist/charts/kinds/Heatmap.d.ts +7 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +37 -16
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +68 -111
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/kinds/donut-layout.d.ts +47 -0
- package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
- package/dist/charts/kinds/donut-layout.js +52 -0
- package/dist/charts/layout/auto-margin.d.ts +7 -2
- package/dist/charts/layout/auto-margin.d.ts.map +1 -1
- package/dist/charts/layout/auto-margin.js +12 -3
- package/dist/charts/layout/end-labels.d.ts +64 -0
- package/dist/charts/layout/end-labels.d.ts.map +1 -0
- package/dist/charts/layout/end-labels.js +76 -0
- package/dist/charts/primitives/Axes.d.ts +32 -4
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +62 -16
- package/dist/charts/primitives/CartesianChart.d.ts +7 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +264 -145
- package/dist/charts/primitives/ChartCard.d.ts +0 -14
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +56 -21
- package/dist/charts/primitives/ChartFrame.d.ts +28 -22
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +116 -34
- package/dist/charts/primitives/ChartLegend.d.ts +19 -15
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +86 -73
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +20 -4
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +54 -27
- package/dist/charts/primitives/HoverOverlay.module.css +13 -0
- package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/dist/charts/primitives/chart-card-context.d.ts +33 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +18 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +75 -34
- package/dist/charts/primitives/chart-layout.d.ts +112 -0
- package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
- package/dist/charts/primitives/chart-layout.js +175 -0
- package/dist/charts/primitives/chart-tier.d.ts +13 -11
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +42 -13
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/charts/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/ticks.d.ts +27 -8
- package/dist/charts/utils/ticks.d.ts.map +1 -1
- package/dist/charts/utils/ticks.js +35 -11
- package/dist/common/errors.d.ts +11 -1
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +16 -2
- package/dist/common/hit-floor.d.ts +10 -0
- package/dist/common/hit-floor.d.ts.map +1 -0
- package/dist/common/hit-floor.js +6 -0
- package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
- package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/common/use-measured-widths.d.ts +37 -0
- package/dist/common/use-measured-widths.d.ts.map +1 -0
- package/dist/common/use-measured-widths.js +51 -0
- package/dist/common/use-media-query.d.ts +6 -0
- package/dist/common/use-media-query.d.ts.map +1 -0
- package/dist/common/use-media-query.js +40 -0
- package/dist/common/validate.d.ts +9 -0
- package/dist/common/validate.d.ts.map +1 -1
- package/dist/common/validate.js +9 -4
- package/dist/content/article-layout.d.ts +4 -2
- package/dist/content/article-layout.d.ts.map +1 -1
- package/dist/content/article-layout.js +43 -3
- package/dist/content/article-layout.module.css +45 -4
- package/dist/content/article-layout.module.css.d.ts +3 -0
- package/dist/content/code-block.d.ts.map +1 -1
- package/dist/content/code-block.js +40 -2
- package/dist/content/code-block.module.css +32 -2
- package/dist/content/prose.module.css +13 -8
- package/dist/controls/actions.d.ts +29 -6
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +158 -27
- package/dist/controls/actions.module.css +14 -1
- package/dist/controls/actions.module.css.d.ts +1 -0
- package/dist/controls/controls.module.css +13 -31
- package/dist/controls/enum-filter.js +2 -2
- package/dist/controls/filter-context.d.ts +10 -0
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/filter-context.js +2 -0
- package/dist/controls/filter-pill.d.ts.map +1 -1
- package/dist/controls/filter-pill.js +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +0 -2
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/sync-button.module.css +2 -1
- package/dist/dashboard/section.d.ts.map +1 -1
- package/dist/dashboard/section.js +1 -0
- package/dist/dashboard/section.module.css +1 -26
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +4 -1
- package/dist/dashboard/stat-card.module.css +6 -9
- package/dist/dashboard/stat-group.module.css +33 -66
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/dashboard/widget-header.d.ts +14 -4
- package/dist/dashboard/widget-header.d.ts.map +1 -1
- package/dist/dashboard/widget-header.js +23 -6
- package/dist/dashboard/widget-header.module.css +145 -133
- package/dist/dashboard/widget-header.module.css.d.ts +2 -0
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +56 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +436 -336
- package/dist/data/data-table.module.css +94 -0
- package/dist/data/data-table.module.css.d.ts +11 -0
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual-list.js +10 -0
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +3 -3
- package/dist/forms/form-layout.module.css +20 -5
- package/dist/forms/form-layout.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +8 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +68 -2
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +30 -8
- package/dist/shell/app-brand.d.ts.map +1 -1
- package/dist/shell/app-brand.js +1 -0
- package/dist/shell/app-brand.module.css +2 -2
- package/dist/shell/app-header.module.css +18 -8
- package/dist/shell/app-main.module.css +54 -3
- package/dist/shell/app-main.module.css.d.ts +1 -0
- package/dist/shell/app-mobile-nav.module.css +16 -4
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +1 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +56 -16
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/page-aside.d.ts +22 -3
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +27 -13
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +10 -5
- package/dist/shell/page-bar.module.css +10 -34
- package/dist/shell/use-breakpoint.d.ts +3 -4
- package/dist/shell/use-breakpoint.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.js +3 -25
- package/dist/shell/use-size-class.d.ts +6 -0
- package/dist/shell/use-size-class.d.ts.map +1 -0
- package/dist/shell/use-size-class.js +19 -0
- package/dist/state/persisted-or-local.d.ts +4 -1
- package/dist/state/persisted-or-local.d.ts.map +1 -1
- package/dist/state/persisted-or-local.js +4 -3
- package/dist/state/persisted.d.ts +10 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +44 -14
- package/dist/styles.css +41 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +11 -3
- package/dist/theme/controls.module.css +7 -0
- package/dist/theme/ctl-theme.d.ts +1 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -1
- package/dist/theme/ctl-theme.js +12 -4
- package/dist/theme/floating.module.css +37 -0
- package/dist/theme/floating.module.css.d.ts +3 -0
- package/dist/theme/index.d.ts +2 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +34 -5
- package/dist/theme/nav-link.module.css +7 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +7 -16
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +23 -28
- package/dist/tokens/palette.d.ts +5 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +8 -30
- package/dist/tokens/size-classes.d.ts +40 -0
- package/dist/tokens/size-classes.d.ts.map +1 -0
- package/dist/tokens/size-classes.js +29 -0
- package/dist/tokens.css +8 -4
- package/llms.txt +3 -3
- package/package.json +18 -17
- package/src/agent-chat/composer.tsx +74 -47
- package/src/agent-chat/thread-workspace.tsx +8 -11
- package/src/agent-chat/use-keyboard-inset.ts +68 -0
- package/src/charts/cursor/touch-pin.ts +138 -0
- package/src/charts/hooks/use-discrete-cursor.ts +335 -0
- package/src/charts/hooks/use-hysteresis.ts +26 -0
- package/src/charts/hooks/useBandPlot.ts +90 -27
- package/src/charts/hooks/useChartCursor.ts +211 -26
- package/src/charts/index.ts +0 -8
- package/src/charts/kinds/BandStrip.tsx +25 -61
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +21 -26
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +23 -65
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/kinds/donut-layout.ts +82 -0
- package/src/charts/layout/auto-margin.ts +24 -5
- package/src/charts/layout/end-labels.ts +135 -0
- package/src/charts/primitives/Axes.tsx +112 -21
- package/src/charts/primitives/CartesianChart.tsx +282 -111
- package/src/charts/primitives/ChartCard.tsx +119 -48
- package/src/charts/primitives/ChartFrame.tsx +204 -60
- package/src/charts/primitives/ChartLegend.tsx +115 -78
- package/src/charts/primitives/ChartTooltip.tsx +24 -3
- package/src/charts/primitives/Crosshair.tsx +2 -2
- package/src/charts/primitives/HoverOverlay.module.css +13 -0
- package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/src/charts/primitives/HoverOverlay.tsx +62 -20
- package/src/charts/primitives/chart-card-context.ts +46 -0
- package/src/charts/primitives/chart-frame-layout.ts +207 -182
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/charts/series.ts +4 -1
- package/src/charts/utils/ticks.ts +95 -19
- package/src/common/errors.ts +25 -1
- package/src/common/hit-floor.ts +9 -0
- package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
- package/src/common/use-measured-widths.ts +88 -0
- package/src/common/use-media-query.ts +56 -0
- package/src/common/validate.ts +17 -4
- package/src/content/article-layout.module.css +45 -4
- package/src/content/article-layout.module.css.d.ts +3 -0
- package/src/content/article-layout.tsx +60 -5
- package/src/content/code-block.module.css +32 -2
- package/src/content/code-block.tsx +47 -3
- package/src/content/prose.module.css +13 -8
- package/src/controls/actions.module.css +14 -1
- package/src/controls/actions.module.css.d.ts +1 -0
- package/src/controls/actions.tsx +223 -75
- package/src/controls/controls.module.css +13 -31
- package/src/controls/enum-filter.tsx +2 -2
- package/src/controls/filter-context.tsx +11 -0
- package/src/controls/filter-pill.tsx +1 -0
- package/src/controls/index.ts +0 -1
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/sync-button.module.css +2 -1
- package/src/dashboard/section.module.css +1 -26
- package/src/dashboard/section.tsx +1 -0
- package/src/dashboard/stat-card.module.css +6 -9
- package/src/dashboard/stat-card.tsx +6 -3
- package/src/dashboard/stat-group.module.css +33 -66
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/dashboard/widget-grid.tsx +7 -6
- package/src/dashboard/widget-header.module.css +145 -133
- package/src/dashboard/widget-header.module.css.d.ts +2 -0
- package/src/dashboard/widget-header.tsx +45 -11
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +547 -299
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +20 -5
- package/src/forms/form-layout.module.css.d.ts +1 -0
- package/src/forms/form-layout.tsx +12 -10
- package/src/guard/index.ts +188 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +60 -7
- package/src/shell/app-brand.module.css +2 -2
- package/src/shell/app-brand.tsx +1 -0
- package/src/shell/app-header.module.css +18 -8
- package/src/shell/app-main.module.css +54 -3
- package/src/shell/app-main.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.module.css +16 -4
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +178 -129
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +70 -21
- package/src/shell/page-bar.module.css +10 -34
- package/src/shell/page-bar.tsx +10 -5
- package/src/shell/use-breakpoint.ts +9 -45
- package/src/shell/use-size-class.ts +31 -0
- package/src/state/persisted-or-local.ts +10 -4
- package/src/state/persisted.ts +76 -17
- package/src/styles.css +41 -0
- package/src/surfaces.ts +9 -1
- package/src/theme/controls.module.css +7 -0
- package/src/theme/ctl-theme.tsx +15 -3
- package/src/theme/floating.module.css +37 -0
- package/src/theme/floating.module.css.d.ts +3 -0
- package/src/theme/index.ts +46 -8
- package/src/theme/nav-link.module.css +7 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
- package/src/tokens/index.ts +20 -30
- package/src/tokens/palette.ts +6 -28
- package/src/tokens/size-classes.ts +46 -0
- package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
- /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
package/src/data/data-table.tsx
CHANGED
|
@@ -37,8 +37,10 @@ import {
|
|
|
37
37
|
Checkbox,
|
|
38
38
|
Group,
|
|
39
39
|
Pagination,
|
|
40
|
+
ScrollArea,
|
|
40
41
|
Select,
|
|
41
42
|
Skeleton,
|
|
43
|
+
Stack,
|
|
42
44
|
Table,
|
|
43
45
|
Text,
|
|
44
46
|
TextInput,
|
|
@@ -48,14 +50,14 @@ import type {
|
|
|
48
50
|
Column,
|
|
49
51
|
ColumnDef,
|
|
50
52
|
ColumnFiltersState,
|
|
51
|
-
ColumnPinningState,
|
|
52
53
|
FilterFn,
|
|
53
54
|
PaginationState,
|
|
55
|
+
Row,
|
|
54
56
|
RowData,
|
|
55
57
|
RowSelectionState,
|
|
56
58
|
SortingState,
|
|
57
59
|
Table as TanstackTable,
|
|
58
|
-
|
|
60
|
+
VisibilityState,
|
|
59
61
|
} from '@tanstack/react-table'
|
|
60
62
|
import {
|
|
61
63
|
flexRender,
|
|
@@ -65,16 +67,13 @@ import {
|
|
|
65
67
|
getSortedRowModel,
|
|
66
68
|
useReactTable,
|
|
67
69
|
} from '@tanstack/react-table'
|
|
68
|
-
import type {
|
|
69
|
-
|
|
70
|
-
KeyboardEvent as ReactKeyboardEvent,
|
|
71
|
-
ReactNode,
|
|
72
|
-
RefObject,
|
|
73
|
-
} from 'react'
|
|
74
|
-
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
70
|
+
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from 'react'
|
|
71
|
+
import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
75
72
|
import { cx } from '../common/props'
|
|
76
73
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
77
74
|
import { BASALT_PREFIX } from '../common/errors'
|
|
75
|
+
import { HIT_COARSE } from '../common/hit-floor'
|
|
76
|
+
import { useMeasuredWidths } from '../common/use-measured-widths'
|
|
78
77
|
import { assertRequiredProps, useValidateProps } from '../common/validate'
|
|
79
78
|
import { FilterSet } from '../controls'
|
|
80
79
|
import { ActionGroup, BarActionSlot } from '../controls/actions'
|
|
@@ -91,6 +90,13 @@ import type { EnumField, FieldHandle, MultiField } from '../state'
|
|
|
91
90
|
import { WidgetHeader } from '../dashboard'
|
|
92
91
|
import { ErrorState } from '../dashboard/query-state'
|
|
93
92
|
import type { QueryStateLike } from '../dashboard/query-state'
|
|
93
|
+
import {
|
|
94
|
+
activationProps,
|
|
95
|
+
EMPTY_FOLD_SET,
|
|
96
|
+
useCardProjection,
|
|
97
|
+
useDataTableState,
|
|
98
|
+
useRowDisclosure,
|
|
99
|
+
} from './data-table-state'
|
|
94
100
|
import { dataQueryBranch } from './query-branch'
|
|
95
101
|
import { isDev } from '../common/is-dev'
|
|
96
102
|
import classes from './data-table.module.css'
|
|
@@ -121,6 +127,14 @@ declare module '@tanstack/react-table' {
|
|
|
121
127
|
* `<td>`'s mono font then overrides what the cell asked for.
|
|
122
128
|
*/
|
|
123
129
|
numeral?: boolean
|
|
130
|
+
/**
|
|
131
|
+
* Fold priority for the measured column fold (`docs/CONTROLS-SPEC.md` §2): once the
|
|
132
|
+
* table no longer fits its wrapper, the HIGHEST-numbered priority column folds first, moving
|
|
133
|
+
* into a per-row disclosure instead of forcing the table into horizontal scroll. Unset columns
|
|
134
|
+
* fall back to their declared position, so a bare `<BasaltDataTable data columns />` folds its
|
|
135
|
+
* LAST column first — nothing regresses with no `meta.priority` set anywhere. See `foldOrder`.
|
|
136
|
+
*/
|
|
137
|
+
priority?: number
|
|
124
138
|
}
|
|
125
139
|
}
|
|
126
140
|
|
|
@@ -315,32 +329,37 @@ const SELECT_COLUMN_ID = '__basalt_select__'
|
|
|
315
329
|
* Built per render inside a `useMemo`, not hoisted as a constant: `ColumnDef` is generic in `T`, and
|
|
316
330
|
* a single shared instance would have to be cast at every call site.
|
|
317
331
|
*
|
|
318
|
-
*
|
|
319
|
-
* row carrying `onRowActivate` opens a detail from a click — so without
|
|
320
|
-
* navigated away from the table the tick was meant to act on.
|
|
332
|
+
* The select cell's `stopPropagation` (see the `<Table.Td>`) is the load-bearing line. The checkbox
|
|
333
|
+
* lives inside the row, and a row carrying `onRowActivate` opens a detail from a click — so without
|
|
334
|
+
* it, ticking the box also navigated away from the table the tick was meant to act on.
|
|
321
335
|
*/
|
|
322
336
|
function selectionColumn<T>(): ColumnDef<T, unknown> {
|
|
323
337
|
return {
|
|
324
338
|
id: SELECT_COLUMN_ID,
|
|
325
339
|
enableSorting: false,
|
|
326
340
|
header: ({ table }) => (
|
|
327
|
-
<Checkbox
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
341
|
+
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
|
|
342
|
+
<label className={classes.selectHit}>
|
|
343
|
+
<Checkbox
|
|
344
|
+
size="xs"
|
|
345
|
+
aria-label="Select all rows"
|
|
346
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
347
|
+
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
|
348
|
+
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
349
|
+
/>
|
|
350
|
+
</label>
|
|
334
351
|
),
|
|
335
352
|
cell: ({ row }) => (
|
|
336
|
-
<Checkbox
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
353
|
+
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
|
|
354
|
+
<label className={classes.selectHit}>
|
|
355
|
+
<Checkbox
|
|
356
|
+
size="xs"
|
|
357
|
+
aria-label={`Select row ${row.index + 1}`}
|
|
358
|
+
checked={row.getIsSelected()}
|
|
359
|
+
disabled={!row.getCanSelect()}
|
|
360
|
+
onChange={row.getToggleSelectedHandler()}
|
|
361
|
+
/>
|
|
362
|
+
</label>
|
|
344
363
|
),
|
|
345
364
|
}
|
|
346
365
|
}
|
|
@@ -370,8 +389,9 @@ function BulkActionBar({ count, actions }: { count: number; actions: BarAction[]
|
|
|
370
389
|
|
|
371
390
|
/**
|
|
372
391
|
* The four boxes `BasaltDataTable` paints: `root` is the outer fragment's replacement wrapper,
|
|
373
|
-
* `toolbar` is the search/facets/actions row, `table` is
|
|
374
|
-
* the pagination
|
|
392
|
+
* `toolbar` is the search/facets/actions row, `table` is whichever body renders — the Mantine
|
|
393
|
+
* `Table`, or the card list's `Stack` while `renderCard` projects — and `footer` is the pagination
|
|
394
|
+
* bar.
|
|
375
395
|
*/
|
|
376
396
|
export type BasaltDataTableSlot = 'root' | 'toolbar' | 'table' | 'footer'
|
|
377
397
|
|
|
@@ -577,6 +597,37 @@ export type BasaltDataTableProps<T> = BasaltProps &
|
|
|
577
597
|
* <BasaltDataTable data={rows} columns={columns} onRowActivate={(row) => setSelected(row)} />
|
|
578
598
|
*/
|
|
579
599
|
onRowActivate?: (row: T) => void
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A compact card projection of one row, rendered INSTEAD of the table while the table's own box
|
|
603
|
+
* is narrower than the `regular` container class (`CONTAINER_CLASSES`, 480px) — keyed on the
|
|
604
|
+
* width this table actually has, never the viewport, so a table in a narrow aside or a split
|
|
605
|
+
* column swaps on a desktop too, and a full-width one does not swap early on a tablet.
|
|
606
|
+
*
|
|
607
|
+
* The cards are the SAME processed rows the table would draw — sorted, filtered and paged — so a
|
|
608
|
+
* list can never disagree with its table; a fixed card order is `initialSorting`, not a second
|
|
609
|
+
* sort. Everything around the body stays: the header (title · count · toolbar), the bulk bar,
|
|
610
|
+
* the pager, and `query` / `isLoading` / `emptyState`. `onRowActivate` makes each card
|
|
611
|
+
* activatable exactly as it does a row (click, or Enter on the focused card). The selection
|
|
612
|
+
* checkbox is not projected — a card list with a selection keeps its bulk bar but no ticks.
|
|
613
|
+
*
|
|
614
|
+
* An activatable card takes `tabIndex=0` + `data-activatable`, never `role="button"`: a card is
|
|
615
|
+
* where a row's actions and links land (argo's all do), and a `button` role makes every
|
|
616
|
+
* descendant presentational, so those nested controls would vanish from the accessibility tree.
|
|
617
|
+
* The affordance is the cursor and the focus ring, as on the row. `maxHeight` caps the card list
|
|
618
|
+
* in a `ScrollArea`; `minWidth` does not apply — it is a horizontal floor for columns, and a
|
|
619
|
+
* card list has none.
|
|
620
|
+
*
|
|
621
|
+
* Reach for it when the row carries more than the columns a phone can hold (a status + label +
|
|
622
|
+
* age header, a clamped title, a prose line). A table that only needs to shed a column or two
|
|
623
|
+
* already has that: the column fold hides the lowest-priority columns behind a per-row
|
|
624
|
+
* disclosure with no prop at all. Server render and first paint are the table.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* <BasaltDataTable data={items} columns={columns} onRowActivate={open}
|
|
628
|
+
* renderCard={(item) => <ItemCard item={item} />} />
|
|
629
|
+
*/
|
|
630
|
+
renderCard?: (row: T) => ReactNode
|
|
580
631
|
/**
|
|
581
632
|
* Prepends a checkbox column (header = select-all on the page) and arms TanStack's row-selection
|
|
582
633
|
* feature. Selection is uncontrolled unless `rowSelection` is passed.
|
|
@@ -777,6 +828,8 @@ function SortIndicator({ direction }: { direction: 'asc' | 'desc' | false }) {
|
|
|
777
828
|
)
|
|
778
829
|
}
|
|
779
830
|
|
|
831
|
+
const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const
|
|
832
|
+
|
|
780
833
|
// ── Numeral cell idiom (docs/DESIGN-SPEC.md §3: "Stat/table numeral: mono, 12-12.5px, weight
|
|
781
834
|
// 500, ink") — auto-detected from the cell's raw value (no column-level opt-in prop needed) so
|
|
782
835
|
// every numeric column gets tabular, mono figures with zero call-site configuration.
|
|
@@ -884,6 +937,215 @@ function getPinnedCellStyle<T>(
|
|
|
884
937
|
}
|
|
885
938
|
}
|
|
886
939
|
|
|
940
|
+
// ── Column fold (`meta.priority`, docs/CONTROLS-SPEC.md §2) ──────────────────
|
|
941
|
+
|
|
942
|
+
/** The column id TanStack will resolve internally for this raw def — explicit `id`, else the
|
|
943
|
+
* accessor key string. Mirrors the resolution `facetColumns` already relies on above. */
|
|
944
|
+
function columnDefId<T>(column: ColumnDef<T, unknown>): string | undefined {
|
|
945
|
+
if (column.id !== undefined) return column.id
|
|
946
|
+
return 'accessorKey' in column && typeof column.accessorKey === 'string'
|
|
947
|
+
? column.accessorKey
|
|
948
|
+
: undefined
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Fold order for `meta.priority` — the FIRST id in the returned list folds first. Effective
|
|
953
|
+
* priority is `meta.priority ?? declaredIndex`, so with no `meta.priority` set anywhere it is just
|
|
954
|
+
* the column's position: the LAST declared column folds first, the zero-config default a bare
|
|
955
|
+
* `<BasaltDataTable data columns />` needs. An explicit priority folds a HIGHER number before a
|
|
956
|
+
* lower one; a tie folds in reverse declared order. A column with no resolvable id (an
|
|
957
|
+
* `accessorFn` with no explicit `id`) is never a fold candidate — nothing downstream could
|
|
958
|
+
* reliably re-identify it once hidden.
|
|
959
|
+
*/
|
|
960
|
+
function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
|
|
961
|
+
return columns
|
|
962
|
+
.map((column, index) => ({
|
|
963
|
+
id: columnDefId(column),
|
|
964
|
+
priority: column.meta?.priority ?? index,
|
|
965
|
+
index,
|
|
966
|
+
}))
|
|
967
|
+
.filter((candidate): candidate is { id: string; priority: number; index: number } =>
|
|
968
|
+
Boolean(candidate.id),
|
|
969
|
+
)
|
|
970
|
+
.toSorted((a, b) => b.priority - a.priority || b.index - a.index)
|
|
971
|
+
.map((candidate) => candidate.id)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Marks a rendered data column header for `useColumnFold`'s width measurement — never read past
|
|
975
|
+
* that hook, so it carries no styling and needs no CSS-module counterpart. */
|
|
976
|
+
const FOLD_ID_ATTR = 'data-basalt-fold-id'
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
|
|
980
|
+
* still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
|
|
981
|
+
* or `enableHiding: false` — which occupy room but can never fold) and the disclosure toggle's own
|
|
982
|
+
* width, once anything is folded — fit `room`. A candidate with no measured width yet (never
|
|
983
|
+
* rendered) is skipped rather than folded blind, since folding it would remove the one place its
|
|
984
|
+
* width could ever be read from.
|
|
985
|
+
*/
|
|
986
|
+
export function planColumnFold(input: {
|
|
987
|
+
room: number
|
|
988
|
+
toggleWidth: number
|
|
989
|
+
overhead: number
|
|
990
|
+
order: readonly string[]
|
|
991
|
+
widths: ReadonlyMap<string, number>
|
|
992
|
+
}): ReadonlySet<string> {
|
|
993
|
+
const { room, toggleWidth, overhead, order, widths } = input
|
|
994
|
+
const known = order.filter((id) => widths.has(id))
|
|
995
|
+
let unfolded = overhead + known.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
|
|
996
|
+
const folded = new Set<string>()
|
|
997
|
+
for (const id of known) {
|
|
998
|
+
// Never fold the last remaining data column — a table with every column folded has no row
|
|
999
|
+
// identifier left to read at all, which is worse than the horizontal scroll this exists to
|
|
1000
|
+
// avoid. With no explicit `meta.priority` this is `order`'s LAST id, which `foldOrder`'s own
|
|
1001
|
+
// doc pins as the FIRST declared column (the last-declared one folds first).
|
|
1002
|
+
if (folded.size >= known.length - 1) break
|
|
1003
|
+
const withToggle = unfolded + (folded.size > 0 ? toggleWidth : 0)
|
|
1004
|
+
if (withToggle <= room) break
|
|
1005
|
+
folded.add(id)
|
|
1006
|
+
unfolded -= widths.get(id) ?? 0
|
|
1007
|
+
}
|
|
1008
|
+
return folded
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* The general-case measured fold. Reacting to `useMeasuredContainment`'s boolean fit directly
|
|
1013
|
+
* (fold one, remeasure, maybe unfold) would oscillate, because folding changes the very box being
|
|
1014
|
+
* measured. Instead this reads each candidate `<th data-basalt-fold-id>`'s width once, while it is
|
|
1015
|
+
* visible, caches it, and decides analytically (`planColumnFold`). `useMeasuredWidths` owns the
|
|
1016
|
+
* cache and the wrapper-vs-table observer that `controls/actions.tsx`'s fold shares.
|
|
1017
|
+
*/
|
|
1018
|
+
function useColumnFold(input: {
|
|
1019
|
+
active: boolean
|
|
1020
|
+
order: readonly string[]
|
|
1021
|
+
/**
|
|
1022
|
+
* Columns excluded from fold candidacy altogether (pinned, or `enableHiding: false`) — they are
|
|
1023
|
+
* never in `order`, but they still occupy room, and their measured widths (carried in the same
|
|
1024
|
+
* `[data-basalt-fold-id]` sweep as every eligible column's) become fixed overhead against the
|
|
1025
|
+
* fold baseline. See `planColumnFold`'s `overhead` param.
|
|
1026
|
+
*/
|
|
1027
|
+
fixedIds: readonly string[]
|
|
1028
|
+
}): {
|
|
1029
|
+
wrapperRef: RefObject<HTMLDivElement | null>
|
|
1030
|
+
folded: ReadonlySet<string>
|
|
1031
|
+
} {
|
|
1032
|
+
const { active, order, fixedIds } = input
|
|
1033
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
1034
|
+
const [folded, setFolded] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
|
|
1035
|
+
const orderRef = useRef(order)
|
|
1036
|
+
orderRef.current = order
|
|
1037
|
+
const fixedIdsRef = useRef(fixedIds)
|
|
1038
|
+
fixedIdsRef.current = fixedIds
|
|
1039
|
+
useLayoutEffect(() => {
|
|
1040
|
+
if (!active || order.length === 0) {
|
|
1041
|
+
setFolded((current) => (current.size === 0 ? current : EMPTY_FOLD_SET))
|
|
1042
|
+
}
|
|
1043
|
+
}, [active, order.length])
|
|
1044
|
+
|
|
1045
|
+
useMeasuredWidths({
|
|
1046
|
+
resolveRoot: () => wrapperRef.current,
|
|
1047
|
+
signature: `${order.join('|')}::${fixedIds.join('|')}`,
|
|
1048
|
+
enabled: active && order.length > 0,
|
|
1049
|
+
// The table too, not only the wrapper: a re-measured column set moves its min-content width
|
|
1050
|
+
// while the wrapper's own box never changes.
|
|
1051
|
+
extra: (root) => [root.querySelector('table')],
|
|
1052
|
+
onMeasure: (root, boxes) => {
|
|
1053
|
+
for (const cell of root.querySelectorAll<HTMLElement>(`[${FOLD_ID_ATTR}]`)) {
|
|
1054
|
+
const id = cell.getAttribute(FOLD_ID_ATTR)
|
|
1055
|
+
if (id !== null && cell.offsetWidth > 0) {
|
|
1056
|
+
boxes.current.set(id, { width: cell.offsetWidth, height: cell.offsetHeight })
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const room = root.clientWidth
|
|
1060
|
+
// A zero reading is an un-laid-out ancestor, not a table with no room — the same guard
|
|
1061
|
+
// `useMeasuredContainment` above runs against the aside animating in from 0.
|
|
1062
|
+
if (room === 0) return
|
|
1063
|
+
const widths = new Map([...boxes.current].map(([id, box]) => [id, box.width]))
|
|
1064
|
+
const overhead = fixedIdsRef.current.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
|
|
1065
|
+
const next = planColumnFold({
|
|
1066
|
+
room,
|
|
1067
|
+
// The disclosure toggle is a fixed icon-button cell matched to the coarse hit floor, so it
|
|
1068
|
+
// reads as one more `--vx-hit` control rather than a narrower afterthought.
|
|
1069
|
+
toggleWidth: HIT_COARSE,
|
|
1070
|
+
overhead,
|
|
1071
|
+
order: orderRef.current,
|
|
1072
|
+
widths,
|
|
1073
|
+
})
|
|
1074
|
+
setFolded((current) =>
|
|
1075
|
+
current.size === next.size && [...current].every((id) => next.has(id)) ? current : next,
|
|
1076
|
+
)
|
|
1077
|
+
},
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
return { wrapperRef, folded: active ? folded : EMPTY_FOLD_SET }
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** The label half of a folded column's disclosure row — its own header when it is a plain string
|
|
1084
|
+
* (the common case), else the column id, since a function/`ReactNode` header has no header
|
|
1085
|
+
* CONTEXT to render with here (this is a body row, not the `<thead>`). */
|
|
1086
|
+
function columnLabel<T>(column: Column<T, unknown>): string {
|
|
1087
|
+
const header = column.columnDef.header
|
|
1088
|
+
return typeof header === 'string' ? header : column.id
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** One row's folded columns as label/value pairs, directly under it. `row.getAllCells()` returns
|
|
1092
|
+
* every leaf cell regardless of visibility (unlike `getVisibleCells()`), so a folded column's own
|
|
1093
|
+
* `cell` renderer still draws here exactly as it would in the `<td>` it no longer occupies. */
|
|
1094
|
+
function RowDisclosure<T>({
|
|
1095
|
+
row,
|
|
1096
|
+
folded,
|
|
1097
|
+
}: {
|
|
1098
|
+
row: Row<T>
|
|
1099
|
+
folded: ReadonlySet<string>
|
|
1100
|
+
}): ReactNode {
|
|
1101
|
+
const cells = row.getAllCells().filter((cell) => folded.has(cell.column.id))
|
|
1102
|
+
return (
|
|
1103
|
+
<div className={classes.foldDisclosure}>
|
|
1104
|
+
{cells.map((cell) => (
|
|
1105
|
+
<div key={cell.id} className={classes.foldDisclosureRow}>
|
|
1106
|
+
<Text className={classes.foldDisclosureLabel}>{columnLabel(cell.column)}</Text>
|
|
1107
|
+
<div className={classes.foldDisclosureValue}>
|
|
1108
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
))}
|
|
1112
|
+
</div>
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The per-row expand/collapse control the fold-disclosure column renders — a text glyph, since
|
|
1117
|
+
* basalt ships no icon set (`docs/CONTROLS-SPEC.md` §2, mirrors `SortIndicator` above). */
|
|
1118
|
+
function FoldToggle({
|
|
1119
|
+
expanded,
|
|
1120
|
+
controlsId,
|
|
1121
|
+
onToggle,
|
|
1122
|
+
}: {
|
|
1123
|
+
expanded: boolean
|
|
1124
|
+
controlsId: string
|
|
1125
|
+
onToggle: () => void
|
|
1126
|
+
}): ReactNode {
|
|
1127
|
+
return (
|
|
1128
|
+
<button
|
|
1129
|
+
type="button"
|
|
1130
|
+
className={classes.foldToggle}
|
|
1131
|
+
data-basalt-hit
|
|
1132
|
+
aria-expanded={expanded}
|
|
1133
|
+
aria-controls={controlsId}
|
|
1134
|
+
aria-label={expanded ? 'Hide row details' : 'Show row details'}
|
|
1135
|
+
// A row carrying `onRowActivate` reads a bubbled click as "activate the row" — the toggle
|
|
1136
|
+
// would open/close its disclosure AND fire the row's own action underneath it.
|
|
1137
|
+
onClick={(event) => {
|
|
1138
|
+
event.stopPropagation()
|
|
1139
|
+
onToggle()
|
|
1140
|
+
}}
|
|
1141
|
+
>
|
|
1142
|
+
<Box component="span" aria-hidden>
|
|
1143
|
+
{expanded ? '▾' : '▸'}
|
|
1144
|
+
</Box>
|
|
1145
|
+
</button>
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
887
1149
|
// ── The manual-pagination contract ────────────────────────────────────────────
|
|
888
1150
|
|
|
889
1151
|
/** One client-side control that `manualPagination` turns into a claim the table cannot support. */
|
|
@@ -973,38 +1235,20 @@ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): vo
|
|
|
973
1235
|
* statically: `stickyHeader` with neither `maxHeight` nor `minWidth`.
|
|
974
1236
|
*
|
|
975
1237
|
* Every other table renders inside `Table.ScrollContainer type="native"`, because a bare `<table>`
|
|
976
|
-
* sizes to its own min-content and
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
* to `auto` as well, which makes it the sticky header's own scrollport — and with no height cap
|
|
980
|
-
* that box has no scroll range at all, so the `<thead>` scrolls clean away (MEASURED in Chrome:
|
|
981
|
-
* top 10.5 → −389.5 across a 400px scroll). Containing it unconditionally deletes the feature;
|
|
982
|
-
* leaving it bare unconditionally widens the page.
|
|
1238
|
+
* sizes to its own min-content and would drag the page sideways. This shape cannot take that
|
|
1239
|
+
* container unconditionally: an `overflow-x: auto` box also computes `overflow-y`, making it the
|
|
1240
|
+
* sticky header's own scrollport with no scroll range, so the `<thead>` scrolls clean away.
|
|
983
1241
|
*
|
|
984
|
-
* So the wrapper's overflow is
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
* columns stay reachable by horizontal scroll, and the header is necessarily inert at that width.
|
|
989
|
-
* That is the honest trade: a header that stops sticking is recoverable, a page that scrolls
|
|
990
|
-
* sideways takes every fixed and sticky element on it with it.
|
|
1242
|
+
* So the wrapper's overflow is MEASURED. While the table FITS, the wrapper is bare and the sticky
|
|
1243
|
+
* header sticks; once it is WIDER, the wrapper flips to `overflow-x: auto` and the header is inert.
|
|
1244
|
+
* The trade is deliberate: a header that stops sticking is recoverable, a page that scrolls
|
|
1245
|
+
* sideways is not.
|
|
991
1246
|
*
|
|
992
|
-
* Three
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
* width, so the same comparison keeps answering correctly from inside the contained state.
|
|
998
|
-
* - **Equality counts as FITS**, which is what stops the two states oscillating: a contained
|
|
999
|
-
* wrapper that grows past the table reads `offsetWidth === clientWidth` (the `width: 100%` table
|
|
1000
|
-
* resolves against the container) and flips back exactly once.
|
|
1001
|
-
* - **A zero `clientWidth` is `unknown`, not overflow** — the ancestor chain has not been laid out
|
|
1002
|
-
* yet (the aside animates its width in from 0, `docs/ASIDE-SPEC.md` §0). Committing to contained
|
|
1003
|
-
* on that reading would wrap a table that was never too wide.
|
|
1004
|
-
*
|
|
1005
|
-
* `useLayoutEffect` runs before paint, so a table that is already measurable resolves without a
|
|
1006
|
-
* flash of the wrong mode. SSR reaches neither the effect nor a `window`, and the default state is
|
|
1007
|
-
* bare — the same node the server and the first client paint both render.
|
|
1247
|
+
* Three invariants: the observer stays alive in BOTH states (no one-way latch, so the wrapper
|
|
1248
|
+
* reverts when the space returns); equality counts as FITS (a `width: 100%` table reads
|
|
1249
|
+
* `offsetWidth === clientWidth`, which stops oscillation); and a zero `clientWidth` is `unknown`,
|
|
1250
|
+
* not overflow (the aside animates its width in from 0). `useLayoutEffect` runs before paint, so an
|
|
1251
|
+
* already-measurable table resolves without a flash; the default is bare for SSR and first paint.
|
|
1008
1252
|
*/
|
|
1009
1253
|
function useMeasuredContainment(active: boolean): {
|
|
1010
1254
|
wrapperRef: RefObject<HTMLDivElement | null>
|
|
@@ -1013,50 +1257,26 @@ function useMeasuredContainment(active: boolean): {
|
|
|
1013
1257
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
1014
1258
|
const [contained, setContained] = useState(false)
|
|
1015
1259
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
// observer; it is simply not an answer yet.
|
|
1023
|
-
const checkFit = (): 'fits' | 'contained' | 'unknown' => {
|
|
1024
|
-
const table = wrapper.querySelector('table')
|
|
1025
|
-
if (table === null) return 'unknown'
|
|
1026
|
-
if (wrapper.clientWidth === 0 || table.offsetWidth === 0) return 'unknown'
|
|
1027
|
-
return table.offsetWidth <= wrapper.clientWidth ? 'fits' : 'contained'
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
const apply = (): void => {
|
|
1031
|
-
const result = checkFit()
|
|
1032
|
-
if (result === 'unknown') return
|
|
1033
|
-
const next = result === 'contained'
|
|
1034
|
-
setContained((current) => (current === next ? current : next))
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
if (typeof ResizeObserver === 'undefined') {
|
|
1038
|
-
// No recovery path without an observer, so the one same-tick read is this environment's
|
|
1039
|
-
// only chance. `unknown` stays bare, which is the pre-measurement default anyway.
|
|
1040
|
-
apply()
|
|
1041
|
-
return
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
const observer = new ResizeObserver(apply)
|
|
1045
|
-
observer.observe(wrapper)
|
|
1046
|
-
const table = wrapper.querySelector('table')
|
|
1260
|
+
// The same measured-fold core `useColumnFold` uses, so this shape no longer mounts a second
|
|
1261
|
+
// bespoke `ResizeObserver` on the wrapper + table.
|
|
1262
|
+
useMeasuredWidths({
|
|
1263
|
+
resolveRoot: () => wrapperRef.current,
|
|
1264
|
+
signature: String(active),
|
|
1265
|
+
enabled: active,
|
|
1047
1266
|
// The TABLE too, not only the wrapper: a column set that re-measures (a facet filter changing
|
|
1048
1267
|
// the widest cell, a font settling) moves the table's min-content width while the wrapper's
|
|
1049
1268
|
// box never changes at all.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1269
|
+
extra: (root) => [root.querySelector('table')],
|
|
1270
|
+
onMeasure: (root) => {
|
|
1271
|
+
const table = root.querySelector('table')
|
|
1272
|
+
// Three answers, not two — see the docblock. A zero reading is an un-laid-out ancestor
|
|
1273
|
+
// (the aside animating in from 0), not overflow, so it moves neither the state nor the
|
|
1274
|
+
// observer; equality counts as FITS and stops the fit/contained oscillation.
|
|
1275
|
+
if (table === null || root.clientWidth === 0 || table.offsetWidth === 0) return
|
|
1276
|
+
const next = table.offsetWidth > root.clientWidth
|
|
1277
|
+
setContained((current) => (current === next ? current : next))
|
|
1278
|
+
},
|
|
1279
|
+
})
|
|
1060
1280
|
|
|
1061
1281
|
return { wrapperRef, contained: active && contained }
|
|
1062
1282
|
}
|
|
@@ -1107,32 +1327,23 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1107
1327
|
isLoading = false,
|
|
1108
1328
|
query,
|
|
1109
1329
|
skeletonRows = 5,
|
|
1110
|
-
initialSorting,
|
|
1111
|
-
onSortingChange,
|
|
1112
1330
|
enableGlobalFilter = false,
|
|
1113
1331
|
globalFilterPlaceholder = 'Search…',
|
|
1114
1332
|
searchIcon,
|
|
1115
|
-
initialGlobalFilter,
|
|
1116
|
-
onGlobalFilterChange,
|
|
1117
1333
|
facets,
|
|
1118
|
-
onColumnFiltersChange,
|
|
1119
1334
|
manualFiltering = false,
|
|
1120
1335
|
actions,
|
|
1121
1336
|
onRowActivate,
|
|
1337
|
+
renderCard,
|
|
1122
1338
|
enableRowSelection = false,
|
|
1123
|
-
rowSelection,
|
|
1124
|
-
onRowSelectionChange,
|
|
1125
1339
|
getRowId,
|
|
1126
1340
|
bulkActions,
|
|
1127
1341
|
enablePagination = false,
|
|
1128
1342
|
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
|
1129
|
-
initialPagination,
|
|
1130
|
-
onPaginationChange,
|
|
1131
1343
|
manualPagination = false,
|
|
1132
1344
|
rowCount,
|
|
1133
1345
|
pageCount,
|
|
1134
1346
|
enablePinning = false,
|
|
1135
|
-
initialColumnPinning,
|
|
1136
1347
|
maxHeight,
|
|
1137
1348
|
minWidth,
|
|
1138
1349
|
stickyHeader,
|
|
@@ -1145,17 +1356,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1145
1356
|
style,
|
|
1146
1357
|
classNames,
|
|
1147
1358
|
} = props
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1150
|
-
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
1151
|
-
const [pagination, setPagination] = useState<PaginationState>(
|
|
1152
|
-
initialPagination ?? { pageIndex: 0, pageSize: pageSizeOptions[0] ?? 10 },
|
|
1153
|
-
)
|
|
1154
|
-
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(initialColumnPinning ?? {})
|
|
1155
|
-
// Uncontrolled by default; `rowSelection`, when passed, is the truth and this only mirrors it so
|
|
1156
|
-
// the updater below has a base to apply against.
|
|
1157
|
-
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
|
|
1158
|
-
const rowSelectionState = rowSelection ?? internalRowSelection
|
|
1359
|
+
const tableState = useDataTableState(props, pageSizeOptions[0] ?? 10)
|
|
1360
|
+
const { globalFilter, columnPinning, onGlobalFilterChange: handleGlobalFilterChange } = tableState
|
|
1159
1361
|
|
|
1160
1362
|
// `stickyHeaderOffset` is DROPPED whenever the table owns a scroll container — see
|
|
1161
1363
|
// `resolvedStickyHeaderOffset` below for why it is always wrong there. Dropping it silently is
|
|
@@ -1198,69 +1400,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1198
1400
|
],
|
|
1199
1401
|
)
|
|
1200
1402
|
|
|
1201
|
-
const handleSortingChange = useCallback(
|
|
1202
|
-
(updater: Updater<SortingState>) => {
|
|
1203
|
-
setSorting((prev) => {
|
|
1204
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1205
|
-
onSortingChange?.(next)
|
|
1206
|
-
return next
|
|
1207
|
-
})
|
|
1208
|
-
},
|
|
1209
|
-
[onSortingChange],
|
|
1210
|
-
)
|
|
1211
|
-
|
|
1212
|
-
const handleGlobalFilterChange = useCallback(
|
|
1213
|
-
(updater: Updater<string>) => {
|
|
1214
|
-
setGlobalFilter((prev) => {
|
|
1215
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1216
|
-
onGlobalFilterChange?.(next)
|
|
1217
|
-
return next
|
|
1218
|
-
})
|
|
1219
|
-
},
|
|
1220
|
-
[onGlobalFilterChange],
|
|
1221
|
-
)
|
|
1222
|
-
|
|
1223
|
-
const handleColumnFiltersChange = useCallback(
|
|
1224
|
-
(updater: Updater<ColumnFiltersState>) => {
|
|
1225
|
-
setColumnFilters((prev) => {
|
|
1226
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1227
|
-
onColumnFiltersChange?.(next)
|
|
1228
|
-
return next
|
|
1229
|
-
})
|
|
1230
|
-
},
|
|
1231
|
-
[onColumnFiltersChange],
|
|
1232
|
-
)
|
|
1233
|
-
|
|
1234
|
-
const handlePaginationChange = useCallback(
|
|
1235
|
-
(updater: Updater<PaginationState>) => {
|
|
1236
|
-
setPagination((prev) => {
|
|
1237
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1238
|
-
onPaginationChange?.(next)
|
|
1239
|
-
return next
|
|
1240
|
-
})
|
|
1241
|
-
},
|
|
1242
|
-
[onPaginationChange],
|
|
1243
|
-
)
|
|
1244
|
-
|
|
1245
|
-
const handleRowSelectionChange = useCallback(
|
|
1246
|
-
(updater: Updater<RowSelectionState>) => {
|
|
1247
|
-
// CONTROLLED: the caller's map is the base AND the only writer. Mirroring it into state too
|
|
1248
|
-
// would leave a copy that the caller never moves, and that stale copy becomes the selection
|
|
1249
|
-
// the moment `rowSelection` goes back to `undefined` — plus every tick would render twice for
|
|
1250
|
-
// one change. Report the next map and let the caller own it.
|
|
1251
|
-
if (rowSelection !== undefined) {
|
|
1252
|
-
onRowSelectionChange?.(typeof updater === 'function' ? updater(rowSelection) : updater)
|
|
1253
|
-
return
|
|
1254
|
-
}
|
|
1255
|
-
setInternalRowSelection((prev) => {
|
|
1256
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1257
|
-
onRowSelectionChange?.(next)
|
|
1258
|
-
return next
|
|
1259
|
-
})
|
|
1260
|
-
},
|
|
1261
|
-
[rowSelection, onRowSelectionChange],
|
|
1262
|
-
)
|
|
1263
|
-
|
|
1264
1403
|
// Facets are TanStack `columnFilters` entries under the hood — inject an exact-match (or
|
|
1265
1404
|
// any-of, for `multiple`) filterFn onto the matching column so the built-in `includesString`
|
|
1266
1405
|
// auto-filter (substring match) never misfires against a Select's exact option value.
|
|
@@ -1268,7 +1407,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1268
1407
|
if (!facets || facets.length === 0) return columns
|
|
1269
1408
|
const facetById = new Map(facets.map((facet) => [facet.columnId, facet]))
|
|
1270
1409
|
return columns.map((column) => {
|
|
1271
|
-
const id =
|
|
1410
|
+
const id = columnDefId(column)
|
|
1272
1411
|
const facet = id === undefined ? undefined : facetById.get(id)
|
|
1273
1412
|
if (!facet) return column
|
|
1274
1413
|
const filterFn: FilterFn<T> = facet.multiple
|
|
@@ -1318,25 +1457,74 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1318
1457
|
const filteringEnabled = !breaches.includes('filtering')
|
|
1319
1458
|
const totalIsAuthoritative = !breaches.includes('total')
|
|
1320
1459
|
|
|
1460
|
+
// A capped body or a horizontal floor both need the same native scroll node — computed here,
|
|
1461
|
+
// ahead of the table, because `pageStickyHeader` (further down) needs it: `maxHeight` alone still
|
|
1462
|
+
// wants its own `ScrollArea` scrollport (a sticky `<thead>` resolves against it, not the measured
|
|
1463
|
+
// containment div, which offers no vertical scroll box at all), so EITHER prop rules out the
|
|
1464
|
+
// measured-containment shape.
|
|
1465
|
+
const scrolls = maxHeight !== undefined || minWidth !== undefined
|
|
1466
|
+
|
|
1467
|
+
// Fold eligibility is narrower than `scrolls`: only `minWidth` is a genuinely DECLARED horizontal
|
|
1468
|
+
// floor — the caller is stating "this table is at least this wide," which folding columns out of
|
|
1469
|
+
// would contradict. `maxHeight` alone is a purely VERTICAL cap and orthogonal to column width, so
|
|
1470
|
+
// a `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend for a
|
|
1471
|
+
// capped, sticky-header body) still folds instead of silently regressing to horizontal scroll.
|
|
1472
|
+
const foldEligible = minWidth === undefined
|
|
1473
|
+
|
|
1474
|
+
// The fold order is computed off the caller's OWN `columns` — never `tableColumns` — so the
|
|
1475
|
+
// selection checkbox (prepended below) is never a fold candidate; see `foldOrder`'s docblock for
|
|
1476
|
+
// the declared-order default and the explicit-priority rule. `enableHiding: false` and a PINNED
|
|
1477
|
+
// column are excluded outright, not merely de-prioritized — both are the caller stating this
|
|
1478
|
+
// column must stay visible, the same declaration a fold candidate has no way to override.
|
|
1479
|
+
const { columnFoldOrder, columnFoldFixedIds } = useMemo(() => {
|
|
1480
|
+
const pinnedIds = new Set([...(columnPinning.left ?? []), ...(columnPinning.right ?? [])])
|
|
1481
|
+
const excluded = new Set(
|
|
1482
|
+
columns.flatMap((column) => {
|
|
1483
|
+
const id = columnDefId(column)
|
|
1484
|
+
if (id === undefined) return []
|
|
1485
|
+
return column.enableHiding === false || pinnedIds.has(id) ? [id] : []
|
|
1486
|
+
}),
|
|
1487
|
+
)
|
|
1488
|
+
return {
|
|
1489
|
+
columnFoldOrder: foldOrder(columns).filter((id) => !excluded.has(id)),
|
|
1490
|
+
columnFoldFixedIds: [...excluded],
|
|
1491
|
+
}
|
|
1492
|
+
}, [columns, columnPinning])
|
|
1493
|
+
// The card projection unmounts the table, so the fold and the containment wrapper go inactive with
|
|
1494
|
+
// it — their measuring effects re-attach to the remounted table when the width returns.
|
|
1495
|
+
const projection = useCardProjection(renderCard !== undefined)
|
|
1496
|
+
const columnFold = useColumnFold({
|
|
1497
|
+
active: foldEligible && !projection.cards,
|
|
1498
|
+
order: columnFoldOrder,
|
|
1499
|
+
fixedIds: columnFoldFixedIds,
|
|
1500
|
+
})
|
|
1501
|
+
const columnVisibility: VisibilityState = useMemo(
|
|
1502
|
+
() => Object.fromEntries([...columnFold.folded].map((id) => [id, false])),
|
|
1503
|
+
[columnFold.folded],
|
|
1504
|
+
)
|
|
1505
|
+
const hasFolded = columnFold.folded.size > 0
|
|
1506
|
+
const foldRows = useRowDisclosure({ data, getRowId })
|
|
1507
|
+
|
|
1321
1508
|
const table = useReactTable<T>({
|
|
1322
1509
|
data,
|
|
1323
1510
|
columns: tableColumns,
|
|
1324
1511
|
state: {
|
|
1325
|
-
sorting,
|
|
1512
|
+
sorting: tableState.sorting,
|
|
1326
1513
|
globalFilter,
|
|
1327
|
-
columnFilters,
|
|
1514
|
+
columnFilters: tableState.columnFilters,
|
|
1328
1515
|
columnPinning,
|
|
1329
|
-
|
|
1330
|
-
...(
|
|
1516
|
+
columnVisibility,
|
|
1517
|
+
...(enablePagination && { pagination: tableState.pagination }),
|
|
1518
|
+
...(enableRowSelection && { rowSelection: tableState.rowSelection }),
|
|
1331
1519
|
},
|
|
1332
|
-
onSortingChange:
|
|
1520
|
+
onSortingChange: tableState.onSortingChange,
|
|
1333
1521
|
onGlobalFilterChange: handleGlobalFilterChange,
|
|
1334
|
-
onColumnFiltersChange:
|
|
1335
|
-
onColumnPinningChange:
|
|
1336
|
-
...(enablePagination && { onPaginationChange:
|
|
1522
|
+
onColumnFiltersChange: tableState.onColumnFiltersChange,
|
|
1523
|
+
onColumnPinningChange: tableState.onColumnPinningChange,
|
|
1524
|
+
...(enablePagination && { onPaginationChange: tableState.onPaginationChange }),
|
|
1337
1525
|
...(enableRowSelection && {
|
|
1338
1526
|
enableRowSelection: true,
|
|
1339
|
-
onRowSelectionChange:
|
|
1527
|
+
onRowSelectionChange: tableState.onRowSelectionChange,
|
|
1340
1528
|
}),
|
|
1341
1529
|
...(getRowId !== undefined && { getRowId }),
|
|
1342
1530
|
getCoreRowModel: getCoreRowModel(),
|
|
@@ -1371,10 +1559,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1371
1559
|
const showSkeleton = branch === undefined ? isLoading : branch === 'pending'
|
|
1372
1560
|
const queryError = branch === 'error' && query !== undefined ? query : undefined
|
|
1373
1561
|
|
|
1374
|
-
// Every colSpan and every skeleton row counts the RENDERED columns
|
|
1375
|
-
//
|
|
1376
|
-
// error row
|
|
1377
|
-
const columnCount =
|
|
1562
|
+
// Every colSpan and every skeleton row counts the RENDERED columns: one more than `columns` for
|
|
1563
|
+
// the prepended checkbox column, one more again for the fold-disclosure toggle once anything has
|
|
1564
|
+
// folded — a stale count left the empty state and the error row short of the header.
|
|
1565
|
+
const columnCount =
|
|
1566
|
+
columns.length + (enableRowSelection ? 1 : 0) + (hasFolded ? 1 : 0) - columnFold.folded.size
|
|
1378
1567
|
|
|
1379
1568
|
// `getSelectedRowModel()` is TanStack's own memo, so its `rows` array keeps its identity until
|
|
1380
1569
|
// the selection or the row model actually moves — which is what makes both memos below hold.
|
|
@@ -1398,10 +1587,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1398
1587
|
// `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
|
|
1399
1588
|
const rows = table.getRowModel().rows
|
|
1400
1589
|
|
|
1401
|
-
// A capped body or a horizontal floor both need the same native scroll node. Everything else —
|
|
1402
|
-
// pinning included — either takes that same container (the uncapped, non-sticky default) or the
|
|
1403
|
-
// measured wrapper below; there is no third overflow box.
|
|
1404
|
-
const scrolls = maxHeight !== undefined || minWidth !== undefined
|
|
1405
1590
|
// `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
|
|
1406
1591
|
// scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
|
|
1407
1592
|
// always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
|
|
@@ -1420,9 +1605,27 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1420
1605
|
// `useMeasuredContainment` drives instead: bare while the table fits, `overflow-x: auto` once it
|
|
1421
1606
|
// does not. Its docblock carries the full accounting.
|
|
1422
1607
|
const pageStickyHeader = !scrolls && stickyHeader === true
|
|
1423
|
-
const containment = useMeasuredContainment(pageStickyHeader)
|
|
1608
|
+
const containment = useMeasuredContainment(pageStickyHeader && !projection.cards)
|
|
1424
1609
|
const resolvedStickyHeaderOffset = scrolls ? undefined : stickyHeaderOffset
|
|
1425
1610
|
|
|
1611
|
+
// The error branch the table did not have (components audit #3) carries the query's OWN `refetch`
|
|
1612
|
+
// behind Retry, so the failure is reported where the rows would have been — in a row spanning
|
|
1613
|
+
// every rendered column, or in place of the card list.
|
|
1614
|
+
const errorNode = queryError !== undefined && (
|
|
1615
|
+
<ErrorState
|
|
1616
|
+
error={queryError.error}
|
|
1617
|
+
title="Could not load"
|
|
1618
|
+
tier="section"
|
|
1619
|
+
retrying={queryError.fetchStatus === 'fetching'}
|
|
1620
|
+
onRetry={() => void queryError.refetch()}
|
|
1621
|
+
/>
|
|
1622
|
+
)
|
|
1623
|
+
const emptyNode = emptyState ?? (
|
|
1624
|
+
<Text c="dimmed" ta="center" size="sm" py="sm">
|
|
1625
|
+
No data to display.
|
|
1626
|
+
</Text>
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1426
1629
|
const tableNode = (
|
|
1427
1630
|
<Table
|
|
1428
1631
|
className={cx(classNames?.table)}
|
|
@@ -1440,6 +1643,9 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1440
1643
|
<Table.Thead>
|
|
1441
1644
|
{headerGroups.map((headerGroup) => (
|
|
1442
1645
|
<Table.Tr key={headerGroup.id}>
|
|
1646
|
+
{/* The disclosure toggle column has no header of its own — nothing is "sorted" or
|
|
1647
|
+
"selected all" across it, only revealed per row. */}
|
|
1648
|
+
{hasFolded && <Table.Th className={classes.foldToggleCell} aria-hidden />}
|
|
1443
1649
|
{headerGroup.headers.map((header) => {
|
|
1444
1650
|
const canSort = sortingEnabled && header.column.getCanSort()
|
|
1445
1651
|
const sorted = header.column.getIsSorted()
|
|
@@ -1448,9 +1654,14 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1448
1654
|
? getPinnedCellStyle(table, header.column)
|
|
1449
1655
|
: undefined
|
|
1450
1656
|
const align = resolveAlign(header.column)
|
|
1657
|
+
const isSelectColumn = header.column.id === SELECT_COLUMN_ID
|
|
1451
1658
|
return (
|
|
1452
1659
|
<Table.Th
|
|
1453
1660
|
key={header.id}
|
|
1661
|
+
{...(isSelectColumn && { className: classes.selectCell })}
|
|
1662
|
+
// The measured fold's own read (`useColumnFold`) — never present on the
|
|
1663
|
+
// selection column, which is never a fold candidate.
|
|
1664
|
+
{...(!isSelectColumn && { [FOLD_ID_ATTR]: header.column.id })}
|
|
1454
1665
|
onClick={canSort ? toggleSorting : undefined}
|
|
1455
1666
|
onKeyDown={
|
|
1456
1667
|
canSort
|
|
@@ -1462,15 +1673,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1462
1673
|
: undefined
|
|
1463
1674
|
}
|
|
1464
1675
|
tabIndex={canSort ? 0 : undefined}
|
|
1465
|
-
aria-sort={
|
|
1466
|
-
canSort
|
|
1467
|
-
? sorted === 'asc'
|
|
1468
|
-
? 'ascending'
|
|
1469
|
-
: sorted === 'desc'
|
|
1470
|
-
? 'descending'
|
|
1471
|
-
: 'none'
|
|
1472
|
-
: undefined
|
|
1473
|
-
}
|
|
1676
|
+
aria-sort={canSort ? (sorted ? ARIA_SORT[sorted] : 'none') : undefined}
|
|
1474
1677
|
style={{
|
|
1475
1678
|
...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
|
|
1476
1679
|
...(align !== undefined && { textAlign: align }),
|
|
@@ -1489,19 +1692,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1489
1692
|
</Table.Thead>
|
|
1490
1693
|
<Table.Tbody>
|
|
1491
1694
|
{queryError !== undefined ? (
|
|
1492
|
-
// The branch the table did not have (components audit #3). It spans every rendered
|
|
1493
|
-
// column and carries the query's OWN `refetch` behind Retry, so the failure is reported
|
|
1494
|
-
// where the rows would have been — not as an empty table saying nothing failed.
|
|
1495
1695
|
<Table.Tr>
|
|
1496
|
-
<Table.Td colSpan={columnCount}>
|
|
1497
|
-
<ErrorState
|
|
1498
|
-
error={queryError.error}
|
|
1499
|
-
title="Could not load"
|
|
1500
|
-
tier="section"
|
|
1501
|
-
retrying={queryError.fetchStatus === 'fetching'}
|
|
1502
|
-
onRetry={() => void queryError.refetch()}
|
|
1503
|
-
/>
|
|
1504
|
-
</Table.Td>
|
|
1696
|
+
<Table.Td colSpan={columnCount}>{errorNode}</Table.Td>
|
|
1505
1697
|
</Table.Tr>
|
|
1506
1698
|
) : showSkeleton ? (
|
|
1507
1699
|
Array.from({ length: skeletonRows }, (_, rowIndex) => (
|
|
@@ -1515,13 +1707,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1515
1707
|
))
|
|
1516
1708
|
) : rows.length === 0 ? (
|
|
1517
1709
|
<Table.Tr>
|
|
1518
|
-
<Table.Td colSpan={columnCount}>
|
|
1519
|
-
{emptyState ?? (
|
|
1520
|
-
<Text c="dimmed" ta="center" size="sm" py="sm">
|
|
1521
|
-
No data to display.
|
|
1522
|
-
</Text>
|
|
1523
|
-
)}
|
|
1524
|
-
</Table.Td>
|
|
1710
|
+
<Table.Td colSpan={columnCount}>{emptyNode}</Table.Td>
|
|
1525
1711
|
</Table.Tr>
|
|
1526
1712
|
) : (
|
|
1527
1713
|
rows.map((row) => {
|
|
@@ -1532,51 +1718,60 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1532
1718
|
...row.getRightVisibleCells(),
|
|
1533
1719
|
]
|
|
1534
1720
|
: row.getVisibleCells()
|
|
1721
|
+
const rowExpanded = hasFolded && foldRows.expanded.has(row.id)
|
|
1535
1722
|
return (
|
|
1536
|
-
<
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1723
|
+
<Fragment key={row.id}>
|
|
1724
|
+
<Table.Tr
|
|
1725
|
+
{...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
|
|
1726
|
+
{...activationProps(onRowActivate, row.original)}
|
|
1727
|
+
>
|
|
1728
|
+
{hasFolded && (
|
|
1729
|
+
<Table.Td className={classes.foldToggleCell}>
|
|
1730
|
+
<FoldToggle
|
|
1731
|
+
expanded={rowExpanded}
|
|
1732
|
+
controlsId={`${row.id}-fold`}
|
|
1733
|
+
onToggle={() => foldRows.toggle(row.id)}
|
|
1734
|
+
/>
|
|
1735
|
+
</Table.Td>
|
|
1736
|
+
)}
|
|
1737
|
+
{cells.map((cell) => {
|
|
1738
|
+
const pinnedStyle = enablePinning
|
|
1739
|
+
? getPinnedCellStyle(table, cell.column)
|
|
1740
|
+
: undefined
|
|
1741
|
+
const align = resolveAlign(cell.column)
|
|
1742
|
+
return (
|
|
1743
|
+
<Table.Td
|
|
1744
|
+
key={cell.id}
|
|
1745
|
+
{...(cell.column.id === SELECT_COLUMN_ID && {
|
|
1746
|
+
className: classes.selectCell,
|
|
1747
|
+
// The label's ::after fills the cell, so a tap on it bubbles to the row —
|
|
1748
|
+
// stop it only where a tick is possible; a disabled row's tap is a row tap.
|
|
1749
|
+
...(cell.row.getCanSelect() && {
|
|
1750
|
+
onClick: (event: ReactMouseEvent) => event.stopPropagation(),
|
|
1751
|
+
}),
|
|
1752
|
+
})}
|
|
1753
|
+
style={{
|
|
1754
|
+
...(typeof cell.getValue() === 'number' &&
|
|
1755
|
+
cell.column.columnDef.meta?.numeral !== false
|
|
1756
|
+
? NUMERIC_CELL_STYLE
|
|
1757
|
+
: undefined),
|
|
1758
|
+
...(align !== undefined && { textAlign: align }),
|
|
1759
|
+
...pinnedStyle,
|
|
1760
|
+
}}
|
|
1761
|
+
>
|
|
1762
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1763
|
+
</Table.Td>
|
|
1764
|
+
)
|
|
1765
|
+
})}
|
|
1766
|
+
</Table.Tr>
|
|
1767
|
+
{rowExpanded && (
|
|
1768
|
+
<Table.Tr id={`${row.id}-fold`} className={classes.foldDisclosureRowTr}>
|
|
1769
|
+
<Table.Td colSpan={columnCount} className={classes.foldDisclosureCell}>
|
|
1770
|
+
<RowDisclosure row={row} folded={columnFold.folded} />
|
|
1576
1771
|
</Table.Td>
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
</
|
|
1772
|
+
</Table.Tr>
|
|
1773
|
+
)}
|
|
1774
|
+
</Fragment>
|
|
1580
1775
|
)
|
|
1581
1776
|
})
|
|
1582
1777
|
)}
|
|
@@ -1584,6 +1779,25 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1584
1779
|
</Table>
|
|
1585
1780
|
)
|
|
1586
1781
|
|
|
1782
|
+
// The `renderCard` body — the same processed rows, states and activation as the table's.
|
|
1783
|
+
const renderCardList = () => (
|
|
1784
|
+
<Stack gap="xs" className={cx(classNames?.table)}>
|
|
1785
|
+
{queryError !== undefined
|
|
1786
|
+
? errorNode
|
|
1787
|
+
: showSkeleton
|
|
1788
|
+
? Array.from({ length: skeletonRows }, (_, index) => (
|
|
1789
|
+
<Skeleton key={`skeleton-${index}`} height={48} radius="md" />
|
|
1790
|
+
))
|
|
1791
|
+
: rows.length === 0
|
|
1792
|
+
? emptyNode
|
|
1793
|
+
: rows.map((row) => (
|
|
1794
|
+
<Box key={row.id} {...activationProps(onRowActivate, row.original)}>
|
|
1795
|
+
{renderCard?.(row.original)}
|
|
1796
|
+
</Box>
|
|
1797
|
+
))}
|
|
1798
|
+
</Stack>
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1587
1801
|
const paginationState = table.getState().pagination
|
|
1588
1802
|
// The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
|
|
1589
1803
|
// Select renders EMPTY when its `value` matches no row, so a table opened at
|
|
@@ -1597,7 +1811,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1597
1811
|
const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
|
|
1598
1812
|
|
|
1599
1813
|
return (
|
|
1600
|
-
<div
|
|
1814
|
+
<div
|
|
1815
|
+
ref={projection.rootRef}
|
|
1816
|
+
className={cx(classNames?.root, className)}
|
|
1817
|
+
{...(style !== undefined && { style })}
|
|
1818
|
+
>
|
|
1601
1819
|
{/*
|
|
1602
1820
|
* ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
|
|
1603
1821
|
* the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
|
|
@@ -1671,25 +1889,47 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1671
1889
|
{bulkBarActions !== undefined && (
|
|
1672
1890
|
<BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
|
|
1673
1891
|
)}
|
|
1674
|
-
{
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
<Table.ScrollContainer
|
|
1687
|
-
type="native"
|
|
1688
|
-
minWidth={minWidth ?? 0}
|
|
1689
|
-
{...(maxHeight !== undefined && { maxHeight })}
|
|
1690
|
-
>
|
|
1892
|
+
{projection.cards ? (
|
|
1893
|
+
maxHeight === undefined ? (
|
|
1894
|
+
renderCardList()
|
|
1895
|
+
) : (
|
|
1896
|
+
<ScrollArea.Autosize mah={maxHeight} type="hover" scrollbars="y">
|
|
1897
|
+
{renderCardList()}
|
|
1898
|
+
</ScrollArea.Autosize>
|
|
1899
|
+
)
|
|
1900
|
+
) : !foldEligible ? (
|
|
1901
|
+
// An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
|
|
1902
|
+
// never engages here (see `foldEligible`, above), so this shape keeps its exact
|
|
1903
|
+
// pre-existing DOM: no extra measuring wrapper for a table nothing folds.
|
|
1904
|
+
<Table.ScrollContainer type="native" minWidth={minWidth ?? 0} maxHeight={maxHeight}>
|
|
1691
1905
|
{tableNode}
|
|
1692
1906
|
</Table.ScrollContainer>
|
|
1907
|
+
) : (
|
|
1908
|
+
// The column fold's own measuring box (`useColumnFold`) — wraps every remaining shape,
|
|
1909
|
+
// `maxHeight`-only included: a vertical cap is orthogonal to column width, so a
|
|
1910
|
+
// `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend) still
|
|
1911
|
+
// folds instead of silently regressing to horizontal scroll. A plain block box, so it
|
|
1912
|
+
// constrains nothing on its own; `columnFold.wrapperRef` only reads it.
|
|
1913
|
+
<div ref={columnFold.wrapperRef} className={classes.foldWrapper}>
|
|
1914
|
+
{pageStickyHeader ? (
|
|
1915
|
+
// Pinning takes this wrapper too — a pinned column's offsets are `position: sticky` on
|
|
1916
|
+
// the cells and need no overflow box of their own, so the `overflow-x: auto` Box that
|
|
1917
|
+
// used to sit here was the exact inert-sticky defect this shape exists to avoid.
|
|
1918
|
+
<div
|
|
1919
|
+
ref={containment.wrapperRef}
|
|
1920
|
+
className={classes.containment}
|
|
1921
|
+
data-contained={containment.contained ? 'true' : 'false'}
|
|
1922
|
+
>
|
|
1923
|
+
{tableNode}
|
|
1924
|
+
</div>
|
|
1925
|
+
) : (
|
|
1926
|
+
// `pageStickyHeader` is false whenever `maxHeight` is set (`scrolls` above), so a vertical
|
|
1927
|
+
// cap lands here — its own `ScrollArea` scrollport, just with no `minWidth` floor.
|
|
1928
|
+
<Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
|
|
1929
|
+
{tableNode}
|
|
1930
|
+
</Table.ScrollContainer>
|
|
1931
|
+
)}
|
|
1932
|
+
</div>
|
|
1693
1933
|
)}
|
|
1694
1934
|
{enablePagination && (
|
|
1695
1935
|
<Group
|
|
@@ -1705,6 +1945,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1705
1945
|
{totalIsAuthoritative ? ` of ${total}` : ''}
|
|
1706
1946
|
</Text>
|
|
1707
1947
|
<Group gap="xs" align="center">
|
|
1948
|
+
{/* theme-allow control-outside-home — the table's own page-size setting, not a page
|
|
1949
|
+
filter: its state is the table's pagination, owned inside this component. */}
|
|
1708
1950
|
<Select
|
|
1709
1951
|
size="ctl"
|
|
1710
1952
|
radius="md"
|
|
@@ -1714,13 +1956,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1714
1956
|
onChange={(value) => value && table.setPageSize(Number(value))}
|
|
1715
1957
|
allowDeselect={false}
|
|
1716
1958
|
/>
|
|
1717
|
-
<
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1959
|
+
<CtlSlot>
|
|
1960
|
+
{/* `Pagination` carries no `ctl`-tier entry in `CTL_THEME` (its own controls resolve
|
|
1961
|
+
the 44px coarse hit floor through `PaginationControl`'s theme-level
|
|
1962
|
+
`data-basalt-hit`, not through the `ctl` size), so `CtlSlot` cannot restore this
|
|
1963
|
+
size on its own — an explicit `size="sm"` is the visual this footer always had. */}
|
|
1964
|
+
<Pagination
|
|
1965
|
+
size="sm"
|
|
1966
|
+
radius="md"
|
|
1967
|
+
total={Math.max(table.getPageCount(), 1)}
|
|
1968
|
+
value={paginationState.pageIndex + 1}
|
|
1969
|
+
onChange={(page) => table.setPageIndex(page - 1)}
|
|
1970
|
+
/>
|
|
1971
|
+
</CtlSlot>
|
|
1724
1972
|
</Group>
|
|
1725
1973
|
</Group>
|
|
1726
1974
|
)}
|