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
@@ -8,21 +8,15 @@ import type { ChartMargin } from '../../tokens'
8
8
  import type { CursorResolution } from '../cursor/resolve'
9
9
  import { useBandPlot } from '../hooks/useBandPlot'
10
10
  import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
11
- import { AxisBottomDate } from '../primitives/Axes'
12
11
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
13
12
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
14
- import {
15
- ChartTooltipFloat,
16
- TooltipBody,
17
- TooltipHeader,
18
- TooltipRow,
19
- } from '../primitives/ChartTooltip'
13
+ import { TooltipRow } from '../primitives/ChartTooltip'
20
14
  import { Crosshair } from '../primitives/Crosshair'
21
15
  import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
22
- import { HoverOverlay } from '../primitives/HoverOverlay'
23
16
  import type { ChartState } from '../primitives/ChartPending'
24
17
  import type { ChartLegendConfig, SeriesStyle } from '../series'
25
18
  import { fmtAxisDate } from '../utils/format'
19
+ import { BandAxisOverlay, BandTooltip, clampFraction } from './band-chrome'
26
20
  import { isDev } from '../../common/is-dev'
27
21
 
28
22
  /**
@@ -256,7 +250,7 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
256
250
  ...(tooltip !== undefined && { tooltip }),
257
251
  })
258
252
 
259
- const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
253
+ const { bands, margin, plotWidth, point, crosshairX, step, bandWidth } = band
260
254
  const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
261
255
  const hatchId = `${chartId}-band-absent`
262
256
  if (absentState !== undefined && !styleByKey.has(absentState)) {
@@ -351,8 +345,6 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
351
345
 
352
346
  if (plotWidth <= 0 || bands.length === 0) return null
353
347
 
354
- const cfg = tooltip === false ? undefined : tooltip
355
- const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
356
348
  const row = point === null ? null : deriveBandRow(getBand(point), styleByKey, hidden, point)
357
349
 
358
350
  return (
@@ -383,69 +375,31 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
383
375
 
384
376
  {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={stripHeight} />}
385
377
 
386
- <AxisBottomDate
387
- top={stripHeight}
388
- scale={scale}
389
- tickValues={band.tickValues}
390
- tickFormat={(v) => formatX(String(v))}
391
- anchorTerminals={band.xAnchorTerminals}
392
- {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
393
- />
394
-
395
- <HoverOverlay
396
- width={plotWidth}
378
+ <BandAxisOverlay
379
+ band={band}
397
380
  height={stripHeight}
398
- onMove={cursor.onPointerMove}
399
- onDown={cursor.onPointerDown}
400
- onUp={cursor.onPointerUp}
401
- onLeave={cursor.onPointerLeave}
402
- onKeyDown={cursor.onKeyDown}
403
- onBlur={cursor.onBlur}
404
- valueMax={Math.max(bands.length - 1, 0)}
405
- {...(point !== null && {
406
- valueNow: bands.indexOf(point),
407
- valueText: formatX(getX(point)),
408
- })}
409
- {...(ariaLabel !== undefined && { ariaLabel })}
381
+ getX={getX}
382
+ formatX={formatX}
383
+ ariaLabel={ariaLabel}
410
384
  />
411
385
  </Group>
412
386
  </svg>
413
387
 
414
- {band.showTooltip && point !== null && (
415
- <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
416
- <TooltipHeader
417
- date={getX(point)}
418
- {...(cfg?.formatHeader !== undefined && {
419
- format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
420
- })}
421
- {...(badge !== null && { label: badge.text, labelColor: badge.color })}
388
+ <BandTooltip band={band} tooltip={tooltip} getX={getX} hidden={hidden}>
389
+ {row !== null && (
390
+ <TooltipRow
391
+ color={row.color}
392
+ label={row.label}
393
+ value={row.value}
394
+ shape={row.shape}
395
+ dashed={row.dashed}
422
396
  />
423
- <TooltipBody>
424
- {cfg?.prependRows?.(point, { hidden })}
425
- {row !== null && (
426
- <TooltipRow
427
- color={row.color}
428
- label={row.label}
429
- value={row.value}
430
- shape={row.shape}
431
- dashed={row.dashed}
432
- />
433
- )}
434
- {cfg?.extraRows?.(point, { hidden })}
435
- </TooltipBody>
436
- </ChartTooltipFloat>
437
- )}
397
+ )}
398
+ </BandTooltip>
438
399
  </>
439
400
  )
440
401
  }
441
402
 
442
- /** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
443
- * end, since the alternative is a band that does not render at all. */
444
- function clampFraction(value: number | undefined): number {
445
- if (value === undefined || !Number.isFinite(value)) return 0
446
- return Math.min(Math.max(value, 0), 1)
447
- }
448
-
449
403
  /**
450
404
  * The strip's one derived row: the hovered band's STATE, named and formatted by its own `series`
451
405
  * entry. A strip cannot show a row for a state it does not draw, and cannot name a state the
@@ -13,11 +13,12 @@ import {
13
13
  TooltipBody,
14
14
  TooltipHeader,
15
15
  TooltipRow,
16
+ TooltipRows,
16
17
  } from '../primitives/ChartTooltip'
17
18
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
18
19
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
19
20
  import { Crosshair, SeriesDot } from '../primitives/Crosshair'
20
- import { HoverOverlay } from '../primitives/HoverOverlay'
21
+ import { cursorSliderProps, HoverOverlay } from '../primitives/HoverOverlay'
21
22
  import { ZoneRects } from '../primitives/ZoneRects'
22
23
  import type { ZoneSpec } from '../primitives/ZoneRects'
23
24
  import type { ChartState } from '../primitives/ChartPending'
@@ -595,18 +596,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
595
596
  <HoverOverlay
596
597
  width={xMax}
597
598
  height={topH + PANE_GAP}
598
- onMove={cursor.onPointerMove}
599
- onDown={cursor.onPointerDown}
600
- onUp={cursor.onPointerUp}
601
- onLeave={cursor.onPointerLeave}
602
- onKeyDown={cursor.onKeyDown}
603
- onBlur={cursor.onBlur}
604
- valueMax={Math.max(data.length - 1, 0)}
605
- {...(point !== null && {
606
- valueNow: data.indexOf(point),
607
- valueText: formatX(getX(point)),
608
- })}
609
- {...(ariaLabel !== undefined && { ariaLabel })}
599
+ {...cursorSliderProps({ cursor, data, getX, formatX, ariaLabel })}
610
600
  />
611
601
  </Group>
612
602
 
@@ -688,17 +678,7 @@ function DualPanelPlot<T>(props: DualPanelPlotProps<T>) {
688
678
  })()}
689
679
  />
690
680
  <TooltipBody>
691
- {deriveTooltipRows(visibleSeries, point, formatTop).map((row) => (
692
- <TooltipRow
693
- key={row.key}
694
- color={row.color}
695
- label={row.label}
696
- value={row.value}
697
- shape={row.shape}
698
- dashed={row.dashed}
699
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
700
- />
701
- ))}
681
+ <TooltipRows rows={deriveTooltipRows(visibleSeries, point, formatTop)} />
702
682
  {barVisible &&
703
683
  (() => {
704
684
  const v = getBar(point)
@@ -10,22 +10,17 @@ import type { CursorResolution } from '../cursor/resolve'
10
10
  import { useBandPlot } from '../hooks/useBandPlot'
11
11
  import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
12
12
  import { probeAxisLabels } from '../layout/auto-margin'
13
- import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
13
+ import { AxisLeftNumeric } from '../primitives/Axes'
14
14
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
15
15
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
16
- import {
17
- ChartTooltipFloat,
18
- TooltipBody,
19
- TooltipHeader,
20
- TooltipRow,
21
- } from '../primitives/ChartTooltip'
16
+ import { TooltipRows } from '../primitives/ChartTooltip'
22
17
  import { Crosshair } from '../primitives/Crosshair'
23
18
  import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
24
- import { HoverOverlay } from '../primitives/HoverOverlay'
25
19
  import type { ChartState } from '../primitives/ChartPending'
26
20
  import { deriveTooltipRows } from '../series'
27
21
  import type { ChartLegendConfig, ChartSeries } from '../series'
28
22
  import { fmtAxisDate } from '../utils/format'
23
+ import { BandAxisOverlay, BandTooltip, clampFraction } from './band-chrome'
29
24
 
30
25
  /** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
31
26
  export type MirroredBarPane = {
@@ -242,7 +237,7 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
242
237
  ...(tooltip !== undefined && { tooltip }),
243
238
  })
244
239
 
245
- const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
240
+ const { bands, margin, plotWidth, point, crosshairX, step, bandWidth } = band
246
241
 
247
242
  // ── Pass 2: the pane rects, now that the plot height is known ───────────────────────────────
248
243
  const barBand = Math.max(plot.height - margin.top - margin.bottom, 2)
@@ -346,8 +341,6 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
346
341
 
347
342
  if (plotWidth <= 0 || bands.length === 0) return null
348
343
 
349
- const cfg = tooltip === false ? undefined : tooltip
350
- const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
351
344
  // Each pane's row falls back to ITS OWN axis formatter, never the other pane's — the two panes
352
345
  // are in different units by construction (an explicit per-series `formatValue` still wins).
353
346
  const visibleSeries = series
@@ -419,70 +412,23 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
419
412
 
420
413
  {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={barBand} />}
421
414
 
422
- <AxisBottomDate
423
- top={barBand}
424
- scale={scale}
425
- tickValues={band.tickValues}
426
- tickFormat={(v) => formatX(String(v))}
427
- anchorTerminals={band.xAnchorTerminals}
428
- {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
429
- />
430
-
431
- <HoverOverlay
432
- width={plotWidth}
415
+ <BandAxisOverlay
416
+ band={band}
433
417
  height={barBand}
434
- onMove={cursor.onPointerMove}
435
- onDown={cursor.onPointerDown}
436
- onUp={cursor.onPointerUp}
437
- onLeave={cursor.onPointerLeave}
438
- onKeyDown={cursor.onKeyDown}
439
- onBlur={cursor.onBlur}
440
- valueMax={Math.max(bands.length - 1, 0)}
441
- {...(point !== null && {
442
- valueNow: bands.indexOf(point),
443
- valueText: formatX(getX(point)),
444
- })}
445
- {...(ariaLabel !== undefined && { ariaLabel })}
418
+ getX={getX}
419
+ formatX={formatX}
420
+ ariaLabel={ariaLabel}
446
421
  />
447
422
  </Group>
448
423
  </svg>
449
424
 
450
- {band.showTooltip && point !== null && (
451
- <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
452
- <TooltipHeader
453
- date={getX(point)}
454
- {...(cfg?.formatHeader !== undefined && {
455
- format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
456
- })}
457
- {...(badge !== null && { label: badge.text, labelColor: badge.color })}
458
- />
459
- <TooltipBody>
460
- {cfg?.prependRows?.(point, { hidden })}
461
- {rows.map((row) => (
462
- <TooltipRow
463
- key={row.key}
464
- color={row.color}
465
- label={row.label}
466
- value={row.value}
467
- shape={row.shape}
468
- dashed={row.dashed}
469
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
470
- />
471
- ))}
472
- {cfg?.extraRows?.(point, { hidden })}
473
- </TooltipBody>
474
- </ChartTooltipFloat>
475
- )}
425
+ <BandTooltip band={band} tooltip={tooltip} getX={getX} hidden={hidden}>
426
+ <TooltipRows rows={rows} />
427
+ </BandTooltip>
476
428
  </>
477
429
  )
478
430
  }
479
431
 
480
- /** A 0..1 share, non-finite included — see `BandStrip`'s identical guard. */
481
- function clampFraction(value: number | undefined): number {
482
- if (value === undefined || !Number.isFinite(value)) return 0
483
- return Math.min(Math.max(value, 0), 1)
484
- }
485
-
486
432
  /** A drawable value, or null. See the call site for why NaN is not drawable. */
487
433
  function finiteOrNull(v: number | null | undefined): number | null {
488
434
  return v === null || v === undefined || Number.isNaN(v) ? null : v
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { BandPlot, BandTooltipConfig } from '../hooks/useBandPlot'
3
+ import { AxisBottomDate } from '../primitives/Axes'
4
+ import { ChartTooltipFloat, TooltipBody, TooltipHeader } from '../primitives/ChartTooltip'
5
+ import { cursorSliderProps, HoverOverlay } from '../primitives/HoverOverlay'
6
+
7
+ /**
8
+ * The chrome `BandStrip` and `MirroredBars` share around their own marks — the slot axis, the
9
+ * keyboard/pointer overlay and the tooltip shell — so the two `useBandPlot` kinds cannot drift in
10
+ * any of them. Internal; neither is exported from a barrel.
11
+ *
12
+ * theme-allow-file hand-rolled-plot — the slot axis and overlay of the two declared banded
13
+ * exceptions (`BandStrip`, `MirroredBars`), which carry the same waiver for the same reason.
14
+ */
15
+
16
+ /** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
17
+ * end, since the alternative is a band that does not render at all. */
18
+ export function clampFraction(value: number | undefined): number {
19
+ if (value === undefined || !Number.isFinite(value)) return 0
20
+ return Math.min(Math.max(value, 0), 1)
21
+ }
22
+
23
+ /** The slot axis under a `height`-tall band row, plus the hover/keyboard overlay over it. */
24
+ export function BandAxisOverlay<T>({
25
+ band,
26
+ height,
27
+ getX,
28
+ formatX,
29
+ ariaLabel,
30
+ }: {
31
+ band: BandPlot<T>
32
+ height: number
33
+ getX: (d: T) => string
34
+ formatX: (key: string) => string
35
+ ariaLabel: string | undefined
36
+ }) {
37
+ return (
38
+ <>
39
+ <AxisBottomDate
40
+ top={height}
41
+ scale={band.scale}
42
+ tickValues={band.tickValues}
43
+ tickFormat={(v) => formatX(String(v))}
44
+ anchorTerminals={band.xAnchorTerminals}
45
+ {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
46
+ />
47
+ <HoverOverlay
48
+ width={band.plotWidth}
49
+ height={height}
50
+ {...cursorSliderProps({ cursor: band.cursor, data: band.bands, getX, formatX, ariaLabel })}
51
+ />
52
+ </>
53
+ )
54
+ }
55
+
56
+ /** The tooltip shell: header (+ badge), the consumer's prepended rows, the kind's own derived
57
+ * `children` rows, the consumer's extra rows. Renders nothing off-hover or with `tooltip: false`. */
58
+ export function BandTooltip<T>({
59
+ band,
60
+ tooltip,
61
+ getX,
62
+ hidden,
63
+ children,
64
+ }: {
65
+ band: BandPlot<T>
66
+ tooltip: BandTooltipConfig<T> | false | undefined
67
+ getX: (d: T) => string
68
+ hidden: ReadonlySet<string>
69
+ children: ReactNode
70
+ }) {
71
+ const { point } = band
72
+ if (!band.showTooltip || point === null) return null
73
+ const cfg = tooltip === false ? undefined : tooltip
74
+ const badge = cfg?.label === undefined ? null : cfg.label(point)
75
+ return (
76
+ <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
77
+ <TooltipHeader
78
+ date={getX(point)}
79
+ {...(cfg?.formatHeader !== undefined && {
80
+ format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
81
+ })}
82
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
83
+ />
84
+ <TooltipBody>
85
+ {cfg?.prependRows?.(point, { hidden })}
86
+ {children}
87
+ {cfg?.extraRows?.(point, { hidden })}
88
+ </TooltipBody>
89
+ </ChartTooltipFloat>
90
+ )
91
+ }
@@ -32,9 +32,9 @@ import {
32
32
  } from './chart-layout'
33
33
  import { useChartContainerClass, useChartMetrics } from './chart-tier'
34
34
  import type { ChartState } from './ChartPending'
35
- import { ChartTooltipFloat, TooltipBody, TooltipHeader, TooltipRow } from './ChartTooltip'
35
+ import { ChartTooltipFloat, TooltipBody, TooltipHeader, TooltipRows } from './ChartTooltip'
36
36
  import { Crosshair, SeriesDot } from './Crosshair'
37
- import { HoverOverlay } from './HoverOverlay'
37
+ import { cursorSliderProps, HoverOverlay } from './HoverOverlay'
38
38
  import { XZoneRects } from './XZoneRects'
39
39
  import type { XZoneSpec } from './XZoneRects'
40
40
  import { ZoneRects } from './ZoneRects'
@@ -405,46 +405,51 @@ function buildYScale<T>(
405
405
  }
406
406
 
407
407
  /**
408
- * `tickLabelPx`'s resolver, split out of a two-deep ternary: a 45° label projects wider than its
409
- * flat glyph box, so it measures through {@link rotatedXLabelPx} instead of the flat/wrapped
410
- * numbers every other rotation uses; a wrapped flat label (`wrapWidth` set) uses ITS width, an
411
- * unwrapped one the plain flat width. `rotate === 90` falls through to the flat/wrapped branch
412
- * unchanged — `wrapWidth` is only ever set for `rotate === 0` (see its own definition), so a 90°
413
- * axis always resolves to `flatLabelPx` here, matching the pre-split behaviour exactly.
408
+ * The x-axis margin ladder, as one pure decision. The flat (unrotated) margin is what every case
409
+ * resolves to except a rotated or wrapped axis, and it is what the rotation decision itself is
410
+ * measured against — deciding needs an `xMax`, and `xMax` needs a margin, so the flat pass breaks
411
+ * the loop. An explicit `xLabelRotate` always wins — including `0`, the documented "never rotate"
412
+ * opt-out; unset, only a categorical (band) axis wraps, and rotates only when even wrapped it
413
+ * doesn't fit (`planXLabels`) — a time or number domain only thins.
414
+ *
415
+ * Per rotation: 45° reuses its pre-measured margin and measures its tick room through
416
+ * {@link rotatedXLabelPx} (a rotated label projects wider than its glyph box); 90° measures fresh
417
+ * (one line box wide, so the flat label room governs its thinning); a wrapped flat axis re-measures
418
+ * for its extra line(s) and thins by the wrapped width.
414
419
  */
415
- function resolveTickLabelPx(input: {
416
- rotate: 0 | 45 | 90
417
- wrapWidth: number | undefined
418
- xLabels: string[]
419
- axisFont: number
420
+ function resolveXAxisLayout(input: {
421
+ marginInput: AutoMarginInput
422
+ flatMargin: ChartMargin
423
+ rotated45Margin: ChartMargin
420
424
  flatLabelPx: number
421
- wrappedLabelPx: number
422
- }): number {
423
- const { rotate, wrapWidth, xLabels, axisFont, flatLabelPx, wrappedLabelPx } = input
424
- if (rotate === 45) return rotatedXLabelPx(xLabels, axisFont)
425
- return wrapWidth === undefined ? flatLabelPx : wrappedLabelPx
426
- }
427
-
428
- /**
429
- * `baseMargin`'s resolver, split out of the same two-deep ternary: an unrotated, unwrapped axis
430
- * keeps the already-measured flat margin; wrapping re-measures for the extra line(s)
431
- * (`bottomLines`); a 45° rotation reuses its own pre-measured margin ({@link rotated45Margin}); a
432
- * 90° one measures fresh (no pre-measured twin exists for it, unlike 45°).
433
- */
434
- function resolveBaseMargin(input: {
425
+ xLabels: string[]
426
+ fontPx: number
427
+ plotWidth: number
428
+ categorical: boolean
429
+ xLabelRotate: 0 | 45 | 90 | undefined
430
+ }): {
431
+ baseMargin: ChartMargin
435
432
  rotate: 0 | 45 | 90
436
433
  wrapWidth: number | undefined
437
- flatMargin: ChartMargin
438
- rotated45Margin: ChartMargin
439
- marginInput: AutoMarginInput
440
- xPlanLines: number
441
- }): ChartMargin {
442
- const { rotate, wrapWidth, flatMargin, rotated45Margin, marginInput, xPlanLines } = input
443
- if (rotate === 45) return rotated45Margin
444
- if (rotate === 90) return autoMargin({ ...marginInput, rotate })
445
- return wrapWidth === undefined
446
- ? flatMargin
447
- : autoMargin({ ...marginInput, bottomLines: xPlanLines })
434
+ tickLabelPx: number
435
+ } {
436
+ const { marginInput, flatMargin, rotated45Margin, flatLabelPx, xLabels, fontPx, categorical } =
437
+ input
438
+ const flatXMax = Math.max(input.plotWidth - flatMargin.left - flatMargin.right, 0)
439
+ const xPlan = planXLabels({ labels: xLabels, plotWidth: flatXMax, fontPx, categorical })
440
+ const rotate = input.xLabelRotate ?? (categorical ? xPlan.rotate : 0)
441
+ if (rotate === 45) {
442
+ const tickLabelPx = rotatedXLabelPx(xLabels, fontPx)
443
+ return { baseMargin: rotated45Margin, rotate, wrapWidth: undefined, tickLabelPx }
444
+ }
445
+ if (rotate === 90) {
446
+ const baseMargin = autoMargin({ ...marginInput, rotate })
447
+ return { baseMargin, rotate, wrapWidth: undefined, tickLabelPx: flatLabelPx }
448
+ }
449
+ if (!xPlan.wrap)
450
+ return { baseMargin: flatMargin, rotate, wrapWidth: undefined, tickLabelPx: flatLabelPx }
451
+ const baseMargin = autoMargin({ ...marginInput, bottomLines: xPlan.lines })
452
+ return { baseMargin, rotate, wrapWidth: xPlan.wrapPx, tickLabelPx: xPlan.labelPx }
448
453
  }
449
454
 
450
455
  /**
@@ -665,16 +670,16 @@ function CartesianPlot<T>({
665
670
  // the actual law: only a band (category) domain wraps or rotates; time and number domains thin.
666
671
  const categorical = tight && classifyDomain(keys) === 'band'
667
672
 
668
- /** The horizontal room one x tick label needs: the widest string that could be painted, plus
669
- * breathing space to its neighbour. Feeds `smartTicks`, which otherwise thinned the axis by a
670
- * constant that knew nothing about the label. Measured at the tick font the axis will actually
671
- * paint, which the tier moves (`docs/CHARTS-SPEC.md` §8). */
672
- const xLabelPx = useMemo(() => xLabelPxFor(xLabels, tier.axisFont), [xLabels, tier.axisFont])
673
+ // The room one flat x tick label needs at the tick font the axis will actually paint
674
+ // (`docs/CHARTS-SPEC.md` §8) — keyed on the labels and font alone, so a y-label or margin change
675
+ // never re-measures every x label.
676
+ const flatLabelPx = useMemo(() => xLabelPxFor(xLabels, tier.axisFont), [xLabels, tier.axisFont])
673
677
 
674
- /** Everything `autoMargin` measures except the rotation — shared by all three passes below so a
675
- * measured label can never differ between the one that DECIDES and the one that PAINTS. */
676
- const marginInput = useMemo(
677
- () => ({
678
+ // Everything `autoMargin` measures except the rotation, measured once for every pass so a label
679
+ // can never differ between the one that DECIDES and the one that PAINTS — plus the 45° margin (a
680
+ // rotated label reaches into the left gutter).
681
+ const measured = useMemo(() => {
682
+ const marginInput: AutoMarginInput = {
678
683
  left: yPlacement === 'outside' ? leftLabels : [],
679
684
  right: rightLabels,
680
685
  bottom: xLabels,
@@ -684,61 +689,34 @@ function CartesianPlot<T>({
684
689
  floor: resolveMarginFloor(yPlacement, tier.margin),
685
690
  anchorTerminals: tight,
686
691
  ...(marginOverride !== undefined && { override: marginOverride }),
687
- }),
688
- [
689
- leftLabels,
690
- rightLabels,
691
- xLabels,
692
- tier.axisFont,
693
- tier.margin,
694
- marginOverride,
695
- tight,
696
- yPlacement,
697
- ],
698
- )
699
-
700
- /** The margin an UNROTATED axis resolves to. It is the final margin in every case except an
701
- * auto-rotated phone axis, and it is what the rotation decision itself is measured against —
702
- * deciding needs an `xMax`, and `xMax` needs a margin, so the unrotated pass breaks the loop. */
703
- const flatMargin = useMemo(() => autoMargin(marginInput), [marginInput])
704
-
705
- /** And the margin a 45° axis would resolve to — the OTHER half of the decision, needed only for
706
- * a categorical (band) domain that still overflows once wrapped (`planXLabels`'s own rotate
707
- * case). A rotated label reaches into the left gutter, so rotating spends plot width. */
708
- const rotated45Margin = useMemo(() => autoMargin({ ...marginInput, rotate: 45 }), [marginInput])
709
-
710
- // An explicit `xLabelRotate` always wins — including `0`, which is the documented "never rotate"
711
- // opt-out. Only an unset one falls through to the domain-driven default: a categorical (band)
712
- // axis wraps before it rotates, and rotates only when even wrapped it doesn't fit
713
- // (`planXLabels`). A time or number domain never wraps or rotates — it only thins.
714
- const flatXMax = Math.max(plot.width - flatMargin.left - flatMargin.right, 0)
715
- const xPlan = useMemo(
716
- () => planXLabels({ labels: xLabels, plotWidth: flatXMax, fontPx: tier.axisFont, categorical }),
717
- [xLabels, flatXMax, tier.axisFont, categorical],
718
- )
719
- const rotate = xLabelRotate ?? (categorical ? xPlan.rotate : 0)
720
- const wrapWidth = rotate === 0 && xPlan.wrap ? xPlan.wrapPx : undefined
721
- // A 90° label is one line box wide, so the constant floor governs it; a 45° one projects wider.
722
- const tickLabelPx = resolveTickLabelPx({
723
- rotate,
724
- wrapWidth,
692
+ }
693
+ return {
694
+ marginInput,
695
+ flatMargin: autoMargin(marginInput),
696
+ rotated45Margin: autoMargin({ ...marginInput, rotate: 45 }),
697
+ }
698
+ }, [
699
+ leftLabels,
700
+ rightLabels,
725
701
  xLabels,
726
- axisFont: tier.axisFont,
727
- flatLabelPx: xLabelPx,
728
- wrappedLabelPx: xPlan.labelPx,
729
- })
730
-
731
- const baseMargin = useMemo(
702
+ tier.axisFont,
703
+ tier.margin,
704
+ marginOverride,
705
+ tight,
706
+ yPlacement,
707
+ ])
708
+ const { baseMargin, rotate, wrapWidth, tickLabelPx } = useMemo(
732
709
  () =>
733
- resolveBaseMargin({
734
- rotate,
735
- wrapWidth,
736
- flatMargin,
737
- rotated45Margin,
738
- marginInput,
739
- xPlanLines: xPlan.lines,
710
+ resolveXAxisLayout({
711
+ ...measured,
712
+ flatLabelPx,
713
+ xLabels,
714
+ fontPx: tier.axisFont,
715
+ plotWidth: plot.width,
716
+ categorical,
717
+ xLabelRotate,
740
718
  }),
741
- [flatMargin, rotated45Margin, marginInput, rotate, wrapWidth, xPlan.lines],
719
+ [measured, flatLabelPx, xLabels, tier.axisFont, plot.width, categorical, xLabelRotate],
742
720
  )
743
721
 
744
722
  // End-of-line labels are planned FIRST, against the base margin: the right gutter is reserved
@@ -998,18 +976,7 @@ function CartesianPlot<T>({
998
976
  <HoverOverlay
999
977
  width={xMax}
1000
978
  height={yMax}
1001
- onMove={cursor.onPointerMove}
1002
- onDown={cursor.onPointerDown}
1003
- onUp={cursor.onPointerUp}
1004
- onLeave={cursor.onPointerLeave}
1005
- onKeyDown={cursor.onKeyDown}
1006
- onBlur={cursor.onBlur}
1007
- valueMax={Math.max(data.length - 1, 0)}
1008
- {...(point !== null && {
1009
- valueNow: data.indexOf(point),
1010
- valueText: formatX(getX(point)),
1011
- })}
1012
- {...(ariaLabel !== undefined && { ariaLabel })}
979
+ {...cursorSliderProps({ cursor: cursor, data: data, getX, formatX, ariaLabel })}
1013
980
  />
1014
981
  </Group>
1015
982
  </svg>
@@ -1025,17 +992,7 @@ function CartesianPlot<T>({
1025
992
  />
1026
993
  <TooltipBody>
1027
994
  {tooltipCfg?.prependRows?.(point, { visible, hidden })}
1028
- {rows.map((row) => (
1029
- <TooltipRow
1030
- key={row.key}
1031
- color={row.color}
1032
- label={row.label}
1033
- value={row.value}
1034
- shape={row.shape}
1035
- dashed={row.dashed}
1036
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
1037
- />
1038
- ))}
995
+ <TooltipRows rows={rows} />
1039
996
  {tooltipCfg?.extraRows?.(point, { visible, hidden })}
1040
997
  </TooltipBody>
1041
998
  </ChartTooltipFloat>