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.
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +101 -17
- package/agent/rules/basalt-mantine.md +4 -4
- package/configs/oxlint-plugin.js +159 -42
- package/configs/oxlint-plugin.test.ts +182 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +18 -66
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +5 -26
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +10 -68
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +49 -76
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +5 -23
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -40
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +5 -133
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +16 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +17 -1
- package/dist/charts/primitives/chart-card-context.d.ts +17 -13
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
- package/dist/charts/primitives/chart-card-context.js +10 -5
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +25 -31
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/controls/controls.module.css +5 -0
- package/dist/controls/sync-button.module.css +1 -0
- package/dist/dashboard/stat-group.module.css +19 -64
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +33 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +179 -277
- package/dist/forms/form-layout.module.css +3 -13
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -5
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +12 -10
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +0 -11
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +13 -14
- package/dist/shell/use-breakpoint.d.ts +1 -1
- package/dist/theme/floating.module.css +1 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/llms.txt +2 -2
- package/package.json +2 -1
- package/src/charts/kinds/BandStrip.tsx +18 -64
- package/src/charts/kinds/DualPanel.tsx +4 -24
- package/src/charts/kinds/MirroredBars.tsx +12 -66
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -122
- package/src/charts/primitives/ChartCard.tsx +5 -27
- package/src/charts/primitives/ChartFrame.tsx +37 -76
- package/src/charts/primitives/ChartLegend.tsx +7 -154
- package/src/charts/primitives/ChartTooltip.tsx +16 -0
- package/src/charts/primitives/HoverOverlay.tsx +52 -21
- package/src/charts/primitives/chart-card-context.ts +25 -16
- package/src/charts/primitives/chart-frame-layout.ts +44 -56
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/controls/controls.module.css +5 -0
- package/src/controls/sync-button.module.css +1 -0
- package/src/dashboard/stat-group.module.css +19 -64
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.tsx +127 -218
- package/src/forms/form-layout.module.css +3 -13
- package/src/guard/index.ts +21 -16
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +18 -17
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +15 -37
- package/src/shell/use-breakpoint.ts +2 -2
- package/src/theme/floating.module.css +1 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
|
@@ -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,
|
|
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
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
axisFont: number
|
|
420
|
+
function resolveXAxisLayout(input: {
|
|
421
|
+
marginInput: AutoMarginInput
|
|
422
|
+
flatMargin: ChartMargin
|
|
423
|
+
rotated45Margin: ChartMargin
|
|
420
424
|
flatLabelPx: number
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
marginInput
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const {
|
|
443
|
-
|
|
444
|
-
if (rotate ===
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
702
|
+
tier.axisFont,
|
|
703
|
+
tier.margin,
|
|
704
|
+
marginOverride,
|
|
705
|
+
tight,
|
|
706
|
+
yPlacement,
|
|
707
|
+
])
|
|
708
|
+
const { baseMargin, rotate, wrapWidth, tickLabelPx } = useMemo(
|
|
732
709
|
() =>
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
710
|
+
resolveXAxisLayout({
|
|
711
|
+
...measured,
|
|
712
|
+
flatLabelPx,
|
|
713
|
+
xLabels,
|
|
714
|
+
fontPx: tier.axisFont,
|
|
715
|
+
plotWidth: plot.width,
|
|
716
|
+
categorical,
|
|
717
|
+
xLabelRotate,
|
|
740
718
|
}),
|
|
741
|
-
[
|
|
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
|
-
|
|
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
|
|
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>
|
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
232
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|