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.
Files changed (102) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +212 -20
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +240 -59
  5. package/configs/oxlint-plugin.test.ts +329 -1
  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 +40 -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.d.ts +6 -1
  48. package/dist/forms/form-layout.d.ts.map +1 -1
  49. package/dist/forms/form-layout.module.css +6 -13
  50. package/dist/guard/index.d.ts +2 -2
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +10 -14
  53. package/dist/notifications/center.d.ts.map +1 -1
  54. package/dist/provider/index.d.ts.map +1 -1
  55. package/dist/provider/index.js +12 -10
  56. package/dist/shell/aside-docking.d.ts +31 -0
  57. package/dist/shell/aside-docking.d.ts.map +1 -0
  58. package/dist/shell/aside-docking.js +19 -0
  59. package/dist/shell/index.d.ts +0 -11
  60. package/dist/shell/index.d.ts.map +1 -1
  61. package/dist/shell/index.js +13 -14
  62. package/dist/shell/page-bar.d.ts +24 -0
  63. package/dist/shell/page-bar.d.ts.map +1 -1
  64. package/dist/shell/use-breakpoint.d.ts +1 -1
  65. package/dist/theme/floating.module.css +1 -0
  66. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  67. package/dist/tokens/index.d.ts +2 -0
  68. package/dist/tokens/index.d.ts.map +1 -1
  69. package/dist/tokens/index.js +4 -0
  70. package/llms.txt +2 -2
  71. package/package.json +2 -1
  72. package/src/charts/kinds/BandStrip.tsx +18 -64
  73. package/src/charts/kinds/DualPanel.tsx +4 -24
  74. package/src/charts/kinds/MirroredBars.tsx +12 -66
  75. package/src/charts/kinds/band-chrome.tsx +91 -0
  76. package/src/charts/primitives/CartesianChart.tsx +79 -122
  77. package/src/charts/primitives/ChartCard.tsx +5 -27
  78. package/src/charts/primitives/ChartFrame.tsx +37 -76
  79. package/src/charts/primitives/ChartLegend.tsx +7 -154
  80. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  81. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  82. package/src/charts/primitives/chart-card-context.ts +25 -16
  83. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  84. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  85. package/src/controls/controls.module.css +5 -0
  86. package/src/controls/sync-button.module.css +1 -0
  87. package/src/dashboard/stat-group.module.css +19 -64
  88. package/src/dashboard/widget-grid.module.css +11 -38
  89. package/src/data/data-table-state.ts +218 -0
  90. package/src/data/data-table.tsx +134 -218
  91. package/src/forms/form-layout.module.css +6 -13
  92. package/src/forms/form-layout.tsx +6 -1
  93. package/src/guard/index.ts +38 -34
  94. package/src/notifications/center.tsx +3 -0
  95. package/src/provider/index.tsx +18 -17
  96. package/src/shell/aside-docking.ts +54 -0
  97. package/src/shell/index.tsx +15 -37
  98. package/src/shell/page-bar.tsx +24 -0
  99. package/src/shell/use-breakpoint.ts +2 -2
  100. package/src/theme/floating.module.css +1 -0
  101. package/src/theme-lab/derive-controls.tsx +2 -0
  102. package/src/tokens/index.ts +5 -0
@@ -12,7 +12,7 @@
12
12
  * The header renders only when at least one of title/info/value/actions/icon/count is set — ending
13
13
  * the `''`-as-hidden-header sentinel a consumer used to reach for otherwise.
14
14
  */
15
- import { useCallback, useMemo, useRef, useState } from 'react'
15
+ import { useMemo, useReducer, useState } from 'react'
16
16
  import type { CSSProperties, ReactNode } from 'react'
17
17
  import { cx } from '../../common/props'
18
18
  import type { BasaltProps, SlotStylesProps } from '../../common/props'
@@ -21,7 +21,7 @@ import { WidgetHeader } from '../../dashboard/widget-header'
21
21
  import type { DeltaPolarity } from '../../dashboard/delta-badge'
22
22
  import { useChartSize } from '../hooks/useChartSize'
23
23
  import { useHysteresis } from '../hooks/use-hysteresis'
24
- import { ChartCardContext } from './chart-card-context'
24
+ import { ChartCardContext, legendSlotReducer } from './chart-card-context'
25
25
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
26
26
  import type { ChartState } from './ChartPending'
27
27
 
@@ -212,32 +212,10 @@ export function ChartCard({
212
212
  false,
213
213
  )
214
214
 
215
- const legendOwnerRef = useRef<string | null>(null)
216
- // Bumped on every release so a previously-denied sibling's claim effect (keyed on the context
217
- // value it reads, see `chart-card-context.ts`) re-runs and gets a chance to re-claim the slot the
218
- // owner just gave up — without it a frame told "no" once stayed denied even after the owner
219
- // unmounted.
220
- const [legendSlotVersion, setLegendSlotVersion] = useState(0)
221
- const claimLegendSlot = useCallback((id: string) => {
222
- if (legendOwnerRef.current === null) legendOwnerRef.current = id
223
- return legendOwnerRef.current === id
224
- }, [])
225
- const releaseLegendSlot = useCallback((id: string) => {
226
- if (legendOwnerRef.current === id) {
227
- legendOwnerRef.current = null
228
- setLegendSlotVersion((v) => v + 1)
229
- }
230
- }, [])
215
+ const [legendClaimants, dispatchLegendSlot] = useReducer(legendSlotReducer, [])
231
216
  const context = useMemo(
232
- () => ({
233
- legendSlot,
234
- inCard: true,
235
- short,
236
- claimLegendSlot,
237
- releaseLegendSlot,
238
- legendSlotVersion,
239
- }),
240
- [legendSlot, short, claimLegendSlot, releaseLegendSlot, legendSlotVersion],
217
+ () => ({ legendSlot, inCard: true, short, legendClaimants, dispatchLegendSlot }),
218
+ [legendSlot, short, legendClaimants],
241
219
  )
242
220
 
243
221
  return (
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
- import { useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import { useCallback, useContext, useId, useLayoutEffect, useMemo, useState } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import type { BasaltProps } from '../../common/props'
5
5
  import {
@@ -228,93 +228,55 @@ export function ChartFrame({
228
228
  inCard,
229
229
  legendSlot,
230
230
  short: cardShort,
231
- claimLegendSlot,
232
- releaseLegendSlot,
233
- legendSlotVersion,
231
+ legendClaimants,
232
+ dispatchLegendSlot,
234
233
  } = useContext(ChartCardContext)
235
234
  const frameId = useId()
236
- const [ownsSlot, setOwnsSlot] = useState(false)
237
235
  const [slotSeedW, setSlotSeedW] = useState(0)
238
- // Mirrors `ownsSlot` synchronously so the retry effect below can read "do I currently own the
239
- // slot" without waiting a render for the state to catch up — see the two effects' own doc.
240
- const ownsSlotRef = useRef(false)
241
236
  const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
242
237
  const vertical = placement === 'left' || placement === 'right'
243
238
  // `Donut` sets `headerSlot: false` (P1-4) — it never wants the slot, so it must never CLAIM it
244
239
  // either, or it would silently deny a sibling frame in the same card that does want it. A
245
240
  // vertical (left/right) legend never portals into the header either (`inHeader` below requires
246
241
  // `!vertical`), so it must not claim the slot from a sibling that could actually use it.
247
- const wantsHeaderSlot = legend !== false && legend.headerSlot !== false && !vertical
242
+ const wantsHeaderSlot =
243
+ legendSlot !== null && legend !== false && legend.headerSlot !== false && !vertical
248
244
  // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
249
- // legend naming a series with nothing to point at is its own small lie. Computed here (ahead of
250
- // the claim effect below) only because that effect's own dev warning needs it.
245
+ // legend naming a series with nothing to point at is its own small lie.
251
246
  const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
252
247
  const legendVisible = legend !== false && resolvedState === null
253
- // The card's header slot is observed like the frame is: its width is what a header legend fits.
254
- // Only the frame that CLAIMS the slot (first to mount, P2-9) observes it or seeds its width — a
255
- // second frame in the same card never measures a slot it will not portal into. The contention
256
- // warning is reported HERE, from the deterministic `owns` this effect just computed, rather than
257
- // as a separate reactive check on `ownsSlot` state — that state takes one extra render to catch up
258
- // with the claim, and a passive effect reacting to the interim (stale) value warned about frames
259
- // that went on to win the claim one render later.
260
- // One claim attempt, shared by the owner effect below (real identity changes) and the
261
- // denied-frame retry effect further down (a sibling's release). Reads `legendVisible` at CALL
262
- // time through the closure, not through a dependency array of its own effect, so the two callers
263
- // stay free to key on whatever triggers each of them.
264
- const tryClaim = useCallback((): boolean => {
265
- if (legendSlot === null || !wantsHeaderSlot) return false
266
- const owns = claimLegendSlot(frameId)
267
- ownsSlotRef.current = owns
268
- setOwnsSlot(owns)
269
- if (!owns) {
270
- setSlotSeedW(0)
271
- if (legendVisible) reportOnce('ChartFrame', legendSlotContention('ChartFrame'))
272
- return false
273
- }
274
- // Seeded synchronously (P2-8) so the very first commit already knows the slot's real width,
275
- // instead of assuming 0 until the ResizeObserver's first (asynchronous) callback — which is
276
- // what let a header legend paint once in the band before jumping to the header on every mount.
248
+ // Ownership is DERIVED from the card's claimant queue (`legendSlotReducer`), never mirrored here:
249
+ // the first frame to claim owns the slot, a later one is denied until everything ahead of it
250
+ // releases, and each frame's only job is to claim while it wants the slot and release on the way
251
+ // out (P2-9).
252
+ //
253
+ // Denied means THIS frame's own claim is in the queue and is not its head — never "someone else
254
+ // owns it" read before this frame has claimed. A new frame's first render can still see an
255
+ // owner that is unmounting in the same commit (a tab switch, a different `useId`); that is a
256
+ // hand-off, and warning off that stale head reported contention for a frame about to own it.
257
+ const ownsSlot = wantsHeaderSlot && legendClaimants[0] === frameId
258
+ const slotDenied = wantsHeaderSlot && !ownsSlot && legendClaimants.includes(frameId)
259
+ useLayoutEffect(() => {
260
+ if (!wantsHeaderSlot) return undefined
261
+ dispatchLegendSlot({ type: 'claim', id: frameId })
262
+ return () => dispatchLegendSlot({ type: 'release', id: frameId })
263
+ }, [wantsHeaderSlot, frameId, dispatchLegendSlot])
264
+ useLayoutEffect(() => {
265
+ if (slotDenied && legendVisible) reportOnce('ChartFrame', legendSlotContention('ChartFrame'))
266
+ }, [slotDenied, legendVisible])
267
+ // Only the owner observes the slot — its width is what a header legend fits. Seeded synchronously
268
+ // (P2-8) so the first paint already knows the slot's real width, instead of assuming 0 until the
269
+ // ResizeObserver's first (asynchronous) callback — which is what let a header legend paint once in
270
+ // the band before jumping to the header on every mount.
271
+ useLayoutEffect(() => {
272
+ if (!ownsSlot || legendSlot === null) return undefined
277
273
  setSlotSeedW(legendSlot.getBoundingClientRect().width)
278
274
  slotRef(legendSlot)
279
- return true
280
- }, [legendSlot, wantsHeaderSlot, legendVisible, frameId, claimLegendSlot, slotRef])
281
-
282
- // Releases the slot only if THIS frame currently owns it, read live off `ownsSlotRef` at
283
- // cleanup time rather than a snapshot closed over when the effect ran — so it correctly releases
284
- // whichever effect (this one's own claim, or the retry effect's) most recently won it.
285
- const releaseIfOwned = useCallback(() => {
286
- if (!ownsSlotRef.current) return
287
- ownsSlotRef.current = false
288
- releaseLegendSlot(frameId)
289
- slotRef(null)
290
- }, [releaseLegendSlot, frameId, slotRef])
291
-
292
- // The OWNER effect: claims/releases only on a real identity change (the slot itself, whether this
293
- // frame even wants one, or a remount) — never on `legendSlotVersion`. A prior version keyed this
294
- // effect on `legendSlotVersion` too, so the owner's own cleanup (which unconditionally released)
295
- // bumped the version, which re-ran this very effect, which released again — an infinite
296
- // release → bump → re-run loop that surfaced as "Maximum update depth exceeded" under StrictMode
297
- // replay or a `legendVisible` flip. Only a DENIED frame reacts to the version now (below).
298
- useLayoutEffect(() => {
299
- if (legendSlot === null || !wantsHeaderSlot) {
300
- setOwnsSlot(false)
275
+ return () => {
276
+ slotRef(null)
301
277
  setSlotSeedW(0)
302
- return undefined
303
278
  }
304
- tryClaim()
305
- return releaseIfOwned
306
- }, [legendSlot, wantsHeaderSlot, tryClaim, releaseIfOwned])
307
-
308
- // The DENIED-frame retry: fires only when the slot's ownership changes elsewhere
309
- // (`legendSlotVersion` bumps on a release). A frame that already owns the slot short-circuits via
310
- // `ownsSlotRef` — this effect never releases what it claims; the owner effect's `releaseIfOwned`
311
- // cleanup (keyed on stable identity, not on this version) is the one place release happens, for
312
- // whichever frame currently holds the slot.
313
- useLayoutEffect(() => {
314
- if (ownsSlotRef.current) return
315
- tryClaim()
316
- // oxlint-disable-next-line react-hooks/exhaustive-deps
317
- }, [legendSlotVersion])
279
+ }, [ownsSlot, legendSlot, slotRef])
318
280
  const viewportClass = useSizeClass()
319
281
  // No `BasaltProvider` (a charts-only consumer) means no viewport hint: the unmeasured first frame
320
282
  // resolves to the regular (desktop) class, not phone chrome.
@@ -368,7 +330,7 @@ export function ChartFrame({
368
330
  'legend.maxRows',
369
331
  'nothing (the legend fits by measured width)',
370
332
  '1.32.0',
371
- 'Overflow folds into an `All N` disclosure; an explicit value still wins this release.',
333
+ `Its UNIT changed from rows to entries: \`maxRows: ${legend.maxRows}\` is now read as a cap of ${legend.maxRows} legend ENTRIES (not rows), with the rest folded into an \`All N\` disclosure. An explicit value still wins this release.`,
372
334
  )
373
335
  : null,
374
336
  [legend, legendItems.length],
@@ -380,7 +342,7 @@ export function ChartFrame({
380
342
  // Memoized on scalars (the legend by its keys+labels) so `ChartTierProvider`'s value is stable
381
343
  // across renders that change nothing. No slot, not the owner, or not measured yet → `slotW` 0 →
382
344
  // a band. `slotSeedW` (P2-8) covers the gap before the ResizeObserver's own first callback.
383
- const slotW = legendSlot === null || !ownsSlot ? 0 : Math.max(slotMeasuredW, slotSeedW)
345
+ const slotW = ownsSlot ? Math.max(slotMeasuredW, slotSeedW) : 0
384
346
  const groups = legend !== false && legend.groups === true
385
347
  // Role and note both feed the resolver (`orderEntries`'s grouping, `legendEntryWidth`'s measured
386
348
  // fit), so a change to either — a series moving `role`, a note appearing/disappearing — has to
@@ -414,8 +376,7 @@ export function ChartFrame({
414
376
  const fit = layout.legend.mode === 'dots' || layout.legend.mode === 'chips' ? layout.legend : null
415
377
  const legendMode = fit?.mode
416
378
  const showLegend = legendVisible && fit !== null
417
- const inHeader =
418
- showLegend && !vertical && legendSlot !== null && ownsSlot && fit?.where === 'header'
379
+ const inHeader = showLegend && ownsSlot && fit?.where === 'header'
419
380
  const sideLegendWidth = showLegend && vertical ? legendW : 0
420
381
  const topBottomLegendHeight = showLegend && !vertical && !inHeader ? legendH : 0
421
382
 
@@ -1,12 +1,10 @@
1
1
  import type { CSSProperties, MouseEvent, ReactNode } from 'react'
2
- import { useCallback, useEffect, useId, useRef, useState } from 'react'
3
- import { createPortal } from 'react-dom'
4
2
  import type { BasaltProps } from '../../common/props'
5
3
  import { alpha, VX } from '../../tokens'
6
- import { useDismissOnOutside } from '../cursor/touch-pin'
7
4
  import type { SeriesRole, LegendPlacement } from '../series'
8
5
  import { DOTS_HIT_GAP, LEGEND_DOT_SIZE, LEGEND_ROW_GAP, orderEntries } from './chart-frame-layout'
9
6
  import { useChartMetrics, useCoarsePointer } from './chart-tier'
7
+ import { LegendDisclosure, useLegendDisclosure } from './legend-disclosure'
10
8
 
11
9
  export type LegendEntry = {
12
10
  key: string
@@ -57,55 +55,6 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
57
55
  /** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
58
56
  const NO_HIDDEN: ReadonlySet<string> = new Set()
59
57
 
60
- const DISCLOSURE_PANEL: CSSProperties = {
61
- position: 'fixed',
62
- zIndex: VX.zIndexFloating,
63
- display: 'flex',
64
- flexDirection: 'column',
65
- gap: LEGEND_ROW_GAP,
66
- // theme-allow raw-scroll-container — a fixed floating panel of one legend, not app chrome
67
- overflowY: 'auto',
68
- // `<dialog open>` brings UA chrome (centring margin, border, fit-content box) this resets.
69
- margin: 0,
70
- border: 'none',
71
- width: 'auto',
72
- height: 'auto',
73
- padding: 10,
74
- boxSizing: 'border-box',
75
- backgroundColor: VX.surface.overlay,
76
- boxShadow: VX.shadowOverlay,
77
- color: VX.muted,
78
- borderRadius: VX.radiusCard,
79
- }
80
-
81
- /** A popover never collapses below one usable row, however little room the chip leaves. */
82
- const DISCLOSURE_MIN_HEIGHT = 96
83
-
84
- /** A popover hangs off its chip (flipping above when there is no room below); a sheet is docked. */
85
- function disclosurePlacement(chip: DOMRect, coarse: boolean): CSSProperties {
86
- if (coarse) {
87
- return {
88
- left: 0,
89
- right: 0,
90
- bottom: 0,
91
- maxHeight: '60dvh',
92
- borderBottomLeftRadius: 0,
93
- borderBottomRightRadius: 0,
94
- padding: '12px 16px calc(12px + env(safe-area-inset-bottom, 0px))',
95
- }
96
- }
97
- const below = window.innerHeight - chip.bottom
98
- const left = Math.max(8, Math.min(chip.left, window.innerWidth - 248))
99
- const base = { left, right: 'auto', minWidth: 200, maxWidth: 'calc(100vw - 16px)' }
100
- return below >= 200 || below >= chip.top
101
- ? { ...base, top: chip.bottom + 4, maxHeight: Math.max(below - 12, DISCLOSURE_MIN_HEIGHT) }
102
- : {
103
- ...base,
104
- bottom: window.innerHeight - chip.top + 4,
105
- maxHeight: Math.max(chip.top - 12, DISCLOSURE_MIN_HEIGHT),
106
- }
107
- }
108
-
109
58
  function wrapperStyle(placement: LegendPlacement, fontSize: number, dots: boolean): CSSProperties {
110
59
  const vertical = placement === 'left' || placement === 'right'
111
60
  const gap = dots ? DOTS_HIT_GAP : VX.legendGap
@@ -365,71 +314,7 @@ export function ChartLegend({
365
314
  }) {
366
315
  const tier = useChartMetrics()
367
316
  const coarse = useCoarsePointer()
368
- const [disclosure, setDisclosure] = useState<CSSProperties | null>(null)
369
- // Whichever button opened the panel — the `All N` chip, or (dots mode) any entry — so focus
370
- // returns to where the user actually tapped, not to a fixed chip that might not exist.
371
- const triggerRef = useRef<HTMLButtonElement | null>(null)
372
- const panelRef = useRef<HTMLDialogElement>(null)
373
- const open = disclosure !== null
374
-
375
- const panelId = useId()
376
- const wasOpen = useRef(false)
377
-
378
- const openDisclosure = (trigger: HTMLButtonElement) => {
379
- triggerRef.current = trigger
380
- setDisclosure(disclosurePlacement(trigger.getBoundingClientRect(), coarse))
381
- }
382
-
383
- // Focus moves into the panel on open and back to the trigger on EVERY close path.
384
- useEffect(() => {
385
- if (open) panelRef.current?.focus()
386
- else if (wasOpen.current) triggerRef.current?.focus()
387
- wasOpen.current = open
388
- }, [open])
389
-
390
- // The panel is fixed to where the trigger WAS — a scroll outside it (any scroller, hence capture)
391
- // or a resize leaves it hanging off nothing; the panel's own scroll is not one. Shared with the
392
- // chart cursors' own outside-dismiss (`useChartCursor`/`useDiscreteCursor`, see `touch-pin.ts`).
393
- const isInsidePanel = useCallback(
394
- (target: Node): boolean =>
395
- panelRef.current?.contains(target) === true || triggerRef.current?.contains(target) === true,
396
- [],
397
- )
398
- useDismissOnOutside({
399
- enabled: open,
400
- isInside: isInsidePanel,
401
- onDismiss: () => setDisclosure(null),
402
- dismissOnResize: true,
403
- scrollExemptInside: true,
404
- })
405
-
406
- // The coarse sheet claims `aria-modal` (below), which is a claim that Tab cannot leave it — a
407
- // native `<dialog open>` (no `showModal()`) does not enforce that on its own (B14). Wrap Tab at
408
- // the panel's own first/last focusable rather than the fine popover, which is dismissed by an
409
- // outside click/Escape just as easily and never claimed to be modal in the first place.
410
- useEffect(() => {
411
- if (!open || !coarse) return
412
- const panel = panelRef.current
413
- if (panel === null) return
414
- const onKeyDown = (e: KeyboardEvent) => {
415
- if (e.key !== 'Tab') return
416
- const focusable = panel.querySelectorAll<HTMLElement>(
417
- 'button, [tabindex]:not([tabindex="-1"])',
418
- )
419
- const first = focusable[0]
420
- const last = focusable[focusable.length - 1]
421
- if (first === undefined || last === undefined) return
422
- if (e.shiftKey && document.activeElement === first) {
423
- e.preventDefault()
424
- last.focus()
425
- } else if (!e.shiftKey && document.activeElement === last) {
426
- e.preventDefault()
427
- first.focus()
428
- }
429
- }
430
- panel.addEventListener('keydown', onKeyDown)
431
- return () => panel.removeEventListener('keydown', onKeyDown)
432
- }, [open, coarse])
317
+ const { open, panelId, openFrom, close, panel } = useLegendDisclosure(coarse)
433
318
 
434
319
  const handleEnter = (key: string) => onHighlight?.(key)
435
320
  const handleLeave = () => onHighlight?.(null)
@@ -476,7 +361,7 @@ export function ChartLegend({
476
361
  textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
477
362
  }}
478
363
  {...(isDotTrigger
479
- ? { onClick: (e: MouseEvent<HTMLButtonElement>) => openDisclosure(e.currentTarget) }
364
+ ? { onClick: (e: MouseEvent<HTMLButtonElement>) => openFrom(e.currentTarget) }
480
365
  : onToggle !== undefined && { onClick: () => onToggle(item.key) })}
481
366
  onMouseEnter={() => handleEnter(item.key)}
482
367
  onMouseLeave={handleLeave}
@@ -514,7 +399,7 @@ export function ChartLegend({
514
399
  aria-expanded={open}
515
400
  aria-haspopup="dialog"
516
401
  aria-controls={panelId}
517
- onClick={(e) => (open ? setDisclosure(null) : openDisclosure(e.currentTarget))}
402
+ onClick={(e) => (open ? close() : openFrom(e.currentTarget))}
518
403
  style={{ ...LEGEND_ITEM_BUTTON, cursor: 'pointer', textDecoration: 'underline' }}
519
404
  >
520
405
  {`All ${entries.length}`}
@@ -528,41 +413,9 @@ export function ChartLegend({
528
413
  style={{ ...wrapperStyle(placement, tier.legendFontSize, mode === 'dots'), ...style }}
529
414
  >
530
415
  {nodes}
531
- {open &&
532
- createPortal(
533
- <>
534
- {coarse && (
535
- <div
536
- aria-hidden
537
- onPointerDown={(e) => {
538
- e.stopPropagation()
539
- setDisclosure(null)
540
- }}
541
- style={{
542
- position: 'fixed',
543
- inset: 0,
544
- // One below the panel it sits behind — still `VX.zIndexFloating`-derived, never a
545
- // second unrelated literal (B14).
546
- zIndex: VX.zIndexFloating - 1,
547
- backgroundColor: alpha(VX.neutral, 0.35),
548
- }}
549
- />
550
- )}
551
- <dialog
552
- ref={panelRef}
553
- id={panelId}
554
- open
555
- tabIndex={-1}
556
- aria-label="Legend"
557
- {...(coarse && { 'aria-modal': true })}
558
- data-basalt-legend-disclosure={coarse ? 'sheet' : 'popover'}
559
- style={{ ...DISCLOSURE_PANEL, fontSize: tier.legendFontSize, ...disclosure }}
560
- >
561
- {entries.map((item) => entryButton(item, true))}
562
- </dialog>
563
- </>,
564
- document.body,
565
- )}
416
+ <LegendDisclosure {...panel} fontSize={tier.legendFontSize}>
417
+ {entries.map((item) => entryButton(item, true))}
418
+ </LegendDisclosure>
566
419
  </div>
567
420
  )
568
421
  }
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'
2
2
  import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { VX } from '../../tokens'
5
+ import type { TooltipRowData } from '../series'
5
6
  import { fmtTooltipDate } from '../utils/format'
6
7
  import { useChartMetrics } from './chart-tier'
7
8
 
@@ -136,6 +137,21 @@ export function TooltipRow({
136
137
  )
137
138
  }
138
139
 
140
+ /** The derived rows (`deriveTooltipRows`), one {@link TooltipRow} each. */
141
+ export function TooltipRows({ rows }: { rows: readonly TooltipRowData[] }) {
142
+ return rows.map((row) => (
143
+ <TooltipRow
144
+ key={row.key}
145
+ color={row.color}
146
+ label={row.label}
147
+ value={row.value}
148
+ shape={row.shape}
149
+ dashed={row.dashed}
150
+ {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
151
+ />
152
+ ))
153
+ }
154
+
139
155
  export function TooltipBody({ children }: { children: ReactNode }) {
140
156
  return <div style={{ padding: '5px 0' }}>{children}</div>
141
157
  }
@@ -1,6 +1,30 @@
1
1
  import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
2
+ import type { ChartCursor } from '../hooks/useChartCursor'
2
3
  import styles from './HoverOverlay.module.css'
3
4
 
5
+ /** Props of {@link HoverOverlay}. */
6
+ export type HoverOverlayProps = {
7
+ width: number
8
+ height: number
9
+ onMove: PointerEventHandler<SVGRectElement>
10
+ /** Coarse-pointer tap: resolves + shows the tooltip immediately, with no preceding `pointermove`
11
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip). The press is provisional; `onUp` commits it. */
12
+ onDown?: PointerEventHandler<SVGRectElement>
13
+ /** Commits the provisional touch press so the pin survives the lift. */
14
+ onUp?: PointerEventHandler<SVGRectElement>
15
+ onLeave: PointerEventHandler<SVGRectElement>
16
+ /** Present = the overlay is focusable and scrubs on ←/→. */
17
+ onKeyDown?: KeyboardEventHandler<SVGRectElement>
18
+ onBlur?: FocusEventHandler<SVGRectElement>
19
+ ariaLabel?: string
20
+ /** Index of the focused point — announced as the slider position. */
21
+ valueNow?: number
22
+ /** Last index of the domain. */
23
+ valueMax?: number
24
+ /** Human-readable label for the focused point (the formatted x key). */
25
+ valueText?: string
26
+ }
27
+
4
28
  /**
5
29
  * Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
6
30
  * sync.
@@ -21,27 +45,7 @@ export function HoverOverlay({
21
45
  valueNow,
22
46
  valueMax,
23
47
  valueText,
24
- }: {
25
- width: number
26
- height: number
27
- onMove: PointerEventHandler<SVGRectElement>
28
- /** Coarse-pointer tap: resolves + shows the tooltip immediately, with no preceding `pointermove`
29
- * (`docs/CHARTS-SPEC.md` §4, Tooltip). The press is provisional; `onUp` commits it. */
30
- onDown?: PointerEventHandler<SVGRectElement>
31
- /** Commits the provisional touch press so the pin survives the lift. */
32
- onUp?: PointerEventHandler<SVGRectElement>
33
- onLeave: PointerEventHandler<SVGRectElement>
34
- /** Present = the overlay is focusable and scrubs on ←/→. */
35
- onKeyDown?: KeyboardEventHandler<SVGRectElement>
36
- onBlur?: FocusEventHandler<SVGRectElement>
37
- ariaLabel?: string
38
- /** Index of the focused point — announced as the slider position. */
39
- valueNow?: number
40
- /** Last index of the domain. */
41
- valueMax?: number
42
- /** Human-readable label for the focused point (the formatted x key). */
43
- valueText?: string
44
- }) {
48
+ }: HoverOverlayProps) {
45
49
  return (
46
50
  // `touch-action: pan-y` lives on the plot's `<svg>` (each kind's own), NOT here: Chrome ignores
47
51
  // `touch-action` on an SVG child `<rect>`, so the browser claims a horizontal pan and cancels
@@ -75,3 +79,30 @@ export function HoverOverlay({
75
79
  />
76
80
  )
77
81
  }
82
+
83
+ /**
84
+ * Everything a FOCUSABLE overlay over indexed data takes besides its rect: the cursor's pointer and
85
+ * keyboard handlers plus the slider label/position. `CartesianChart`, `DualPanel`'s top pane and
86
+ * the banded kinds all wire it identically, so it is stated once.
87
+ */
88
+ export function cursorSliderProps<T>(input: {
89
+ cursor: ChartCursor<T>
90
+ data: readonly T[]
91
+ getX: (d: T) => string
92
+ formatX: (key: string) => string
93
+ ariaLabel: string | undefined
94
+ }): Omit<HoverOverlayProps, 'width' | 'height'> {
95
+ const { cursor, data, getX, formatX, ariaLabel } = input
96
+ const { point } = cursor
97
+ return {
98
+ onMove: cursor.onPointerMove,
99
+ onDown: cursor.onPointerDown,
100
+ onUp: cursor.onPointerUp,
101
+ onLeave: cursor.onPointerLeave,
102
+ onKeyDown: cursor.onKeyDown,
103
+ onBlur: cursor.onBlur,
104
+ valueMax: Math.max(data.length - 1, 0),
105
+ ...(point !== null && { valueNow: data.indexOf(point), valueText: formatX(getX(point)) }),
106
+ ...(ariaLabel !== undefined && { ariaLabel }),
107
+ }
108
+ }
@@ -1,4 +1,23 @@
1
1
  import { createContext } from 'react'
2
+ import type { Dispatch } from 'react'
3
+
4
+ /** One frame claiming or releasing the card's header legend slot. */
5
+ export type LegendSlotAction = { type: 'claim' | 'release'; id: string }
6
+
7
+ /**
8
+ * The ONE owner of the legend-slot protocol (`docs/waves/PLAN.md` wave 3, P2-9): claimants in claim
9
+ * order, and the head owns the slot. A later claimant is denied simply by not being the head, and
10
+ * it takes over the moment everything ahead of it releases — no retry signal, no ownership mirrored
11
+ * in a ref. Returns the same array on a no-op so a repeated claim/release never re-renders the card.
12
+ */
13
+ export function legendSlotReducer(
14
+ claimants: readonly string[],
15
+ { type, id }: LegendSlotAction,
16
+ ): readonly string[] {
17
+ const held = claimants.includes(id)
18
+ if (type === 'claim') return held ? claimants : [...claimants, id]
19
+ return held ? claimants.filter((c) => c !== id) : claimants
20
+ }
2
21
 
3
22
  /**
4
23
  * `ChartCard`'s internal context: `inCard` (a frame inside a card treats its computed height as a
@@ -9,29 +28,19 @@ import { createContext } from 'react'
9
28
  * 'dots' before it eats the plot floor (`docs/CHARTS-SPEC.md` §5, wave 11). `null`/`false` until the slot
10
29
  * mounts, and outside any card. Deliberately not exported from any barrel.
11
30
  *
12
- * `claimLegendSlot`/`releaseLegendSlot` arbitrate the slot when a card hosts more than one
13
- * `ChartFrame` (`docs/waves/PLAN.md` wave 3, P2-9): the first frame to claim an id keeps it for as
14
- * long as it holds it, every later claimant is told no and falls back to its own band legend
15
- * instead of a second frame silently portalling into the same node. Outside any card there is no
16
- * slot to contend for, so the default always grants the claim.
17
- *
18
- * `legendSlotVersion` bumps every time the owner releases the slot (round 3's carried gap): a
19
- * denied frame's claim attempt runs once, in its own mount effect, so with no signal that the slot
20
- * freed up it would sit denied forever even after the owning frame unmounts. Frames read it only to
21
- * put it in a dependency array — the effect re-running is the entire point, not the number itself.
31
+ * `legendClaimants` is {@link legendSlotReducer}'s queue (its head owns the slot) and
32
+ * `dispatchLegendSlot` feeds it. Outside any card there is no slot, so nothing ever claims.
22
33
  */
23
34
  export const ChartCardContext = createContext<{
24
35
  legendSlot: HTMLDivElement | null
25
36
  inCard: boolean
26
37
  short: boolean
27
- claimLegendSlot: (id: string) => boolean
28
- releaseLegendSlot: (id: string) => void
29
- legendSlotVersion: number
38
+ legendClaimants: readonly string[]
39
+ dispatchLegendSlot: Dispatch<LegendSlotAction>
30
40
  }>({
31
41
  legendSlot: null,
32
42
  inCard: false,
33
43
  short: false,
34
- claimLegendSlot: () => true,
35
- releaseLegendSlot: () => {},
36
- legendSlotVersion: 0,
44
+ legendClaimants: [],
45
+ dispatchLegendSlot: () => {},
37
46
  })