basalt-ui 1.30.2 → 1.31.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 +31 -0
- package/MIGRATING.md +172 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +1 -1
- package/configs/oxlint-plugin.js +254 -4
- package/configs/oxlint-plugin.test.ts +145 -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 +54 -41
- 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 +145 -131
- 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 +83 -68
- 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 +271 -125
- 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 +74 -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 +133 -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 +212 -71
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +4 -3
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +37 -26
- 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 +29 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +13 -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 +76 -29
- 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/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 +8 -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 +1 -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 +16 -4
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- 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.d.ts +23 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +339 -141
- 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 +30 -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 +67 -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/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +22 -2
- 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/index.d.ts +12 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +57 -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 +36 -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/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 +17 -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 +11 -1
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +19 -4
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +15 -3
- 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 -68
- package/src/charts/primitives/ChartCard.tsx +141 -48
- package/src/charts/primitives/ChartFrame.tsx +243 -60
- package/src/charts/primitives/ChartLegend.tsx +257 -73
- package/src/charts/primitives/ChartTooltip.tsx +8 -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 +15 -4
- package/src/charts/primitives/chart-card-context.ts +37 -0
- package/src/charts/primitives/chart-frame-layout.ts +214 -177
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- 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 +8 -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 +1 -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 +16 -4
- 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.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +503 -164
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +30 -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 +183 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/provider/index.tsx +53 -1
- 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/index.tsx +200 -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 +36 -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/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
|
@@ -51,11 +51,13 @@ import type {
|
|
|
51
51
|
ColumnPinningState,
|
|
52
52
|
FilterFn,
|
|
53
53
|
PaginationState,
|
|
54
|
+
Row,
|
|
54
55
|
RowData,
|
|
55
56
|
RowSelectionState,
|
|
56
57
|
SortingState,
|
|
57
58
|
Table as TanstackTable,
|
|
58
59
|
Updater,
|
|
60
|
+
VisibilityState,
|
|
59
61
|
} from '@tanstack/react-table'
|
|
60
62
|
import {
|
|
61
63
|
flexRender,
|
|
@@ -68,13 +70,16 @@ import {
|
|
|
68
70
|
import type {
|
|
69
71
|
CSSProperties,
|
|
70
72
|
KeyboardEvent as ReactKeyboardEvent,
|
|
73
|
+
MouseEvent as ReactMouseEvent,
|
|
71
74
|
ReactNode,
|
|
72
75
|
RefObject,
|
|
73
76
|
} from 'react'
|
|
74
|
-
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
77
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
75
78
|
import { cx } from '../common/props'
|
|
76
79
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
77
80
|
import { BASALT_PREFIX } from '../common/errors'
|
|
81
|
+
import { HIT_COARSE } from '../common/hit-floor'
|
|
82
|
+
import { useMeasuredWidths } from '../common/use-measured-widths'
|
|
78
83
|
import { assertRequiredProps, useValidateProps } from '../common/validate'
|
|
79
84
|
import { FilterSet } from '../controls'
|
|
80
85
|
import { ActionGroup, BarActionSlot } from '../controls/actions'
|
|
@@ -121,6 +126,14 @@ declare module '@tanstack/react-table' {
|
|
|
121
126
|
* `<td>`'s mono font then overrides what the cell asked for.
|
|
122
127
|
*/
|
|
123
128
|
numeral?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* Fold priority for the measured column fold (`docs/CONTROLS-SPEC.md` §2): once the
|
|
131
|
+
* table no longer fits its wrapper, the HIGHEST-numbered priority column folds first, moving
|
|
132
|
+
* into a per-row disclosure instead of forcing the table into horizontal scroll. Unset columns
|
|
133
|
+
* fall back to their declared position, so a bare `<BasaltDataTable data columns />` folds its
|
|
134
|
+
* LAST column first — nothing regresses with no `meta.priority` set anywhere. See `foldOrder`.
|
|
135
|
+
*/
|
|
136
|
+
priority?: number
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -315,32 +328,37 @@ const SELECT_COLUMN_ID = '__basalt_select__'
|
|
|
315
328
|
* Built per render inside a `useMemo`, not hoisted as a constant: `ColumnDef` is generic in `T`, and
|
|
316
329
|
* a single shared instance would have to be cast at every call site.
|
|
317
330
|
*
|
|
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.
|
|
331
|
+
* The select cell's `stopPropagation` (see the `<Table.Td>`) is the load-bearing line. The checkbox
|
|
332
|
+
* lives inside the row, and a row carrying `onRowActivate` opens a detail from a click — so without
|
|
333
|
+
* it, ticking the box also navigated away from the table the tick was meant to act on.
|
|
321
334
|
*/
|
|
322
335
|
function selectionColumn<T>(): ColumnDef<T, unknown> {
|
|
323
336
|
return {
|
|
324
337
|
id: SELECT_COLUMN_ID,
|
|
325
338
|
enableSorting: false,
|
|
326
339
|
header: ({ table }) => (
|
|
327
|
-
<Checkbox
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
|
|
341
|
+
<label className={classes.selectHit}>
|
|
342
|
+
<Checkbox
|
|
343
|
+
size="xs"
|
|
344
|
+
aria-label="Select all rows"
|
|
345
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
346
|
+
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
|
347
|
+
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
348
|
+
/>
|
|
349
|
+
</label>
|
|
334
350
|
),
|
|
335
351
|
cell: ({ row }) => (
|
|
336
|
-
<Checkbox
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
352
|
+
// oxlint-disable-next-line jsx-a11y/label-has-associated-control -- wraps <Checkbox>, which oxlint cannot see is an input
|
|
353
|
+
<label className={classes.selectHit}>
|
|
354
|
+
<Checkbox
|
|
355
|
+
size="xs"
|
|
356
|
+
aria-label={`Select row ${row.index + 1}`}
|
|
357
|
+
checked={row.getIsSelected()}
|
|
358
|
+
disabled={!row.getCanSelect()}
|
|
359
|
+
onChange={row.getToggleSelectedHandler()}
|
|
360
|
+
/>
|
|
361
|
+
</label>
|
|
344
362
|
),
|
|
345
363
|
}
|
|
346
364
|
}
|
|
@@ -884,6 +902,217 @@ function getPinnedCellStyle<T>(
|
|
|
884
902
|
}
|
|
885
903
|
}
|
|
886
904
|
|
|
905
|
+
// ── Column fold (`meta.priority`, docs/CONTROLS-SPEC.md §2) ──────────────────
|
|
906
|
+
|
|
907
|
+
/** The column id TanStack will resolve internally for this raw def — explicit `id`, else the
|
|
908
|
+
* accessor key string. Mirrors the resolution `facetColumns` already relies on above. */
|
|
909
|
+
function columnDefId<T>(column: ColumnDef<T, unknown>): string | undefined {
|
|
910
|
+
if (column.id !== undefined) return column.id
|
|
911
|
+
return 'accessorKey' in column && typeof column.accessorKey === 'string'
|
|
912
|
+
? column.accessorKey
|
|
913
|
+
: undefined
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Fold order for `meta.priority` — the FIRST id in the returned list folds first. Effective
|
|
918
|
+
* priority is `meta.priority ?? declaredIndex`, so with no `meta.priority` set anywhere it is just
|
|
919
|
+
* the column's position: the LAST declared column folds first, the zero-config default a bare
|
|
920
|
+
* `<BasaltDataTable data columns />` needs. An explicit priority folds a HIGHER number before a
|
|
921
|
+
* lower one; a tie folds in reverse declared order. A column with no resolvable id (an
|
|
922
|
+
* `accessorFn` with no explicit `id`) is never a fold candidate — nothing downstream could
|
|
923
|
+
* reliably re-identify it once hidden.
|
|
924
|
+
*/
|
|
925
|
+
function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
|
|
926
|
+
return columns
|
|
927
|
+
.map((column, index) => ({
|
|
928
|
+
id: columnDefId(column),
|
|
929
|
+
priority: column.meta?.priority ?? index,
|
|
930
|
+
index,
|
|
931
|
+
}))
|
|
932
|
+
.filter((candidate): candidate is { id: string; priority: number; index: number } =>
|
|
933
|
+
Boolean(candidate.id),
|
|
934
|
+
)
|
|
935
|
+
.toSorted((a, b) => b.priority - a.priority || b.index - a.index)
|
|
936
|
+
.map((candidate) => candidate.id)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Marks a rendered data column header for `useColumnFold`'s width measurement — never read past
|
|
940
|
+
* that hook, so it carries no styling and needs no CSS-module counterpart. */
|
|
941
|
+
const FOLD_ID_ATTR = 'data-basalt-fold-id'
|
|
942
|
+
|
|
943
|
+
const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
|
|
947
|
+
* still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
|
|
948
|
+
* or `enableHiding: false` — which occupy room but can never fold) and the disclosure toggle's own
|
|
949
|
+
* width, once anything is folded — fit `room`. A candidate with no measured width yet (never
|
|
950
|
+
* rendered) is skipped rather than folded blind, since folding it would remove the one place its
|
|
951
|
+
* width could ever be read from.
|
|
952
|
+
*/
|
|
953
|
+
export function planColumnFold(input: {
|
|
954
|
+
room: number
|
|
955
|
+
toggleWidth: number
|
|
956
|
+
overhead: number
|
|
957
|
+
order: readonly string[]
|
|
958
|
+
widths: ReadonlyMap<string, number>
|
|
959
|
+
}): ReadonlySet<string> {
|
|
960
|
+
const { room, toggleWidth, overhead, order, widths } = input
|
|
961
|
+
const known = order.filter((id) => widths.has(id))
|
|
962
|
+
let unfolded = overhead + known.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
|
|
963
|
+
const folded = new Set<string>()
|
|
964
|
+
for (const id of known) {
|
|
965
|
+
// Never fold the last remaining data column — a table with every column folded has no row
|
|
966
|
+
// identifier left to read at all, which is worse than the horizontal scroll this exists to
|
|
967
|
+
// avoid. With no explicit `meta.priority` this is `order`'s LAST id, which `foldOrder`'s own
|
|
968
|
+
// doc pins as the FIRST declared column (the last-declared one folds first).
|
|
969
|
+
if (folded.size >= known.length - 1) break
|
|
970
|
+
const withToggle = unfolded + (folded.size > 0 ? toggleWidth : 0)
|
|
971
|
+
if (withToggle <= room) break
|
|
972
|
+
folded.add(id)
|
|
973
|
+
unfolded -= widths.get(id) ?? 0
|
|
974
|
+
}
|
|
975
|
+
return folded
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* The general-case measured fold. Reacting to `useMeasuredContainment`'s boolean fit directly
|
|
980
|
+
* (fold one, remeasure, maybe unfold) would oscillate, because folding changes the very box being
|
|
981
|
+
* measured. Instead this reads each candidate `<th data-basalt-fold-id>`'s width once, while it is
|
|
982
|
+
* visible, caches it, and decides analytically (`planColumnFold`). `useMeasuredWidths` owns the
|
|
983
|
+
* cache and the wrapper-vs-table observer that `controls/actions.tsx`'s fold shares.
|
|
984
|
+
*/
|
|
985
|
+
function useColumnFold(input: {
|
|
986
|
+
active: boolean
|
|
987
|
+
order: readonly string[]
|
|
988
|
+
/**
|
|
989
|
+
* Columns excluded from fold candidacy altogether (pinned, or `enableHiding: false`) — they are
|
|
990
|
+
* never in `order`, but they still occupy room, and their measured widths (carried in the same
|
|
991
|
+
* `[data-basalt-fold-id]` sweep as every eligible column's) become fixed overhead against the
|
|
992
|
+
* fold baseline. See `planColumnFold`'s `overhead` param.
|
|
993
|
+
*/
|
|
994
|
+
fixedIds: readonly string[]
|
|
995
|
+
}): {
|
|
996
|
+
wrapperRef: RefObject<HTMLDivElement | null>
|
|
997
|
+
folded: ReadonlySet<string>
|
|
998
|
+
} {
|
|
999
|
+
const { active, order, fixedIds } = input
|
|
1000
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
1001
|
+
const [folded, setFolded] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
|
|
1002
|
+
const orderRef = useRef(order)
|
|
1003
|
+
orderRef.current = order
|
|
1004
|
+
const fixedIdsRef = useRef(fixedIds)
|
|
1005
|
+
fixedIdsRef.current = fixedIds
|
|
1006
|
+
useLayoutEffect(() => {
|
|
1007
|
+
if (!active || order.length === 0) {
|
|
1008
|
+
setFolded((current) => (current.size === 0 ? current : EMPTY_FOLD_SET))
|
|
1009
|
+
}
|
|
1010
|
+
}, [active, order.length])
|
|
1011
|
+
|
|
1012
|
+
useMeasuredWidths({
|
|
1013
|
+
resolveRoot: () => wrapperRef.current,
|
|
1014
|
+
signature: `${order.join('|')}::${fixedIds.join('|')}`,
|
|
1015
|
+
enabled: active && order.length > 0,
|
|
1016
|
+
// The table too, not only the wrapper: a re-measured column set moves its min-content width
|
|
1017
|
+
// while the wrapper's own box never changes.
|
|
1018
|
+
extra: (root) => [root.querySelector('table')],
|
|
1019
|
+
onMeasure: (root, boxes) => {
|
|
1020
|
+
for (const cell of root.querySelectorAll<HTMLElement>(`[${FOLD_ID_ATTR}]`)) {
|
|
1021
|
+
const id = cell.getAttribute(FOLD_ID_ATTR)
|
|
1022
|
+
if (id !== null && cell.offsetWidth > 0) {
|
|
1023
|
+
boxes.current.set(id, { width: cell.offsetWidth, height: cell.offsetHeight })
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const room = root.clientWidth
|
|
1027
|
+
// A zero reading is an un-laid-out ancestor, not a table with no room — the same guard
|
|
1028
|
+
// `useMeasuredContainment` above runs against the aside animating in from 0.
|
|
1029
|
+
if (room === 0) return
|
|
1030
|
+
const widths = new Map([...boxes.current].map(([id, box]) => [id, box.width]))
|
|
1031
|
+
const overhead = fixedIdsRef.current.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0)
|
|
1032
|
+
const next = planColumnFold({
|
|
1033
|
+
room,
|
|
1034
|
+
// The disclosure toggle is a fixed icon-button cell matched to the coarse hit floor, so it
|
|
1035
|
+
// reads as one more `--vx-hit` control rather than a narrower afterthought.
|
|
1036
|
+
toggleWidth: HIT_COARSE,
|
|
1037
|
+
overhead,
|
|
1038
|
+
order: orderRef.current,
|
|
1039
|
+
widths,
|
|
1040
|
+
})
|
|
1041
|
+
setFolded((current) =>
|
|
1042
|
+
current.size === next.size && [...current].every((id) => next.has(id)) ? current : next,
|
|
1043
|
+
)
|
|
1044
|
+
},
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
return { wrapperRef, folded: active ? folded : EMPTY_FOLD_SET }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** The label half of a folded column's disclosure row — its own header when it is a plain string
|
|
1051
|
+
* (the common case), else the column id, since a function/`ReactNode` header has no header
|
|
1052
|
+
* CONTEXT to render with here (this is a body row, not the `<thead>`). */
|
|
1053
|
+
function columnLabel<T>(column: Column<T, unknown>): string {
|
|
1054
|
+
const header = column.columnDef.header
|
|
1055
|
+
return typeof header === 'string' ? header : column.id
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** One row's folded columns as label/value pairs, directly under it. `row.getAllCells()` returns
|
|
1059
|
+
* every leaf cell regardless of visibility (unlike `getVisibleCells()`), so a folded column's own
|
|
1060
|
+
* `cell` renderer still draws here exactly as it would in the `<td>` it no longer occupies. */
|
|
1061
|
+
function RowDisclosure<T>({
|
|
1062
|
+
row,
|
|
1063
|
+
folded,
|
|
1064
|
+
}: {
|
|
1065
|
+
row: Row<T>
|
|
1066
|
+
folded: ReadonlySet<string>
|
|
1067
|
+
}): ReactNode {
|
|
1068
|
+
const cells = row.getAllCells().filter((cell) => folded.has(cell.column.id))
|
|
1069
|
+
return (
|
|
1070
|
+
<div className={classes.foldDisclosure}>
|
|
1071
|
+
{cells.map((cell) => (
|
|
1072
|
+
<div key={cell.id} className={classes.foldDisclosureRow}>
|
|
1073
|
+
<Text className={classes.foldDisclosureLabel}>{columnLabel(cell.column)}</Text>
|
|
1074
|
+
<div className={classes.foldDisclosureValue}>
|
|
1075
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1076
|
+
</div>
|
|
1077
|
+
</div>
|
|
1078
|
+
))}
|
|
1079
|
+
</div>
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** The per-row expand/collapse control the fold-disclosure column renders — a text glyph, since
|
|
1084
|
+
* basalt ships no icon set (`docs/CONTROLS-SPEC.md` §2, mirrors `SortIndicator` above). */
|
|
1085
|
+
function FoldToggle({
|
|
1086
|
+
expanded,
|
|
1087
|
+
controlsId,
|
|
1088
|
+
onToggle,
|
|
1089
|
+
}: {
|
|
1090
|
+
expanded: boolean
|
|
1091
|
+
controlsId: string
|
|
1092
|
+
onToggle: () => void
|
|
1093
|
+
}): ReactNode {
|
|
1094
|
+
return (
|
|
1095
|
+
<button
|
|
1096
|
+
type="button"
|
|
1097
|
+
className={classes.foldToggle}
|
|
1098
|
+
data-basalt-hit
|
|
1099
|
+
aria-expanded={expanded}
|
|
1100
|
+
aria-controls={controlsId}
|
|
1101
|
+
aria-label={expanded ? 'Hide row details' : 'Show row details'}
|
|
1102
|
+
// A row carrying `onRowActivate` reads a bubbled click as "activate the row" — the toggle
|
|
1103
|
+
// would open/close its disclosure AND fire the row's own action underneath it.
|
|
1104
|
+
onClick={(event) => {
|
|
1105
|
+
event.stopPropagation()
|
|
1106
|
+
onToggle()
|
|
1107
|
+
}}
|
|
1108
|
+
>
|
|
1109
|
+
<Box component="span" aria-hidden>
|
|
1110
|
+
{expanded ? '▾' : '▸'}
|
|
1111
|
+
</Box>
|
|
1112
|
+
</button>
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
887
1116
|
// ── The manual-pagination contract ────────────────────────────────────────────
|
|
888
1117
|
|
|
889
1118
|
/** One client-side control that `manualPagination` turns into a claim the table cannot support. */
|
|
@@ -973,38 +1202,20 @@ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): vo
|
|
|
973
1202
|
* statically: `stickyHeader` with neither `maxHeight` nor `minWidth`.
|
|
974
1203
|
*
|
|
975
1204
|
* 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.
|
|
983
|
-
*
|
|
984
|
-
* So the wrapper's overflow is decided by MEASUREMENT, the same fit-check `useTrackFits`
|
|
985
|
-
* (`controls/panel-row.tsx`) runs for a segmented track. While the table FITS its wrapper the
|
|
986
|
-
* wrapper is `overflow: visible` — bare, and the page-sticky header sticks against `AppShell.Main`
|
|
987
|
-
* exactly as it did before. Once the table is WIDER the wrapper flips to `overflow-x: auto`, the
|
|
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.
|
|
991
|
-
*
|
|
992
|
-
* Three properties that are easy to lose:
|
|
1205
|
+
* sizes to its own min-content and would drag the page sideways. This shape cannot take that
|
|
1206
|
+
* container unconditionally: an `overflow-x: auto` box also computes `overflow-y`, making it the
|
|
1207
|
+
* sticky header's own scrollport with no scroll range, so the `<thead>` scrolls clean away.
|
|
993
1208
|
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
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.
|
|
1209
|
+
* So the wrapper's overflow is MEASURED. While the table FITS, the wrapper is bare and the sticky
|
|
1210
|
+
* header sticks; once it is WIDER, the wrapper flips to `overflow-x: auto` and the header is inert.
|
|
1211
|
+
* The trade is deliberate: a header that stops sticking is recoverable, a page that scrolls
|
|
1212
|
+
* sideways is not.
|
|
1004
1213
|
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1214
|
+
* Three invariants: the observer stays alive in BOTH states (no one-way latch, so the wrapper
|
|
1215
|
+
* reverts when the space returns); equality counts as FITS (a `width: 100%` table reads
|
|
1216
|
+
* `offsetWidth === clientWidth`, which stops oscillation); and a zero `clientWidth` is `unknown`,
|
|
1217
|
+
* not overflow (the aside animates its width in from 0). `useLayoutEffect` runs before paint, so an
|
|
1218
|
+
* already-measurable table resolves without a flash; the default is bare for SSR and first paint.
|
|
1008
1219
|
*/
|
|
1009
1220
|
function useMeasuredContainment(active: boolean): {
|
|
1010
1221
|
wrapperRef: RefObject<HTMLDivElement | null>
|
|
@@ -1013,50 +1224,26 @@ function useMeasuredContainment(active: boolean): {
|
|
|
1013
1224
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
1014
1225
|
const [contained, setContained] = useState(false)
|
|
1015
1226
|
|
|
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')
|
|
1227
|
+
// The same measured-fold core `useColumnFold` uses, so this shape no longer mounts a second
|
|
1228
|
+
// bespoke `ResizeObserver` on the wrapper + table.
|
|
1229
|
+
useMeasuredWidths({
|
|
1230
|
+
resolveRoot: () => wrapperRef.current,
|
|
1231
|
+
signature: String(active),
|
|
1232
|
+
enabled: active,
|
|
1047
1233
|
// The TABLE too, not only the wrapper: a column set that re-measures (a facet filter changing
|
|
1048
1234
|
// the widest cell, a font settling) moves the table's min-content width while the wrapper's
|
|
1049
1235
|
// box never changes at all.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1236
|
+
extra: (root) => [root.querySelector('table')],
|
|
1237
|
+
onMeasure: (root) => {
|
|
1238
|
+
const table = root.querySelector('table')
|
|
1239
|
+
// Three answers, not two — see the docblock. A zero reading is an un-laid-out ancestor
|
|
1240
|
+
// (the aside animating in from 0), not overflow, so it moves neither the state nor the
|
|
1241
|
+
// observer; equality counts as FITS and stops the fit/contained oscillation.
|
|
1242
|
+
if (table === null || root.clientWidth === 0 || table.offsetWidth === 0) return
|
|
1243
|
+
const next = table.offsetWidth > root.clientWidth
|
|
1244
|
+
setContained((current) => (current === next ? current : next))
|
|
1245
|
+
},
|
|
1246
|
+
})
|
|
1060
1247
|
|
|
1061
1248
|
return { wrapperRef, contained: active && contained }
|
|
1062
1249
|
}
|
|
@@ -1268,7 +1455,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1268
1455
|
if (!facets || facets.length === 0) return columns
|
|
1269
1456
|
const facetById = new Map(facets.map((facet) => [facet.columnId, facet]))
|
|
1270
1457
|
return columns.map((column) => {
|
|
1271
|
-
const id =
|
|
1458
|
+
const id = columnDefId(column)
|
|
1272
1459
|
const facet = id === undefined ? undefined : facetById.get(id)
|
|
1273
1460
|
if (!facet) return column
|
|
1274
1461
|
const filterFn: FilterFn<T> = facet.multiple
|
|
@@ -1318,6 +1505,97 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1318
1505
|
const filteringEnabled = !breaches.includes('filtering')
|
|
1319
1506
|
const totalIsAuthoritative = !breaches.includes('total')
|
|
1320
1507
|
|
|
1508
|
+
// A capped body or a horizontal floor both need the same native scroll node — computed here,
|
|
1509
|
+
// ahead of the table, because `pageStickyHeader` (further down) needs it: `maxHeight` alone still
|
|
1510
|
+
// wants its own `ScrollArea` scrollport (a sticky `<thead>` resolves against it, not the measured
|
|
1511
|
+
// containment div, which offers no vertical scroll box at all), so EITHER prop rules out the
|
|
1512
|
+
// measured-containment shape.
|
|
1513
|
+
const scrolls = maxHeight !== undefined || minWidth !== undefined
|
|
1514
|
+
|
|
1515
|
+
// Fold eligibility is narrower than `scrolls`: only `minWidth` is a genuinely DECLARED horizontal
|
|
1516
|
+
// floor — the caller is stating "this table is at least this wide," which folding columns out of
|
|
1517
|
+
// would contradict. `maxHeight` alone is a purely VERTICAL cap and orthogonal to column width, so
|
|
1518
|
+
// a `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend for a
|
|
1519
|
+
// capped, sticky-header body) still folds instead of silently regressing to horizontal scroll.
|
|
1520
|
+
const foldEligible = minWidth === undefined
|
|
1521
|
+
|
|
1522
|
+
// The fold order is computed off the caller's OWN `columns` — never `tableColumns` — so the
|
|
1523
|
+
// selection checkbox (prepended below) is never a fold candidate; see `foldOrder`'s docblock for
|
|
1524
|
+
// the declared-order default and the explicit-priority rule. `enableHiding: false` and a PINNED
|
|
1525
|
+
// column are excluded outright, not merely de-prioritized — both are the caller stating this
|
|
1526
|
+
// column must stay visible, the same declaration a fold candidate has no way to override.
|
|
1527
|
+
const { columnFoldOrder, columnFoldFixedIds } = useMemo(() => {
|
|
1528
|
+
const pinnedIds = new Set([...(columnPinning.left ?? []), ...(columnPinning.right ?? [])])
|
|
1529
|
+
const excluded = new Set(
|
|
1530
|
+
columns.flatMap((column) => {
|
|
1531
|
+
const id = columnDefId(column)
|
|
1532
|
+
if (id === undefined) return []
|
|
1533
|
+
return column.enableHiding === false || pinnedIds.has(id) ? [id] : []
|
|
1534
|
+
}),
|
|
1535
|
+
)
|
|
1536
|
+
return {
|
|
1537
|
+
columnFoldOrder: foldOrder(columns).filter((id) => !excluded.has(id)),
|
|
1538
|
+
columnFoldFixedIds: [...excluded],
|
|
1539
|
+
}
|
|
1540
|
+
}, [columns, columnPinning])
|
|
1541
|
+
const columnFold = useColumnFold({
|
|
1542
|
+
active: foldEligible,
|
|
1543
|
+
order: columnFoldOrder,
|
|
1544
|
+
fixedIds: columnFoldFixedIds,
|
|
1545
|
+
})
|
|
1546
|
+
const columnVisibility: VisibilityState = useMemo(
|
|
1547
|
+
() => Object.fromEntries([...columnFold.folded].map((id) => [id, false])),
|
|
1548
|
+
[columnFold.folded],
|
|
1549
|
+
)
|
|
1550
|
+
const hasFolded = columnFold.folded.size > 0
|
|
1551
|
+
const [expandedFoldRows, setExpandedFoldRows] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
|
|
1552
|
+
const toggleFoldRow = useCallback((rowId: string) => {
|
|
1553
|
+
setExpandedFoldRows((current) => {
|
|
1554
|
+
const next = new Set(current)
|
|
1555
|
+
if (next.has(rowId)) next.delete(rowId)
|
|
1556
|
+
else next.add(rowId)
|
|
1557
|
+
return next
|
|
1558
|
+
})
|
|
1559
|
+
}, [])
|
|
1560
|
+
// `row.id` defaults to the row's index in `data` (no `getRowId` passed) — stable across a
|
|
1561
|
+
// client-side sort/filter/pagination (they reorder or subset the SAME core rows). PRUNED rather
|
|
1562
|
+
// than cleared on every `data` change: a non-memoized `data` prop (an inline `.map()`, an
|
|
1563
|
+
// unstable query result) hands a brand-new array of the SAME rows on every parent re-render, and
|
|
1564
|
+
// clearing on that reference alone collapsed every open disclosure the instant anything upstream
|
|
1565
|
+
// re-rendered.
|
|
1566
|
+
//
|
|
1567
|
+
// Pruning by ID ALONE is only safe with `getRowId` — a caller-declared identity that genuinely
|
|
1568
|
+
// survives a page turn or a refetch, so an id no longer present (a manual-pagination page turn, a
|
|
1569
|
+
// delete) still has to go, or a differently-shaped record seated at a reused id would silently
|
|
1570
|
+
// read as pre-expanded. Without `getRowId` the id IS the array index, and an index survives a
|
|
1571
|
+
// refetch trivially — it is still "0", "1", … even when every object behind it is now a
|
|
1572
|
+
// completely different record. Keeping an expanded index across THAT would silently attach the
|
|
1573
|
+
// old disclosure to a stranger's row. So the index-id lane instead keeps a `prevData` snapshot and
|
|
1574
|
+
// asks the narrower, correct question: is the object AT this index still the same reference as
|
|
1575
|
+
// last time? A non-memoized array of the SAME rows answers yes at every index (the case above);
|
|
1576
|
+
// a page turn or a refetch answers no.
|
|
1577
|
+
const prevDataRef = useRef(data)
|
|
1578
|
+
useEffect(() => {
|
|
1579
|
+
const prevData = prevDataRef.current
|
|
1580
|
+
prevDataRef.current = data
|
|
1581
|
+
setExpandedFoldRows((current) => {
|
|
1582
|
+
if (current.size === 0) return current
|
|
1583
|
+
let next: ReadonlySet<string>
|
|
1584
|
+
if (getRowId === undefined) {
|
|
1585
|
+
next = new Set(
|
|
1586
|
+
[...current].filter((id) => {
|
|
1587
|
+
const index = Number(id)
|
|
1588
|
+
return data[index] !== undefined && data[index] === prevData[index]
|
|
1589
|
+
}),
|
|
1590
|
+
)
|
|
1591
|
+
} else {
|
|
1592
|
+
const liveIds = new Set(data.map((row, index) => getRowId(row, index)))
|
|
1593
|
+
next = new Set([...current].filter((id) => liveIds.has(id)))
|
|
1594
|
+
}
|
|
1595
|
+
return next.size === current.size ? current : next
|
|
1596
|
+
})
|
|
1597
|
+
}, [data, getRowId])
|
|
1598
|
+
|
|
1321
1599
|
const table = useReactTable<T>({
|
|
1322
1600
|
data,
|
|
1323
1601
|
columns: tableColumns,
|
|
@@ -1326,6 +1604,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1326
1604
|
globalFilter,
|
|
1327
1605
|
columnFilters,
|
|
1328
1606
|
columnPinning,
|
|
1607
|
+
columnVisibility,
|
|
1329
1608
|
...(enablePagination && { pagination }),
|
|
1330
1609
|
...(enableRowSelection && { rowSelection: rowSelectionState }),
|
|
1331
1610
|
},
|
|
@@ -1371,10 +1650,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1371
1650
|
const showSkeleton = branch === undefined ? isLoading : branch === 'pending'
|
|
1372
1651
|
const queryError = branch === 'error' && query !== undefined ? query : undefined
|
|
1373
1652
|
|
|
1374
|
-
// Every colSpan and every skeleton row counts the RENDERED columns
|
|
1375
|
-
//
|
|
1376
|
-
// error row
|
|
1377
|
-
const columnCount =
|
|
1653
|
+
// Every colSpan and every skeleton row counts the RENDERED columns: one more than `columns` for
|
|
1654
|
+
// the prepended checkbox column, one more again for the fold-disclosure toggle once anything has
|
|
1655
|
+
// folded — a stale count left the empty state and the error row short of the header.
|
|
1656
|
+
const columnCount =
|
|
1657
|
+
columns.length + (enableRowSelection ? 1 : 0) + (hasFolded ? 1 : 0) - columnFold.folded.size
|
|
1378
1658
|
|
|
1379
1659
|
// `getSelectedRowModel()` is TanStack's own memo, so its `rows` array keeps its identity until
|
|
1380
1660
|
// the selection or the row model actually moves — which is what makes both memos below hold.
|
|
@@ -1400,8 +1680,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1400
1680
|
|
|
1401
1681
|
// A capped body or a horizontal floor both need the same native scroll node. Everything else —
|
|
1402
1682
|
// 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
|
-
|
|
1683
|
+
// measured wrapper below; there is no third overflow box. (`scrolls` itself is computed earlier,
|
|
1684
|
+
// ahead of the table, because the column fold above needs it too.)
|
|
1405
1685
|
// `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
|
|
1406
1686
|
// scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
|
|
1407
1687
|
// always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
|
|
@@ -1440,6 +1720,9 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1440
1720
|
<Table.Thead>
|
|
1441
1721
|
{headerGroups.map((headerGroup) => (
|
|
1442
1722
|
<Table.Tr key={headerGroup.id}>
|
|
1723
|
+
{/* The disclosure toggle column has no header of its own — nothing is "sorted" or
|
|
1724
|
+
"selected all" across it, only revealed per row. */}
|
|
1725
|
+
{hasFolded && <Table.Th className={classes.foldToggleCell} aria-hidden />}
|
|
1443
1726
|
{headerGroup.headers.map((header) => {
|
|
1444
1727
|
const canSort = sortingEnabled && header.column.getCanSort()
|
|
1445
1728
|
const sorted = header.column.getIsSorted()
|
|
@@ -1448,9 +1731,14 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1448
1731
|
? getPinnedCellStyle(table, header.column)
|
|
1449
1732
|
: undefined
|
|
1450
1733
|
const align = resolveAlign(header.column)
|
|
1734
|
+
const isSelectColumn = header.column.id === SELECT_COLUMN_ID
|
|
1451
1735
|
return (
|
|
1452
1736
|
<Table.Th
|
|
1453
1737
|
key={header.id}
|
|
1738
|
+
{...(isSelectColumn && { className: classes.selectCell })}
|
|
1739
|
+
// The measured fold's own read (`useColumnFold`) — never present on the
|
|
1740
|
+
// selection column, which is never a fold candidate.
|
|
1741
|
+
{...(!isSelectColumn && { [FOLD_ID_ATTR]: header.column.id })}
|
|
1454
1742
|
onClick={canSort ? toggleSorting : undefined}
|
|
1455
1743
|
onKeyDown={
|
|
1456
1744
|
canSort
|
|
@@ -1532,51 +1820,77 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1532
1820
|
...row.getRightVisibleCells(),
|
|
1533
1821
|
]
|
|
1534
1822
|
: row.getVisibleCells()
|
|
1823
|
+
const rowExpanded = hasFolded && expandedFoldRows.has(row.id)
|
|
1535
1824
|
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
|
-
key={cell.id}
|
|
1566
|
-
style={{
|
|
1567
|
-
...(typeof cell.getValue() === 'number' &&
|
|
1568
|
-
cell.column.columnDef.meta?.numeral !== false
|
|
1569
|
-
? NUMERIC_CELL_STYLE
|
|
1570
|
-
: undefined),
|
|
1571
|
-
...(align !== undefined && { textAlign: align }),
|
|
1572
|
-
...pinnedStyle,
|
|
1573
|
-
}}
|
|
1574
|
-
>
|
|
1575
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1825
|
+
<Fragment key={row.id}>
|
|
1826
|
+
<Table.Tr
|
|
1827
|
+
{...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
|
|
1828
|
+
{...(onRowActivate !== undefined && {
|
|
1829
|
+
className: classes.activatable,
|
|
1830
|
+
'data-activatable': true,
|
|
1831
|
+
tabIndex: 0,
|
|
1832
|
+
onClick: () => onRowActivate(row.original),
|
|
1833
|
+
// Enter only. Space is the browser's own page-scroll on a focused non-button, and
|
|
1834
|
+
// stealing it from a keyboard reader moving down a long table costs more than the
|
|
1835
|
+
// second activation key buys.
|
|
1836
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
|
|
1837
|
+
if (event.key !== 'Enter') return
|
|
1838
|
+
// Only the ROW's own Enter. A cell may hold a button, a link or the selection
|
|
1839
|
+
// checkbox, and keydown bubbles — so without this an Enter on a nested control
|
|
1840
|
+
// fired that control AND opened the row's detail behind it.
|
|
1841
|
+
if (event.target !== event.currentTarget) return
|
|
1842
|
+
event.preventDefault()
|
|
1843
|
+
onRowActivate(row.original)
|
|
1844
|
+
},
|
|
1845
|
+
})}
|
|
1846
|
+
>
|
|
1847
|
+
{hasFolded && (
|
|
1848
|
+
<Table.Td className={classes.foldToggleCell}>
|
|
1849
|
+
<FoldToggle
|
|
1850
|
+
expanded={rowExpanded}
|
|
1851
|
+
controlsId={`${row.id}-fold`}
|
|
1852
|
+
onToggle={() => toggleFoldRow(row.id)}
|
|
1853
|
+
/>
|
|
1576
1854
|
</Table.Td>
|
|
1577
|
-
)
|
|
1578
|
-
|
|
1579
|
-
|
|
1855
|
+
)}
|
|
1856
|
+
{cells.map((cell) => {
|
|
1857
|
+
const pinnedStyle = enablePinning
|
|
1858
|
+
? getPinnedCellStyle(table, cell.column)
|
|
1859
|
+
: undefined
|
|
1860
|
+
const align = resolveAlign(cell.column)
|
|
1861
|
+
return (
|
|
1862
|
+
<Table.Td
|
|
1863
|
+
key={cell.id}
|
|
1864
|
+
{...(cell.column.id === SELECT_COLUMN_ID && {
|
|
1865
|
+
className: classes.selectCell,
|
|
1866
|
+
// The label's ::after fills the cell, so a tap on it bubbles to the row —
|
|
1867
|
+
// stop it only where a tick is possible; a disabled row's tap is a row tap.
|
|
1868
|
+
...(cell.row.getCanSelect() && {
|
|
1869
|
+
onClick: (event: ReactMouseEvent) => event.stopPropagation(),
|
|
1870
|
+
}),
|
|
1871
|
+
})}
|
|
1872
|
+
style={{
|
|
1873
|
+
...(typeof cell.getValue() === 'number' &&
|
|
1874
|
+
cell.column.columnDef.meta?.numeral !== false
|
|
1875
|
+
? NUMERIC_CELL_STYLE
|
|
1876
|
+
: undefined),
|
|
1877
|
+
...(align !== undefined && { textAlign: align }),
|
|
1878
|
+
...pinnedStyle,
|
|
1879
|
+
}}
|
|
1880
|
+
>
|
|
1881
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1882
|
+
</Table.Td>
|
|
1883
|
+
)
|
|
1884
|
+
})}
|
|
1885
|
+
</Table.Tr>
|
|
1886
|
+
{rowExpanded && (
|
|
1887
|
+
<Table.Tr id={`${row.id}-fold`} className={classes.foldDisclosureRowTr}>
|
|
1888
|
+
<Table.Td colSpan={columnCount} className={classes.foldDisclosureCell}>
|
|
1889
|
+
<RowDisclosure row={row} folded={columnFold.folded} />
|
|
1890
|
+
</Table.Td>
|
|
1891
|
+
</Table.Tr>
|
|
1892
|
+
)}
|
|
1893
|
+
</Fragment>
|
|
1580
1894
|
)
|
|
1581
1895
|
})
|
|
1582
1896
|
)}
|
|
@@ -1671,25 +1985,44 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1671
1985
|
{bulkBarActions !== undefined && (
|
|
1672
1986
|
<BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
|
|
1673
1987
|
)}
|
|
1674
|
-
{
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
<
|
|
1679
|
-
ref={containment.wrapperRef}
|
|
1680
|
-
className={classes.containment}
|
|
1681
|
-
data-contained={containment.contained ? 'true' : 'false'}
|
|
1682
|
-
>
|
|
1683
|
-
{tableNode}
|
|
1684
|
-
</div>
|
|
1685
|
-
) : (
|
|
1686
|
-
<Table.ScrollContainer
|
|
1687
|
-
type="native"
|
|
1688
|
-
minWidth={minWidth ?? 0}
|
|
1689
|
-
{...(maxHeight !== undefined && { maxHeight })}
|
|
1690
|
-
>
|
|
1988
|
+
{!foldEligible ? (
|
|
1989
|
+
// An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
|
|
1990
|
+
// never engages here (see `foldEligible`, above), so this shape keeps its exact
|
|
1991
|
+
// pre-existing DOM: no extra measuring wrapper for a table nothing folds.
|
|
1992
|
+
<Table.ScrollContainer type="native" minWidth={minWidth ?? 0} maxHeight={maxHeight}>
|
|
1691
1993
|
{tableNode}
|
|
1692
1994
|
</Table.ScrollContainer>
|
|
1995
|
+
) : (
|
|
1996
|
+
// The column fold's own measuring box (`useColumnFold`) — wraps every remaining shape,
|
|
1997
|
+
// `maxHeight`-only included: a vertical cap is orthogonal to column width, so a
|
|
1998
|
+
// `stickyHeader` + `maxHeight` table (the pairing this package's own docs recommend) still
|
|
1999
|
+
// folds instead of silently regressing to horizontal scroll. A plain block box, so it
|
|
2000
|
+
// constrains nothing on its own; `columnFold.wrapperRef` only reads it.
|
|
2001
|
+
<div ref={columnFold.wrapperRef} className={classes.foldWrapper}>
|
|
2002
|
+
{pageStickyHeader ? (
|
|
2003
|
+
// Pinning takes this wrapper too — a pinned column's offsets are `position: sticky` on
|
|
2004
|
+
// the cells and need no overflow box of their own, so the `overflow-x: auto` Box that
|
|
2005
|
+
// used to sit here was the exact inert-sticky defect this shape exists to avoid.
|
|
2006
|
+
<div
|
|
2007
|
+
ref={containment.wrapperRef}
|
|
2008
|
+
className={classes.containment}
|
|
2009
|
+
data-contained={containment.contained ? 'true' : 'false'}
|
|
2010
|
+
>
|
|
2011
|
+
{tableNode}
|
|
2012
|
+
</div>
|
|
2013
|
+
) : maxHeight !== undefined ? (
|
|
2014
|
+
// `pageStickyHeader` is already false whenever `maxHeight` is set (`scrolls` above), so
|
|
2015
|
+
// this is the ONE remaining case: a vertical cap with no sticky header to measure —
|
|
2016
|
+
// still wants its own `ScrollArea` scrollport, just with no `minWidth` floor.
|
|
2017
|
+
<Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
|
|
2018
|
+
{tableNode}
|
|
2019
|
+
</Table.ScrollContainer>
|
|
2020
|
+
) : (
|
|
2021
|
+
<Table.ScrollContainer type="native" minWidth={0}>
|
|
2022
|
+
{tableNode}
|
|
2023
|
+
</Table.ScrollContainer>
|
|
2024
|
+
)}
|
|
2025
|
+
</div>
|
|
1693
2026
|
)}
|
|
1694
2027
|
{enablePagination && (
|
|
1695
2028
|
<Group
|
|
@@ -1714,13 +2047,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1714
2047
|
onChange={(value) => value && table.setPageSize(Number(value))}
|
|
1715
2048
|
allowDeselect={false}
|
|
1716
2049
|
/>
|
|
1717
|
-
<
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
2050
|
+
<CtlSlot>
|
|
2051
|
+
{/* `Pagination` carries no `ctl`-tier entry in `CTL_THEME` (its own controls resolve
|
|
2052
|
+
the 44px coarse hit floor through `PaginationControl`'s theme-level
|
|
2053
|
+
`data-basalt-hit`, not through the `ctl` size), so `CtlSlot` cannot restore this
|
|
2054
|
+
size on its own — an explicit `size="sm"` is the visual this footer always had. */}
|
|
2055
|
+
<Pagination
|
|
2056
|
+
size="sm"
|
|
2057
|
+
radius="md"
|
|
2058
|
+
total={Math.max(table.getPageCount(), 1)}
|
|
2059
|
+
value={paginationState.pageIndex + 1}
|
|
2060
|
+
onChange={(page) => table.setPageIndex(page - 1)}
|
|
2061
|
+
/>
|
|
2062
|
+
</CtlSlot>
|
|
1724
2063
|
</Group>
|
|
1725
2064
|
</Group>
|
|
1726
2065
|
)}
|