basalt-ui 1.31.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/MIGRATING.md +101 -17
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +159 -42
  5. package/configs/oxlint-plugin.test.ts +182 -0
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/BandStrip.js +18 -66
  8. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  9. package/dist/charts/kinds/DualPanel.js +5 -26
  10. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  11. package/dist/charts/kinds/MirroredBars.js +10 -68
  12. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  13. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  14. package/dist/charts/kinds/band-chrome.js +71 -0
  15. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  16. package/dist/charts/primitives/CartesianChart.js +49 -76
  17. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  18. package/dist/charts/primitives/ChartCard.js +5 -23
  19. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  20. package/dist/charts/primitives/ChartFrame.js +23 -40
  21. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +5 -133
  23. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  24. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartTooltip.js +16 -1
  26. package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
  27. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  28. package/dist/charts/primitives/HoverOverlay.js +17 -1
  29. package/dist/charts/primitives/chart-card-context.d.ts +17 -13
  30. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
  31. package/dist/charts/primitives/chart-card-context.js +10 -5
  32. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  33. package/dist/charts/primitives/chart-frame-layout.js +25 -31
  34. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  35. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  36. package/dist/charts/primitives/legend-disclosure.js +153 -0
  37. package/dist/controls/controls.module.css +5 -0
  38. package/dist/controls/sync-button.module.css +1 -0
  39. package/dist/dashboard/stat-group.module.css +19 -64
  40. package/dist/dashboard/widget-grid.module.css +11 -38
  41. package/dist/data/data-table-state.d.ts +83 -0
  42. package/dist/data/data-table-state.d.ts.map +1 -0
  43. package/dist/data/data-table-state.js +139 -0
  44. package/dist/data/data-table.d.ts +33 -2
  45. package/dist/data/data-table.d.ts.map +1 -1
  46. package/dist/data/data-table.js +179 -277
  47. package/dist/forms/form-layout.module.css +3 -13
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +6 -5
  50. package/dist/notifications/center.d.ts.map +1 -1
  51. package/dist/provider/index.d.ts.map +1 -1
  52. package/dist/provider/index.js +12 -10
  53. package/dist/shell/aside-docking.d.ts +31 -0
  54. package/dist/shell/aside-docking.d.ts.map +1 -0
  55. package/dist/shell/aside-docking.js +19 -0
  56. package/dist/shell/index.d.ts +0 -11
  57. package/dist/shell/index.d.ts.map +1 -1
  58. package/dist/shell/index.js +13 -14
  59. package/dist/shell/use-breakpoint.d.ts +1 -1
  60. package/dist/theme/floating.module.css +1 -0
  61. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  62. package/llms.txt +2 -2
  63. package/package.json +2 -1
  64. package/src/charts/kinds/BandStrip.tsx +18 -64
  65. package/src/charts/kinds/DualPanel.tsx +4 -24
  66. package/src/charts/kinds/MirroredBars.tsx +12 -66
  67. package/src/charts/kinds/band-chrome.tsx +91 -0
  68. package/src/charts/primitives/CartesianChart.tsx +79 -122
  69. package/src/charts/primitives/ChartCard.tsx +5 -27
  70. package/src/charts/primitives/ChartFrame.tsx +37 -76
  71. package/src/charts/primitives/ChartLegend.tsx +7 -154
  72. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  73. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  74. package/src/charts/primitives/chart-card-context.ts +25 -16
  75. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  76. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  77. package/src/controls/controls.module.css +5 -0
  78. package/src/controls/sync-button.module.css +1 -0
  79. package/src/dashboard/stat-group.module.css +19 -64
  80. package/src/dashboard/widget-grid.module.css +11 -38
  81. package/src/data/data-table-state.ts +218 -0
  82. package/src/data/data-table.tsx +127 -218
  83. package/src/forms/form-layout.module.css +3 -13
  84. package/src/guard/index.ts +21 -16
  85. package/src/notifications/center.tsx +3 -0
  86. package/src/provider/index.tsx +18 -17
  87. package/src/shell/aside-docking.ts +54 -0
  88. package/src/shell/index.tsx +15 -37
  89. package/src/shell/use-breakpoint.ts +2 -2
  90. package/src/theme/floating.module.css +1 -0
  91. package/src/theme-lab/derive-controls.tsx +2 -0
@@ -0,0 +1,177 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { alpha, VX } from '../../tokens'
5
+ import { useDismissOnOutside } from '../cursor/touch-pin'
6
+ import { LEGEND_ROW_GAP } from './chart-frame-layout'
7
+
8
+ const DISCLOSURE_PANEL: CSSProperties = {
9
+ position: 'fixed',
10
+ zIndex: VX.zIndexFloating,
11
+ display: 'flex',
12
+ flexDirection: 'column',
13
+ gap: LEGEND_ROW_GAP,
14
+ // theme-allow raw-scroll-container — a fixed floating panel of one legend, not app chrome
15
+ overflowY: 'auto',
16
+ // `<dialog open>` brings UA chrome (centring margin, border, fit-content box) this resets.
17
+ margin: 0,
18
+ border: 'none',
19
+ width: 'auto',
20
+ height: 'auto',
21
+ padding: 10,
22
+ boxSizing: 'border-box',
23
+ backgroundColor: VX.surface.overlay,
24
+ boxShadow: VX.shadowOverlay,
25
+ color: VX.muted,
26
+ borderRadius: VX.radiusCard,
27
+ }
28
+
29
+ /** A popover never collapses below one usable row, however little room the chip leaves. */
30
+ const DISCLOSURE_MIN_HEIGHT = 96
31
+
32
+ /** A popover hangs off its chip (flipping above when there is no room below); a sheet is docked. */
33
+ function disclosurePlacement(chip: DOMRect, coarse: boolean): CSSProperties {
34
+ if (coarse) {
35
+ return {
36
+ left: 0,
37
+ right: 0,
38
+ bottom: 0,
39
+ maxHeight: '60dvh',
40
+ borderBottomLeftRadius: 0,
41
+ borderBottomRightRadius: 0,
42
+ padding: '12px 16px calc(12px + env(safe-area-inset-bottom, 0px))',
43
+ }
44
+ }
45
+ const below = window.innerHeight - chip.bottom
46
+ const left = Math.max(8, Math.min(chip.left, window.innerWidth - 248))
47
+ const base = { left, right: 'auto', minWidth: 200, maxWidth: 'calc(100vw - 16px)' }
48
+ return below >= 200 || below >= chip.top
49
+ ? { ...base, top: chip.bottom + 4, maxHeight: Math.max(below - 12, DISCLOSURE_MIN_HEIGHT) }
50
+ : {
51
+ ...base,
52
+ bottom: window.innerHeight - chip.top + 4,
53
+ maxHeight: Math.max(chip.top - 12, DISCLOSURE_MIN_HEIGHT),
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `ChartLegend`'s `All N` disclosure state: which button opened it (the chip, or a dots-mode entry)
59
+ * and where the fixed panel sits. Focus moves into the panel on open and back to that trigger on
60
+ * EVERY close path; a scroll outside the panel or a resize closes it, since it is fixed to where the
61
+ * trigger WAS.
62
+ */
63
+ export function useLegendDisclosure(coarse: boolean) {
64
+ const [placement, setPlacement] = useState<CSSProperties | null>(null)
65
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
66
+ const panelRef = useRef<HTMLDialogElement>(null)
67
+ const panelId = useId()
68
+ const wasOpen = useRef(false)
69
+ const open = placement !== null
70
+ const close = useCallback(() => setPlacement(null), [])
71
+ const openFrom = (trigger: HTMLButtonElement) => {
72
+ triggerRef.current = trigger
73
+ setPlacement(disclosurePlacement(trigger.getBoundingClientRect(), coarse))
74
+ }
75
+
76
+ useEffect(() => {
77
+ if (open) panelRef.current?.focus()
78
+ else if (wasOpen.current) triggerRef.current?.focus()
79
+ wasOpen.current = open
80
+ }, [open])
81
+
82
+ // Shared with the chart cursors' own outside-dismiss (`useChartCursor`/`useDiscreteCursor`, see
83
+ // `touch-pin.ts`); the panel's own scroll is not an outside one.
84
+ const isInsidePanel = useCallback(
85
+ (target: Node): boolean =>
86
+ panelRef.current?.contains(target) === true || triggerRef.current?.contains(target) === true,
87
+ [],
88
+ )
89
+ useDismissOnOutside({
90
+ enabled: open,
91
+ isInside: isInsidePanel,
92
+ onDismiss: close,
93
+ dismissOnResize: true,
94
+ scrollExemptInside: true,
95
+ })
96
+
97
+ // The coarse sheet claims `aria-modal` (below), which is a claim that Tab cannot leave it — a
98
+ // native `<dialog open>` (no `showModal()`) does not enforce that on its own (B14). Wrap Tab at
99
+ // the panel's own first/last focusable rather than the fine popover, which is dismissed by an
100
+ // outside click/Escape just as easily and never claimed to be modal in the first place.
101
+ useEffect(() => {
102
+ if (!open || !coarse) return
103
+ const panel = panelRef.current
104
+ if (panel === null) return
105
+ const onKeyDown = (e: KeyboardEvent) => {
106
+ if (e.key !== 'Tab') return
107
+ const focusable = panel.querySelectorAll<HTMLElement>(
108
+ 'button, [tabindex]:not([tabindex="-1"])',
109
+ )
110
+ const first = focusable[0]
111
+ const last = focusable[focusable.length - 1]
112
+ if (first === undefined || last === undefined) return
113
+ if (e.shiftKey && document.activeElement === first) {
114
+ e.preventDefault()
115
+ last.focus()
116
+ } else if (!e.shiftKey && document.activeElement === last) {
117
+ e.preventDefault()
118
+ first.focus()
119
+ }
120
+ }
121
+ panel.addEventListener('keydown', onKeyDown)
122
+ return () => panel.removeEventListener('keydown', onKeyDown)
123
+ }, [open, coarse])
124
+
125
+ return { open, panelId, openFrom, close, panel: { panelRef, panelId, coarse, placement, close } }
126
+ }
127
+
128
+ /** The portalled panel itself — a popover under the chip on a fine pointer, a docked sheet (over a
129
+ * scrim) on a coarse one. Fixed-position, so opening it never grows the legend band. */
130
+ export function LegendDisclosure({
131
+ panelRef,
132
+ panelId,
133
+ coarse,
134
+ placement,
135
+ close,
136
+ fontSize,
137
+ children,
138
+ }: ReturnType<typeof useLegendDisclosure>['panel'] & {
139
+ fontSize: number
140
+ children: ReactNode
141
+ }) {
142
+ if (placement === null) return null
143
+ return createPortal(
144
+ <>
145
+ {coarse && (
146
+ <div
147
+ aria-hidden
148
+ onPointerDown={(e) => {
149
+ e.stopPropagation()
150
+ close()
151
+ }}
152
+ style={{
153
+ position: 'fixed',
154
+ inset: 0,
155
+ // One below the panel it sits behind — still `VX.zIndexFloating`-derived, never a
156
+ // second unrelated literal (B14).
157
+ zIndex: VX.zIndexFloating - 1,
158
+ backgroundColor: alpha(VX.neutral, 0.35),
159
+ }}
160
+ />
161
+ )}
162
+ <dialog
163
+ ref={panelRef}
164
+ id={panelId}
165
+ open
166
+ tabIndex={-1}
167
+ aria-label="Legend"
168
+ {...(coarse && { 'aria-modal': true })}
169
+ data-basalt-legend-disclosure={coarse ? 'sheet' : 'popover'}
170
+ style={{ ...DISCLOSURE_PANEL, fontSize, ...placement }}
171
+ >
172
+ {children}
173
+ </dialog>
174
+ </>,
175
+ document.body,
176
+ )
177
+ }
@@ -49,6 +49,11 @@
49
49
  flex-shrink: 0;
50
50
  }
51
51
 
52
+ /* theme-allow-file raw-media-query — the ControlRow's own C9 swap (pills inline, the rest in a
53
+ sheet) keys on the SIZE CLASS by design: the sheet is compact-class chrome, the same axis the
54
+ shell's bottom bar reads. Every width query here is `SIZE_CLASSES.medium`, pinned by
55
+ `breakpoint-literals.test.ts`. */
56
+
52
57
  /* Below `sm` only the first `inline` slots are pills; the rest live in the sheet. */
53
58
  @media (max-width: 52.49375em) {
54
59
  .pillSlot:not([data-inline]) {
@@ -45,6 +45,7 @@
45
45
  * width itself, pinned to the `ctl` height so the box is square. This module is unlayered, so it
46
46
  * outranks `@layer mantine` regardless of specificity.
47
47
  */
48
+ /* theme-allow raw-media-query — the control's own C9 icon-only swap at the compact size class */
48
49
  @media (max-width: 52.49375em) {
49
50
  .label,
50
51
  .age {
@@ -15,24 +15,24 @@
15
15
  * one: a query with no container resolves to false, so a shell-only context would have silently
16
16
  * pinned every non-shell consumer to the base count.
17
17
  *
18
- * The `@supports not` block below it is the pre-2023 fallback and the ONLY place the old viewport
19
- * law survives. It is written as an exclusive alternative rather than as a base the container
20
- * queries override, because the rail's `:nth-child` suppressors are MORE specific than the rule
21
- * that draws the border: two overlapping laws would each suppress a different set of leading cells
22
- * and the rail would lose hairlines in the middle of a row. One law at a time, chosen by support.
18
+ * The literals are the container-class law (`CONTAINER_CLASSES`, `tokens/size-classes.ts`): the
19
+ * `sm` count opens at `wide` (800px), not at the viewport-era 768. Two are StatGroup's own and say
20
+ * so: 260px (one column, below) and 1200px, where the full `cols` count opens. The class table
21
+ * stops at 800, and 800 is too early for a full KPI row — four cells of ~190px clamp a value a real
22
+ * dashboard prints (`tests/layout/page-aside.layout.test.ts` measures ~195px against the 844px row
23
+ * a claimed aside leaves). Conditions are px, never `em`: `em` inside a `@container` condition
24
+ * resolves against the CONTAINER's font-size, so a consumer panel with smaller text would silently
25
+ * move the boundary. A condition cannot read a custom property, so the numbers are written; the
26
+ * COUNTS are custom properties the component sets per instance.
23
27
  *
24
- * The literals: `@container` conditions are stated in px, `@media` ones in Mantine's own `em`
25
- * (`--mantine-breakpoint-sm` 48em, `--mantine-breakpoint-lg` 75em). Both spellings name the same
26
- * boundary at the 16px initial font size — 768px and 1200px — but `em` inside a `@container`
27
- * condition resolves against the CONTAINER's font-size rather than the root's, so a consumer panel
28
- * with smaller text would silently move the breakpoint. px is the only spelling that cannot drift.
29
- * (`47.99375em` → `767.9px`, `74.99375em` → `1199.9px`.) A condition still cannot read a custom property either way, so the numbers are written;
30
- * the COUNTS are custom properties the component sets per instance.
28
+ * No viewport fallback: basalt's own CSS already requires `color-mix()` (Safari 16.2, Chrome 111,
29
+ * Firefox 113), and every one of those parses `@container` — a `@supports not (container-type)`
30
+ * block is unreachable in any browser that renders basalt at all.
31
31
  */
32
32
 
33
33
  .container {
34
34
  container-type: inline-size;
35
- container-name: basalt-stat-group;
35
+ container-name: basalt-grid;
36
36
  }
37
37
 
38
38
  .root {
@@ -45,7 +45,7 @@
45
45
 
46
46
  /* 260px is StatGroup's own literal (docs/DESIGN-CORE.md § Layout, elevation, shapes), not a container class: one column below it,
47
47
  * so every cell leads a row and the rail draws no leading hairline at all. */
48
- @container basalt-stat-group (max-width: 259.9px) {
48
+ @container basalt-grid (max-width: 259.9px) {
49
49
  .root {
50
50
  grid-template-columns: minmax(0, 1fr);
51
51
  }
@@ -56,13 +56,13 @@
56
56
  }
57
57
  }
58
58
 
59
- @container basalt-stat-group (min-width: 768px) {
59
+ @container basalt-grid (min-width: 800px) {
60
60
  .root {
61
61
  grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
62
62
  }
63
63
  }
64
64
 
65
- @container basalt-stat-group (min-width: 1200px) {
65
+ @container basalt-grid (min-width: 1200px) {
66
66
  .root {
67
67
  grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
68
68
  }
@@ -83,14 +83,14 @@
83
83
  padding-inline-start: var(--vx-space-stack-md);
84
84
  }
85
85
 
86
- @container basalt-stat-group (max-width: 767.9px) {
86
+ @container basalt-grid (max-width: 799.9px) {
87
87
  .root[data-divided] > *:nth-child(2n + 1) {
88
88
  border-inline-start: 0;
89
89
  padding-inline-start: 0;
90
90
  }
91
91
  }
92
92
 
93
- @container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px) {
93
+ @container basalt-grid (min-width: 800px) and (max-width: 1199.9px) {
94
94
  .root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
95
95
  .root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
96
96
  border-inline-start: 0;
@@ -98,7 +98,7 @@
98
98
  }
99
99
  }
100
100
 
101
- @container basalt-stat-group (min-width: 1200px) {
101
+ @container basalt-grid (min-width: 1200px) {
102
102
  .root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
103
103
  .root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
104
104
  .root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
@@ -107,48 +107,3 @@
107
107
  padding-inline-start: 0;
108
108
  }
109
109
  }
110
-
111
- /*
112
- * The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
113
- * this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not.
114
- * A browser that cannot parse `container-type` also cannot parse the `@container` blocks above, so
115
- * the two sets are never live at once.
116
- */
117
- @supports not (container-type: inline-size) {
118
- @media (min-width: 48em) {
119
- .root {
120
- grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
121
- }
122
- }
123
-
124
- @media (min-width: 75em) {
125
- .root {
126
- grid-template-columns: repeat(var(--basalt-stat-group-cols-lg), minmax(0, 1fr));
127
- }
128
- }
129
-
130
- @media (max-width: 47.99375em) {
131
- .root[data-divided] > *:nth-child(2n + 1) {
132
- border-inline-start: 0;
133
- padding-inline-start: 0;
134
- }
135
- }
136
-
137
- @media (min-width: 48em) and (max-width: 74.99375em) {
138
- .root[data-divided][data-sm-cols='2'] > *:nth-child(2n + 1),
139
- .root[data-divided][data-sm-cols='3'] > *:nth-child(3n + 1) {
140
- border-inline-start: 0;
141
- padding-inline-start: 0;
142
- }
143
- }
144
-
145
- @media (min-width: 75em) {
146
- .root[data-divided][data-cols='2'] > *:nth-child(2n + 1),
147
- .root[data-divided][data-cols='3'] > *:nth-child(3n + 1),
148
- .root[data-divided][data-cols='4'] > *:nth-child(4n + 1),
149
- .root[data-divided][data-cols='5'] > *:nth-child(5n + 1) {
150
- border-inline-start: 0;
151
- padding-inline-start: 0;
152
- }
153
- }
154
- }
@@ -15,18 +15,19 @@
15
15
  * container. A span resolved against the viewport while the tracks follow the container is exactly
16
16
  * the phantom-track defect `WidgetGrid.Item`'s context clamp exists to prevent.
17
17
  *
18
- * The literals: px in `@container`, Mantine's own `em` in the `@supports not` fallback — same
19
- * boundary (768px / 1200px at the 16px initial font size), but `em` inside a `@container`
20
- * condition resolves against the CONTAINER's font-size, so px is the only spelling that cannot
21
- * drift. A condition cannot read a custom property either way, so the numbers are written; the
22
- * COUNTS are custom properties, set once per instance by the component — three hand-written
23
- * `repeat()` blocks would have to enumerate every `cols` value, and the whole point of this
24
- * primitive is that a consumer states one desktop number and basalt owns the rest.
18
+ * The literals are the container-class law, as in `stat-group.module.css`: the `sm` count opens
19
+ * at `wide` (800px); 1200px, where the full `cols` count opens, is the grid's own boundary above
20
+ * the class table's top step. px, never `em` (an `em` condition resolves against the container's
21
+ * font-size). A condition cannot read a custom property, so the numbers are written; the COUNTS
22
+ * are custom properties, set once per instance by the component — three hand-written `repeat()`
23
+ * blocks would have to enumerate every `cols` value, and the whole point of this primitive is that
24
+ * a consumer states one desktop number and basalt owns the rest. No viewport fallback, for the
25
+ * reason `stat-group.module.css` states: nothing that renders basalt lacks `@container`.
25
26
  */
26
27
 
27
28
  .container {
28
29
  container-type: inline-size;
29
- container-name: basalt-widget-grid;
30
+ container-name: basalt-grid;
30
31
  }
31
32
 
32
33
  .root {
@@ -47,7 +48,7 @@
47
48
  min-width: 0;
48
49
  }
49
50
 
50
- @container basalt-widget-grid (min-width: 768px) {
51
+ @container basalt-grid (min-width: 800px) {
51
52
  .root {
52
53
  grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
53
54
  }
@@ -57,7 +58,7 @@
57
58
  }
58
59
  }
59
60
 
60
- @container basalt-widget-grid (min-width: 1200px) {
61
+ @container basalt-grid (min-width: 1200px) {
61
62
  .root {
62
63
  grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
63
64
  }
@@ -66,31 +67,3 @@
66
67
  grid-column: span var(--basalt-widget-grid-span-lg);
67
68
  }
68
69
  }
69
-
70
- /*
71
- * The pre-container-query fallback — Safari < 16, Chrome < 106, Firefox < 110. Verbatim the law
72
- * this file shipped before, keyed on the viewport, and reachable ONLY where `@container` is not: a
73
- * browser that cannot parse `container-type` cannot parse the blocks above either, so the two sets
74
- * are never live at once.
75
- */
76
- @supports not (container-type: inline-size) {
77
- @media (min-width: 48em) {
78
- .root {
79
- grid-template-columns: repeat(var(--basalt-widget-grid-cols-sm), minmax(0, 1fr));
80
- }
81
-
82
- .item {
83
- grid-column: span var(--basalt-widget-grid-span-sm);
84
- }
85
- }
86
-
87
- @media (min-width: 75em) {
88
- .root {
89
- grid-template-columns: repeat(var(--basalt-widget-grid-cols-lg), minmax(0, 1fr));
90
- }
91
-
92
- .item {
93
- grid-column: span var(--basalt-widget-grid-span-lg);
94
- }
95
- }
96
- }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `BasaltDataTable`'s state hooks — the table's own interactive state, the fold row disclosure, the
3
+ * card projection and the row/card activation props. Internal to `./data/table`; nothing here is on a
4
+ * published subpath.
5
+ */
6
+ import type {
7
+ ColumnFiltersState,
8
+ ColumnPinningState,
9
+ PaginationState,
10
+ RowSelectionState,
11
+ SortingState,
12
+ Updater,
13
+ } from '@tanstack/react-table'
14
+ import { functionalUpdate } from '@tanstack/react-table'
15
+ import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react'
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+ import { useMeasuredWidths } from '../common/use-measured-widths'
18
+ import { CONTAINER_CLASSES } from '../tokens/size-classes'
19
+ import classes from './data-table.module.css'
20
+
21
+ export const EMPTY_FOLD_SET: ReadonlySet<string> = new Set()
22
+
23
+ /**
24
+ * Which rows have their fold disclosure open. `row.id` defaults to the row's index in `data` (no
25
+ * `getRowId`) — stable across a client-side sort/filter/pagination, which reorder or subset the SAME
26
+ * core rows. PRUNED rather than cleared on every `data` change: a non-memoized `data` prop (an inline
27
+ * `.map()`, an unstable query result) hands a brand-new array of the SAME rows on every parent
28
+ * re-render, and clearing on that reference alone collapsed every open disclosure the instant
29
+ * anything upstream re-rendered.
30
+ *
31
+ * Pruning by ID ALONE is only safe with `getRowId` — a caller-declared identity that genuinely
32
+ * survives a page turn or a refetch, so an id no longer present (a manual-pagination page turn, a
33
+ * delete) still has to go, or a differently-shaped record seated at a reused id would silently read
34
+ * as pre-expanded. Without `getRowId` the id IS the array index, and an index survives a refetch
35
+ * trivially — it is still "0", "1", … even when every object behind it is a different record.
36
+ * Keeping an expanded index across THAT would silently attach the old disclosure to a stranger's
37
+ * row. So the index-id lane keeps a `prevData` snapshot and asks the narrower, correct question: is
38
+ * the object AT this index still the same reference as last time? A non-memoized array of the SAME
39
+ * rows answers yes at every index (the case above); a page turn or a refetch answers no.
40
+ */
41
+ export function useRowDisclosure<T>({
42
+ data,
43
+ getRowId,
44
+ }: {
45
+ data: readonly T[]
46
+ getRowId: ((row: T, index: number) => string) | undefined
47
+ }): { expanded: ReadonlySet<string>; toggle: (rowId: string) => void } {
48
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(EMPTY_FOLD_SET)
49
+ const toggle = useCallback((rowId: string) => {
50
+ setExpanded((current) => {
51
+ const next = new Set(current)
52
+ if (next.has(rowId)) next.delete(rowId)
53
+ else next.add(rowId)
54
+ return next
55
+ })
56
+ }, [])
57
+ const prevDataRef = useRef(data)
58
+ useEffect(() => {
59
+ const prevData = prevDataRef.current
60
+ prevDataRef.current = data
61
+ setExpanded((current) => {
62
+ if (current.size === 0) return current
63
+ const liveIds = getRowId && new Set(data.map((row, index) => getRowId(row, index)))
64
+ const isLive = (id: string) => {
65
+ if (liveIds) return liveIds.has(id)
66
+ const index = Number(id)
67
+ return data[index] !== undefined && data[index] === prevData[index]
68
+ }
69
+ const next = new Set([...current].filter(isLive))
70
+ return next.size === current.size ? current : next
71
+ })
72
+ }, [data, getRowId])
73
+ return { expanded, toggle }
74
+ }
75
+
76
+ /** `useState` whose every write also reports the settled value — the one shape sorting, the global
77
+ * filter, the column filters and pagination all share. The callback runs inside the updater, so it
78
+ * sees exactly the value TanStack applied. */
79
+ function useReportedState<S>(
80
+ initial: S,
81
+ onChange: ((next: S) => void) | undefined,
82
+ ): [S, (updater: Updater<S>) => void] {
83
+ const [value, setValue] = useState(initial)
84
+ const update = useCallback(
85
+ (updater: Updater<S>) => {
86
+ setValue((prev) => {
87
+ const next = functionalUpdate(updater, prev)
88
+ onChange?.(next)
89
+ return next
90
+ })
91
+ },
92
+ [onChange],
93
+ )
94
+ return [value, update]
95
+ }
96
+
97
+ /** The `BasaltDataTableProps` fields `useDataTableState` reads — declared here rather than picked
98
+ * off the props type, so the hook owns its contract and this module never imports the component. */
99
+ export type DataTableStateProps = {
100
+ initialSorting?: SortingState | undefined
101
+ onSortingChange?: ((sorting: SortingState) => void) | undefined
102
+ initialGlobalFilter?: string | undefined
103
+ onGlobalFilterChange?: ((value: string) => void) | undefined
104
+ onColumnFiltersChange?: ((filters: ColumnFiltersState) => void) | undefined
105
+ initialPagination?: PaginationState | undefined
106
+ onPaginationChange?: ((pagination: PaginationState) => void) | undefined
107
+ initialColumnPinning?: ColumnPinningState | undefined
108
+ rowSelection?: RowSelectionState | undefined
109
+ onRowSelectionChange?: ((selection: RowSelectionState) => void) | undefined
110
+ }
111
+
112
+ /** The table's own interactive state and the TanStack `on*Change` handlers that write it — every
113
+ * piece uncontrolled with an optional change callback, except row selection, which is controlled
114
+ * whenever `rowSelection` is passed. */
115
+ export function useDataTableState(props: DataTableStateProps, defaultPageSize: number) {
116
+ const { rowSelection, onRowSelectionChange } = props
117
+ const [sorting, onSortingChange] = useReportedState(
118
+ props.initialSorting ?? [],
119
+ props.onSortingChange,
120
+ )
121
+ const [globalFilter, onGlobalFilterChange] = useReportedState(
122
+ props.initialGlobalFilter ?? '',
123
+ props.onGlobalFilterChange,
124
+ )
125
+ const [columnFilters, onColumnFiltersChange] = useReportedState<ColumnFiltersState>(
126
+ [],
127
+ props.onColumnFiltersChange,
128
+ )
129
+ const [pagination, onPaginationChange] = useReportedState(
130
+ props.initialPagination ?? { pageIndex: 0, pageSize: defaultPageSize },
131
+ props.onPaginationChange,
132
+ )
133
+ const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
134
+ props.initialColumnPinning ?? {},
135
+ )
136
+ // Uncontrolled by default; `rowSelection`, when passed, is the truth and this only mirrors it so
137
+ // the updater below has a base to apply against.
138
+ const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
139
+
140
+ const handleRowSelectionChange = useCallback(
141
+ (updater: Updater<RowSelectionState>) => {
142
+ // CONTROLLED: the caller's map is the base AND the only writer. Mirroring it into state too
143
+ // would leave a copy that the caller never moves, and that stale copy becomes the selection
144
+ // the moment `rowSelection` goes back to `undefined` — plus every tick would render twice for
145
+ // one change. Report the next map and let the caller own it.
146
+ if (rowSelection !== undefined) {
147
+ onRowSelectionChange?.(functionalUpdate(updater, rowSelection))
148
+ return
149
+ }
150
+ setInternalRowSelection((prev) => {
151
+ const next = functionalUpdate(updater, prev)
152
+ onRowSelectionChange?.(next)
153
+ return next
154
+ })
155
+ },
156
+ [rowSelection, onRowSelectionChange],
157
+ )
158
+
159
+ return {
160
+ sorting,
161
+ onSortingChange,
162
+ globalFilter,
163
+ onGlobalFilterChange,
164
+ columnFilters,
165
+ onColumnFiltersChange,
166
+ pagination,
167
+ onPaginationChange,
168
+ columnPinning,
169
+ onColumnPinningChange: setColumnPinning,
170
+ rowSelection: rowSelection ?? internalRowSelection,
171
+ onRowSelectionChange: handleRowSelectionChange,
172
+ }
173
+ }
174
+
175
+ /** Whether the table renders as `renderCard` projections: its own root measured below the `regular`
176
+ * container class. A zero width is an un-laid-out ancestor, not a narrow table, so it moves nothing
177
+ * — the default (and the SSR answer) is the table. The root's width never depends on which body it
178
+ * holds, so the swap cannot oscillate. */
179
+ export function useCardProjection(enabled: boolean): {
180
+ rootRef: RefObject<HTMLDivElement | null>
181
+ cards: boolean
182
+ } {
183
+ const rootRef = useRef<HTMLDivElement>(null)
184
+ const [narrow, setNarrow] = useState(false)
185
+ useMeasuredWidths({
186
+ resolveRoot: () => rootRef.current,
187
+ signature: '',
188
+ enabled,
189
+ onMeasure: (root) => {
190
+ if (root.clientWidth === 0) return
191
+ const next = root.clientWidth < CONTAINER_CLASSES.regular
192
+ setNarrow((current) => (current === next ? current : next))
193
+ },
194
+ })
195
+ return { rootRef, cards: enabled && narrow }
196
+ }
197
+
198
+ /** Click + Enter activation, shared by a row and its card. Enter only: Space is the browser's own
199
+ * page-scroll on a focused non-button, and stealing it from a keyboard reader moving down a long
200
+ * table costs more than the second activation key buys. */
201
+ export function activationProps<T>(onRowActivate: ((row: T) => void) | undefined, row: T) {
202
+ if (onRowActivate === undefined) return undefined
203
+ return {
204
+ className: classes.activatable,
205
+ 'data-activatable': true,
206
+ tabIndex: 0,
207
+ onClick: () => onRowActivate(row),
208
+ onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => {
209
+ if (event.key !== 'Enter') return
210
+ // Only the element's own Enter. A cell or card may hold a button, a link or the selection
211
+ // checkbox, and keydown bubbles — so without this an Enter on a nested control fired that
212
+ // control AND opened the row's detail behind it.
213
+ if (event.target !== event.currentTarget) return
214
+ event.preventDefault()
215
+ onRowActivate(row)
216
+ },
217
+ }
218
+ }