basalt-ui 1.31.0 → 1.32.1
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/CHANGELOG.md +34 -0
- package/MIGRATING.md +212 -20
- package/agent/rules/basalt-mantine.md +4 -4
- package/configs/oxlint-plugin.js +240 -59
- package/configs/oxlint-plugin.test.ts +329 -1
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +18 -66
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +5 -26
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +10 -68
- 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/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +49 -76
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +5 -23
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -40
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +5 -133
- 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 +16 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +17 -1
- package/dist/charts/primitives/chart-card-context.d.ts +17 -13
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
- package/dist/charts/primitives/chart-card-context.js +10 -5
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +25 -31
- 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/controls/controls.module.css +5 -0
- package/dist/controls/sync-button.module.css +1 -0
- package/dist/dashboard/stat-group.module.css +19 -64
- package/dist/dashboard/widget-grid.module.css +11 -38
- 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 +40 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +179 -277
- package/dist/forms/form-layout.d.ts +6 -1
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.module.css +6 -13
- package/dist/guard/index.d.ts +2 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +10 -14
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +12 -10
- 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 +0 -11
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +13 -14
- package/dist/shell/page-bar.d.ts +24 -0
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.d.ts +1 -1
- package/dist/theme/floating.module.css +1 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +4 -0
- package/llms.txt +2 -2
- package/package.json +2 -1
- package/src/charts/kinds/BandStrip.tsx +18 -64
- package/src/charts/kinds/DualPanel.tsx +4 -24
- package/src/charts/kinds/MirroredBars.tsx +12 -66
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -122
- package/src/charts/primitives/ChartCard.tsx +5 -27
- package/src/charts/primitives/ChartFrame.tsx +37 -76
- package/src/charts/primitives/ChartLegend.tsx +7 -154
- package/src/charts/primitives/ChartTooltip.tsx +16 -0
- package/src/charts/primitives/HoverOverlay.tsx +52 -21
- package/src/charts/primitives/chart-card-context.ts +25 -16
- package/src/charts/primitives/chart-frame-layout.ts +44 -56
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/controls/controls.module.css +5 -0
- package/src/controls/sync-button.module.css +1 -0
- package/src/dashboard/stat-group.module.css +19 -64
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.tsx +134 -218
- package/src/forms/form-layout.module.css +6 -13
- package/src/forms/form-layout.tsx +6 -1
- package/src/guard/index.ts +38 -34
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +18 -17
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +15 -37
- package/src/shell/page-bar.tsx +24 -0
- package/src/shell/use-breakpoint.ts +2 -2
- package/src/theme/floating.module.css +1 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
- package/src/tokens/index.ts +5 -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,7 +50,6 @@ import type {
|
|
|
48
50
|
Column,
|
|
49
51
|
ColumnDef,
|
|
50
52
|
ColumnFiltersState,
|
|
51
|
-
ColumnPinningState,
|
|
52
53
|
FilterFn,
|
|
53
54
|
PaginationState,
|
|
54
55
|
Row,
|
|
@@ -56,7 +57,6 @@ import type {
|
|
|
56
57
|
RowSelectionState,
|
|
57
58
|
SortingState,
|
|
58
59
|
Table as TanstackTable,
|
|
59
|
-
Updater,
|
|
60
60
|
VisibilityState,
|
|
61
61
|
} from '@tanstack/react-table'
|
|
62
62
|
import {
|
|
@@ -67,14 +67,8 @@ import {
|
|
|
67
67
|
getSortedRowModel,
|
|
68
68
|
useReactTable,
|
|
69
69
|
} from '@tanstack/react-table'
|
|
70
|
-
import type {
|
|
71
|
-
|
|
72
|
-
KeyboardEvent as ReactKeyboardEvent,
|
|
73
|
-
MouseEvent as ReactMouseEvent,
|
|
74
|
-
ReactNode,
|
|
75
|
-
RefObject,
|
|
76
|
-
} from 'react'
|
|
77
|
-
import { Fragment, useCallback, useEffect, 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'
|
|
78
72
|
import { cx } from '../common/props'
|
|
79
73
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
80
74
|
import { BASALT_PREFIX } from '../common/errors'
|
|
@@ -96,6 +90,13 @@ import type { EnumField, FieldHandle, MultiField } from '../state'
|
|
|
96
90
|
import { WidgetHeader } from '../dashboard'
|
|
97
91
|
import { ErrorState } from '../dashboard/query-state'
|
|
98
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'
|
|
99
100
|
import { dataQueryBranch } from './query-branch'
|
|
100
101
|
import { isDev } from '../common/is-dev'
|
|
101
102
|
import classes from './data-table.module.css'
|
|
@@ -388,8 +389,9 @@ function BulkActionBar({ count, actions }: { count: number; actions: BarAction[]
|
|
|
388
389
|
|
|
389
390
|
/**
|
|
390
391
|
* The four boxes `BasaltDataTable` paints: `root` is the outer fragment's replacement wrapper,
|
|
391
|
-
* `toolbar` is the search/facets/actions row, `table` is
|
|
392
|
-
* 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.
|
|
393
395
|
*/
|
|
394
396
|
export type BasaltDataTableSlot = 'root' | 'toolbar' | 'table' | 'footer'
|
|
395
397
|
|
|
@@ -595,6 +597,40 @@ export type BasaltDataTableProps<T> = BasaltProps &
|
|
|
595
597
|
* <BasaltDataTable data={rows} columns={columns} onRowActivate={(row) => setSelected(row)} />
|
|
596
598
|
*/
|
|
597
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 — never Space, the
|
|
612
|
+
* browser's own page scroll on a focused non-button). The selection
|
|
613
|
+
* checkbox is not projected — a card list with a selection keeps its bulk bar but no ticks.
|
|
614
|
+
*
|
|
615
|
+
* An activatable card takes `tabIndex=0` + `data-activatable`, never `role="button"`: a card is
|
|
616
|
+
* where a row's actions and links land (argo's all do), and a `button` role makes every
|
|
617
|
+
* descendant presentational, so those nested controls would vanish from the accessibility tree.
|
|
618
|
+
* The affordance is the cursor and the focus ring, as on the row. `maxHeight` caps the card list
|
|
619
|
+
* in a `ScrollArea`; `minWidth` does not apply — it is a horizontal floor for columns, and a
|
|
620
|
+
* card list has none. `stickyHeader` has nothing to stick while the cards render. A card order
|
|
621
|
+
* with no user sort is `data`'s own order — sort it there (or `initialSorting`) for a domain
|
|
622
|
+
* order.
|
|
623
|
+
*
|
|
624
|
+
* Reach for it when the row carries more than the columns a phone can hold (a status + label +
|
|
625
|
+
* age header, a clamped title, a prose line). A table that only needs to shed a column or two
|
|
626
|
+
* already has that: the column fold hides the lowest-priority columns behind a per-row
|
|
627
|
+
* disclosure with no prop at all. Server render and first paint are the table.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* <BasaltDataTable data={items} columns={columns} onRowActivate={open}
|
|
631
|
+
* renderCard={(item) => <ItemCard item={item} />} />
|
|
632
|
+
*/
|
|
633
|
+
renderCard?: (row: T) => ReactNode
|
|
598
634
|
/**
|
|
599
635
|
* Prepends a checkbox column (header = select-all on the page) and arms TanStack's row-selection
|
|
600
636
|
* feature. Selection is uncontrolled unless `rowSelection` is passed.
|
|
@@ -728,6 +764,10 @@ export type BasaltDataTableProps<T> = BasaltProps &
|
|
|
728
764
|
* its own card instead of widening the page. Reach for this only to hold a MINIMUM table width
|
|
729
765
|
* — e.g. so eight columns keep their readable widths and scroll rather than compress.
|
|
730
766
|
*
|
|
767
|
+
* Setting it also switches the column fold OFF (a declared floor is the caller saying "this
|
|
768
|
+
* table is at least this wide", which folding columns out would contradict), so a `minWidth`
|
|
769
|
+
* table scrolls where the same table without it folds. `maxHeight` alone keeps the fold.
|
|
770
|
+
*
|
|
731
771
|
* The one table whose wrapper is MEASURED rather than declared is `stickyHeader` with neither
|
|
732
772
|
* this nor `maxHeight`; see that prop and `useMeasuredContainment` in the implementation for
|
|
733
773
|
* why, and pair it with `maxHeight` if the table must both scroll and stick.
|
|
@@ -795,6 +835,8 @@ function SortIndicator({ direction }: { direction: 'asc' | 'desc' | false }) {
|
|
|
795
835
|
)
|
|
796
836
|
}
|
|
797
837
|
|
|
838
|
+
const ARIA_SORT = { asc: 'ascending', desc: 'descending' } as const
|
|
839
|
+
|
|
798
840
|
// ── Numeral cell idiom (docs/DESIGN-SPEC.md §3: "Stat/table numeral: mono, 12-12.5px, weight
|
|
799
841
|
// 500, ink") — auto-detected from the cell's raw value (no column-level opt-in prop needed) so
|
|
800
842
|
// every numeric column gets tabular, mono figures with zero call-site configuration.
|
|
@@ -940,8 +982,6 @@ function foldOrder<T>(columns: readonly ColumnDef<T, unknown>[]): string[] {
|
|
|
940
982
|
* that hook, so it carries no styling and needs no CSS-module counterpart. */
|
|
941
983
|
const FOLD_ID_ATTR = 'data-basalt-fold-id'
|
|
942
984
|
|
|
943
|
-
const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
|
|
944
|
-
|
|
945
985
|
/**
|
|
946
986
|
* Pure decision: fold columns off the FRONT of `order` (already fold-first sorted) until the
|
|
947
987
|
* still-unfolded ones — plus `overhead` (the columns excluded from candidacy altogether — pinned,
|
|
@@ -1294,32 +1334,23 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1294
1334
|
isLoading = false,
|
|
1295
1335
|
query,
|
|
1296
1336
|
skeletonRows = 5,
|
|
1297
|
-
initialSorting,
|
|
1298
|
-
onSortingChange,
|
|
1299
1337
|
enableGlobalFilter = false,
|
|
1300
1338
|
globalFilterPlaceholder = 'Search…',
|
|
1301
1339
|
searchIcon,
|
|
1302
|
-
initialGlobalFilter,
|
|
1303
|
-
onGlobalFilterChange,
|
|
1304
1340
|
facets,
|
|
1305
|
-
onColumnFiltersChange,
|
|
1306
1341
|
manualFiltering = false,
|
|
1307
1342
|
actions,
|
|
1308
1343
|
onRowActivate,
|
|
1344
|
+
renderCard,
|
|
1309
1345
|
enableRowSelection = false,
|
|
1310
|
-
rowSelection,
|
|
1311
|
-
onRowSelectionChange,
|
|
1312
1346
|
getRowId,
|
|
1313
1347
|
bulkActions,
|
|
1314
1348
|
enablePagination = false,
|
|
1315
1349
|
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
|
1316
|
-
initialPagination,
|
|
1317
|
-
onPaginationChange,
|
|
1318
1350
|
manualPagination = false,
|
|
1319
1351
|
rowCount,
|
|
1320
1352
|
pageCount,
|
|
1321
1353
|
enablePinning = false,
|
|
1322
|
-
initialColumnPinning,
|
|
1323
1354
|
maxHeight,
|
|
1324
1355
|
minWidth,
|
|
1325
1356
|
stickyHeader,
|
|
@@ -1332,17 +1363,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1332
1363
|
style,
|
|
1333
1364
|
classNames,
|
|
1334
1365
|
} = props
|
|
1335
|
-
const
|
|
1336
|
-
const
|
|
1337
|
-
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
1338
|
-
const [pagination, setPagination] = useState<PaginationState>(
|
|
1339
|
-
initialPagination ?? { pageIndex: 0, pageSize: pageSizeOptions[0] ?? 10 },
|
|
1340
|
-
)
|
|
1341
|
-
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(initialColumnPinning ?? {})
|
|
1342
|
-
// Uncontrolled by default; `rowSelection`, when passed, is the truth and this only mirrors it so
|
|
1343
|
-
// the updater below has a base to apply against.
|
|
1344
|
-
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
|
|
1345
|
-
const rowSelectionState = rowSelection ?? internalRowSelection
|
|
1366
|
+
const tableState = useDataTableState(props, pageSizeOptions[0] ?? 10)
|
|
1367
|
+
const { globalFilter, columnPinning, onGlobalFilterChange: handleGlobalFilterChange } = tableState
|
|
1346
1368
|
|
|
1347
1369
|
// `stickyHeaderOffset` is DROPPED whenever the table owns a scroll container — see
|
|
1348
1370
|
// `resolvedStickyHeaderOffset` below for why it is always wrong there. Dropping it silently is
|
|
@@ -1385,69 +1407,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1385
1407
|
],
|
|
1386
1408
|
)
|
|
1387
1409
|
|
|
1388
|
-
const handleSortingChange = useCallback(
|
|
1389
|
-
(updater: Updater<SortingState>) => {
|
|
1390
|
-
setSorting((prev) => {
|
|
1391
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1392
|
-
onSortingChange?.(next)
|
|
1393
|
-
return next
|
|
1394
|
-
})
|
|
1395
|
-
},
|
|
1396
|
-
[onSortingChange],
|
|
1397
|
-
)
|
|
1398
|
-
|
|
1399
|
-
const handleGlobalFilterChange = useCallback(
|
|
1400
|
-
(updater: Updater<string>) => {
|
|
1401
|
-
setGlobalFilter((prev) => {
|
|
1402
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1403
|
-
onGlobalFilterChange?.(next)
|
|
1404
|
-
return next
|
|
1405
|
-
})
|
|
1406
|
-
},
|
|
1407
|
-
[onGlobalFilterChange],
|
|
1408
|
-
)
|
|
1409
|
-
|
|
1410
|
-
const handleColumnFiltersChange = useCallback(
|
|
1411
|
-
(updater: Updater<ColumnFiltersState>) => {
|
|
1412
|
-
setColumnFilters((prev) => {
|
|
1413
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1414
|
-
onColumnFiltersChange?.(next)
|
|
1415
|
-
return next
|
|
1416
|
-
})
|
|
1417
|
-
},
|
|
1418
|
-
[onColumnFiltersChange],
|
|
1419
|
-
)
|
|
1420
|
-
|
|
1421
|
-
const handlePaginationChange = useCallback(
|
|
1422
|
-
(updater: Updater<PaginationState>) => {
|
|
1423
|
-
setPagination((prev) => {
|
|
1424
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1425
|
-
onPaginationChange?.(next)
|
|
1426
|
-
return next
|
|
1427
|
-
})
|
|
1428
|
-
},
|
|
1429
|
-
[onPaginationChange],
|
|
1430
|
-
)
|
|
1431
|
-
|
|
1432
|
-
const handleRowSelectionChange = useCallback(
|
|
1433
|
-
(updater: Updater<RowSelectionState>) => {
|
|
1434
|
-
// CONTROLLED: the caller's map is the base AND the only writer. Mirroring it into state too
|
|
1435
|
-
// would leave a copy that the caller never moves, and that stale copy becomes the selection
|
|
1436
|
-
// the moment `rowSelection` goes back to `undefined` — plus every tick would render twice for
|
|
1437
|
-
// one change. Report the next map and let the caller own it.
|
|
1438
|
-
if (rowSelection !== undefined) {
|
|
1439
|
-
onRowSelectionChange?.(typeof updater === 'function' ? updater(rowSelection) : updater)
|
|
1440
|
-
return
|
|
1441
|
-
}
|
|
1442
|
-
setInternalRowSelection((prev) => {
|
|
1443
|
-
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
1444
|
-
onRowSelectionChange?.(next)
|
|
1445
|
-
return next
|
|
1446
|
-
})
|
|
1447
|
-
},
|
|
1448
|
-
[rowSelection, onRowSelectionChange],
|
|
1449
|
-
)
|
|
1450
|
-
|
|
1451
1410
|
// Facets are TanStack `columnFilters` entries under the hood — inject an exact-match (or
|
|
1452
1411
|
// any-of, for `multiple`) filterFn onto the matching column so the built-in `includesString`
|
|
1453
1412
|
// auto-filter (substring match) never misfires against a Select's exact option value.
|
|
@@ -1538,8 +1497,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1538
1497
|
columnFoldFixedIds: [...excluded],
|
|
1539
1498
|
}
|
|
1540
1499
|
}, [columns, columnPinning])
|
|
1500
|
+
// The card projection unmounts the table, so the fold and the containment wrapper go inactive with
|
|
1501
|
+
// it — their measuring effects re-attach to the remounted table when the width returns.
|
|
1502
|
+
const projection = useCardProjection(renderCard !== undefined)
|
|
1541
1503
|
const columnFold = useColumnFold({
|
|
1542
|
-
active: foldEligible,
|
|
1504
|
+
active: foldEligible && !projection.cards,
|
|
1543
1505
|
order: columnFoldOrder,
|
|
1544
1506
|
fixedIds: columnFoldFixedIds,
|
|
1545
1507
|
})
|
|
@@ -1548,74 +1510,28 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1548
1510
|
[columnFold.folded],
|
|
1549
1511
|
)
|
|
1550
1512
|
const hasFolded = columnFold.folded.size > 0
|
|
1551
|
-
const
|
|
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])
|
|
1513
|
+
const foldRows = useRowDisclosure({ data, getRowId })
|
|
1598
1514
|
|
|
1599
1515
|
const table = useReactTable<T>({
|
|
1600
1516
|
data,
|
|
1601
1517
|
columns: tableColumns,
|
|
1602
1518
|
state: {
|
|
1603
|
-
sorting,
|
|
1519
|
+
sorting: tableState.sorting,
|
|
1604
1520
|
globalFilter,
|
|
1605
|
-
columnFilters,
|
|
1521
|
+
columnFilters: tableState.columnFilters,
|
|
1606
1522
|
columnPinning,
|
|
1607
1523
|
columnVisibility,
|
|
1608
|
-
...(enablePagination && { pagination }),
|
|
1609
|
-
...(enableRowSelection && { rowSelection:
|
|
1524
|
+
...(enablePagination && { pagination: tableState.pagination }),
|
|
1525
|
+
...(enableRowSelection && { rowSelection: tableState.rowSelection }),
|
|
1610
1526
|
},
|
|
1611
|
-
onSortingChange:
|
|
1527
|
+
onSortingChange: tableState.onSortingChange,
|
|
1612
1528
|
onGlobalFilterChange: handleGlobalFilterChange,
|
|
1613
|
-
onColumnFiltersChange:
|
|
1614
|
-
onColumnPinningChange:
|
|
1615
|
-
...(enablePagination && { onPaginationChange:
|
|
1529
|
+
onColumnFiltersChange: tableState.onColumnFiltersChange,
|
|
1530
|
+
onColumnPinningChange: tableState.onColumnPinningChange,
|
|
1531
|
+
...(enablePagination && { onPaginationChange: tableState.onPaginationChange }),
|
|
1616
1532
|
...(enableRowSelection && {
|
|
1617
1533
|
enableRowSelection: true,
|
|
1618
|
-
onRowSelectionChange:
|
|
1534
|
+
onRowSelectionChange: tableState.onRowSelectionChange,
|
|
1619
1535
|
}),
|
|
1620
1536
|
...(getRowId !== undefined && { getRowId }),
|
|
1621
1537
|
getCoreRowModel: getCoreRowModel(),
|
|
@@ -1678,10 +1594,6 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1678
1594
|
// `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
|
|
1679
1595
|
const rows = table.getRowModel().rows
|
|
1680
1596
|
|
|
1681
|
-
// A capped body or a horizontal floor both need the same native scroll node. Everything else —
|
|
1682
|
-
// pinning included — either takes that same container (the uncapped, non-sticky default) or the
|
|
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.)
|
|
1685
1597
|
// `stickyHeaderOffset` is a WINDOW-scroll concept — the height of whatever fixed chrome the page
|
|
1686
1598
|
// scrolls under (the AppShell header plus `PageBar` row 2). Inside the scroll container it is
|
|
1687
1599
|
// always wrong: that box is the sticky header's own scrollport, so the offset parks the `<thead>`
|
|
@@ -1700,9 +1612,27 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1700
1612
|
// `useMeasuredContainment` drives instead: bare while the table fits, `overflow-x: auto` once it
|
|
1701
1613
|
// does not. Its docblock carries the full accounting.
|
|
1702
1614
|
const pageStickyHeader = !scrolls && stickyHeader === true
|
|
1703
|
-
const containment = useMeasuredContainment(pageStickyHeader)
|
|
1615
|
+
const containment = useMeasuredContainment(pageStickyHeader && !projection.cards)
|
|
1704
1616
|
const resolvedStickyHeaderOffset = scrolls ? undefined : stickyHeaderOffset
|
|
1705
1617
|
|
|
1618
|
+
// The error branch the table did not have (components audit #3) carries the query's OWN `refetch`
|
|
1619
|
+
// behind Retry, so the failure is reported where the rows would have been — in a row spanning
|
|
1620
|
+
// every rendered column, or in place of the card list.
|
|
1621
|
+
const errorNode = queryError !== undefined && (
|
|
1622
|
+
<ErrorState
|
|
1623
|
+
error={queryError.error}
|
|
1624
|
+
title="Could not load"
|
|
1625
|
+
tier="section"
|
|
1626
|
+
retrying={queryError.fetchStatus === 'fetching'}
|
|
1627
|
+
onRetry={() => void queryError.refetch()}
|
|
1628
|
+
/>
|
|
1629
|
+
)
|
|
1630
|
+
const emptyNode = emptyState ?? (
|
|
1631
|
+
<Text c="dimmed" ta="center" size="sm" py="sm">
|
|
1632
|
+
No data to display.
|
|
1633
|
+
</Text>
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1706
1636
|
const tableNode = (
|
|
1707
1637
|
<Table
|
|
1708
1638
|
className={cx(classNames?.table)}
|
|
@@ -1750,15 +1680,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1750
1680
|
: undefined
|
|
1751
1681
|
}
|
|
1752
1682
|
tabIndex={canSort ? 0 : undefined}
|
|
1753
|
-
aria-sort={
|
|
1754
|
-
canSort
|
|
1755
|
-
? sorted === 'asc'
|
|
1756
|
-
? 'ascending'
|
|
1757
|
-
: sorted === 'desc'
|
|
1758
|
-
? 'descending'
|
|
1759
|
-
: 'none'
|
|
1760
|
-
: undefined
|
|
1761
|
-
}
|
|
1683
|
+
aria-sort={canSort ? (sorted ? ARIA_SORT[sorted] : 'none') : undefined}
|
|
1762
1684
|
style={{
|
|
1763
1685
|
...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
|
|
1764
1686
|
...(align !== undefined && { textAlign: align }),
|
|
@@ -1777,19 +1699,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1777
1699
|
</Table.Thead>
|
|
1778
1700
|
<Table.Tbody>
|
|
1779
1701
|
{queryError !== undefined ? (
|
|
1780
|
-
// The branch the table did not have (components audit #3). It spans every rendered
|
|
1781
|
-
// column and carries the query's OWN `refetch` behind Retry, so the failure is reported
|
|
1782
|
-
// where the rows would have been — not as an empty table saying nothing failed.
|
|
1783
1702
|
<Table.Tr>
|
|
1784
|
-
<Table.Td colSpan={columnCount}>
|
|
1785
|
-
<ErrorState
|
|
1786
|
-
error={queryError.error}
|
|
1787
|
-
title="Could not load"
|
|
1788
|
-
tier="section"
|
|
1789
|
-
retrying={queryError.fetchStatus === 'fetching'}
|
|
1790
|
-
onRetry={() => void queryError.refetch()}
|
|
1791
|
-
/>
|
|
1792
|
-
</Table.Td>
|
|
1703
|
+
<Table.Td colSpan={columnCount}>{errorNode}</Table.Td>
|
|
1793
1704
|
</Table.Tr>
|
|
1794
1705
|
) : showSkeleton ? (
|
|
1795
1706
|
Array.from({ length: skeletonRows }, (_, rowIndex) => (
|
|
@@ -1803,13 +1714,7 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1803
1714
|
))
|
|
1804
1715
|
) : rows.length === 0 ? (
|
|
1805
1716
|
<Table.Tr>
|
|
1806
|
-
<Table.Td colSpan={columnCount}>
|
|
1807
|
-
{emptyState ?? (
|
|
1808
|
-
<Text c="dimmed" ta="center" size="sm" py="sm">
|
|
1809
|
-
No data to display.
|
|
1810
|
-
</Text>
|
|
1811
|
-
)}
|
|
1812
|
-
</Table.Td>
|
|
1717
|
+
<Table.Td colSpan={columnCount}>{emptyNode}</Table.Td>
|
|
1813
1718
|
</Table.Tr>
|
|
1814
1719
|
) : (
|
|
1815
1720
|
rows.map((row) => {
|
|
@@ -1820,36 +1725,19 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1820
1725
|
...row.getRightVisibleCells(),
|
|
1821
1726
|
]
|
|
1822
1727
|
: row.getVisibleCells()
|
|
1823
|
-
const rowExpanded = hasFolded &&
|
|
1728
|
+
const rowExpanded = hasFolded && foldRows.expanded.has(row.id)
|
|
1824
1729
|
return (
|
|
1825
1730
|
<Fragment key={row.id}>
|
|
1826
1731
|
<Table.Tr
|
|
1827
1732
|
{...(enableRowSelection && row.getIsSelected() && { 'data-selected': true })}
|
|
1828
|
-
{...(onRowActivate
|
|
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
|
-
})}
|
|
1733
|
+
{...activationProps(onRowActivate, row.original)}
|
|
1846
1734
|
>
|
|
1847
1735
|
{hasFolded && (
|
|
1848
1736
|
<Table.Td className={classes.foldToggleCell}>
|
|
1849
1737
|
<FoldToggle
|
|
1850
1738
|
expanded={rowExpanded}
|
|
1851
1739
|
controlsId={`${row.id}-fold`}
|
|
1852
|
-
onToggle={() =>
|
|
1740
|
+
onToggle={() => foldRows.toggle(row.id)}
|
|
1853
1741
|
/>
|
|
1854
1742
|
</Table.Td>
|
|
1855
1743
|
)}
|
|
@@ -1898,6 +1786,25 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1898
1786
|
</Table>
|
|
1899
1787
|
)
|
|
1900
1788
|
|
|
1789
|
+
// The `renderCard` body — the same processed rows, states and activation as the table's.
|
|
1790
|
+
const renderCardList = () => (
|
|
1791
|
+
<Stack gap="xs" className={cx(classNames?.table)}>
|
|
1792
|
+
{queryError !== undefined
|
|
1793
|
+
? errorNode
|
|
1794
|
+
: showSkeleton
|
|
1795
|
+
? Array.from({ length: skeletonRows }, (_, index) => (
|
|
1796
|
+
<Skeleton key={`skeleton-${index}`} height={48} radius="md" />
|
|
1797
|
+
))
|
|
1798
|
+
: rows.length === 0
|
|
1799
|
+
? emptyNode
|
|
1800
|
+
: rows.map((row) => (
|
|
1801
|
+
<Box key={row.id} {...activationProps(onRowActivate, row.original)}>
|
|
1802
|
+
{renderCard?.(row.original)}
|
|
1803
|
+
</Box>
|
|
1804
|
+
))}
|
|
1805
|
+
</Stack>
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1901
1808
|
const paginationState = table.getState().pagination
|
|
1902
1809
|
// The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
|
|
1903
1810
|
// Select renders EMPTY when its `value` matches no row, so a table opened at
|
|
@@ -1911,7 +1818,11 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1911
1818
|
const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
|
|
1912
1819
|
|
|
1913
1820
|
return (
|
|
1914
|
-
<div
|
|
1821
|
+
<div
|
|
1822
|
+
ref={projection.rootRef}
|
|
1823
|
+
className={cx(classNames?.root, className)}
|
|
1824
|
+
{...(style !== undefined && { style })}
|
|
1825
|
+
>
|
|
1915
1826
|
{/*
|
|
1916
1827
|
* ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
|
|
1917
1828
|
* the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
|
|
@@ -1985,7 +1896,15 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
1985
1896
|
{bulkBarActions !== undefined && (
|
|
1986
1897
|
<BulkActionBar count={selectedRows.length} actions={bulkBarActions} />
|
|
1987
1898
|
)}
|
|
1988
|
-
{
|
|
1899
|
+
{projection.cards ? (
|
|
1900
|
+
maxHeight === undefined ? (
|
|
1901
|
+
renderCardList()
|
|
1902
|
+
) : (
|
|
1903
|
+
<ScrollArea.Autosize mah={maxHeight} type="hover" scrollbars="y">
|
|
1904
|
+
{renderCardList()}
|
|
1905
|
+
</ScrollArea.Autosize>
|
|
1906
|
+
)
|
|
1907
|
+
) : !foldEligible ? (
|
|
1989
1908
|
// An explicit `minWidth` is the one genuinely DECLARED horizontal floor — the column fold
|
|
1990
1909
|
// never engages here (see `foldEligible`, above), so this shape keeps its exact
|
|
1991
1910
|
// pre-existing DOM: no extra measuring wrapper for a table nothing folds.
|
|
@@ -2010,15 +1929,10 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
2010
1929
|
>
|
|
2011
1930
|
{tableNode}
|
|
2012
1931
|
</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
1932
|
) : (
|
|
2021
|
-
|
|
1933
|
+
// `pageStickyHeader` is false whenever `maxHeight` is set (`scrolls` above), so a vertical
|
|
1934
|
+
// cap lands here — its own `ScrollArea` scrollport, just with no `minWidth` floor.
|
|
1935
|
+
<Table.ScrollContainer type="native" minWidth={0} maxHeight={maxHeight}>
|
|
2022
1936
|
{tableNode}
|
|
2023
1937
|
</Table.ScrollContainer>
|
|
2024
1938
|
)}
|
|
@@ -2038,6 +1952,8 @@ export function BasaltDataTable<T>(props: BasaltDataTableProps<T>) {
|
|
|
2038
1952
|
{totalIsAuthoritative ? ` of ${total}` : ''}
|
|
2039
1953
|
</Text>
|
|
2040
1954
|
<Group gap="xs" align="center">
|
|
1955
|
+
{/* theme-allow control-outside-home — the table's own page-size setting, not a page
|
|
1956
|
+
filter: its state is the table's pagination, owned inside this component. */}
|
|
2041
1957
|
<Select
|
|
2042
1958
|
size="ctl"
|
|
2043
1959
|
radius="md"
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* containment and `.grid` is the queried child, the same split `StatGroup`'s `.container`/`.root`
|
|
15
15
|
* uses — a container cannot reliably query ITSELF for a layout-affecting property (measured here:
|
|
16
16
|
* Chrome silently keeps the un-queried value even once the container's own width crosses the
|
|
17
|
-
* condition), only a descendant. The `599.9px
|
|
17
|
+
* condition), only a descendant. The `599.9px` literal: 600px is roughly the point
|
|
18
18
|
* two ~250px+ cells stop feeling cramped; `@container`/`@media` conditions cannot read a custom
|
|
19
|
-
* property, so
|
|
19
|
+
* property, so it is a written number, not a `--vx-*` ref. No viewport fallback — see
|
|
20
|
+
* `stat-group.module.css`: nothing that renders basalt lacks `@container`.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/* ── FormSection ─────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -102,17 +103,6 @@
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
/* Pre-container-query fallback (Safari < 16, Chrome < 106, Firefox < 110) — the old viewport law,
|
|
106
|
-
* reachable only where `@container` itself cannot parse (same reasoning as `stat-group.module.css`). */
|
|
107
|
-
@supports not (container-type: inline-size) {
|
|
108
|
-
@media (max-width: 47.99375em) {
|
|
109
|
-
.grid {
|
|
110
|
-
grid-template-columns: minmax(0, 1fr);
|
|
111
|
-
gap: var(--vx-space-stack-xs);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
106
|
/* ── FormGroup ───────────────────────────────────────────────────────────────────────────────── */
|
|
117
107
|
|
|
118
108
|
/* Same UA reset as `.control`, same reason. */
|
|
@@ -148,9 +138,12 @@
|
|
|
148
138
|
gap: var(--vx-space-stack-xs);
|
|
149
139
|
}
|
|
150
140
|
|
|
141
|
+
/* Bottom-aligned, not stretched: a trailing button beside a labelled FormRow keeps its own height
|
|
142
|
+
and lines up with the input, not with the label above it. */
|
|
151
143
|
.groupControls[data-direction='row'] {
|
|
152
144
|
flex-direction: row;
|
|
153
145
|
flex-wrap: wrap;
|
|
146
|
+
align-items: flex-end;
|
|
154
147
|
gap: var(--vx-space-control-gap);
|
|
155
148
|
}
|
|
156
149
|
|
|
@@ -213,7 +213,12 @@ export type FormGroupProps = BasaltProps & {
|
|
|
213
213
|
error?: ReactNode
|
|
214
214
|
/** Marks the group required — same two-channel treatment as `FormRow`. */
|
|
215
215
|
required?: boolean
|
|
216
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* `column` stacks the options; `row` lays them out inline, wraps, and bottom-aligns them, so a
|
|
218
|
+
* trailing button sits level with the inputs instead of stretching to the tallest child. Give a
|
|
219
|
+
* `FormRow` inside a `row` group (or any flex row) a definite width — it is an inline-size
|
|
220
|
+
* container, so its intrinsic width is zero and it collapses otherwise. @default 'column'
|
|
221
|
+
*/
|
|
217
222
|
direction?: 'row' | 'column'
|
|
218
223
|
/** The cluster — checkboxes, radios, chips. */
|
|
219
224
|
children: ReactNode
|