basalt-ui 1.30.2 → 1.31.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/AGENTS.md +1 -1
- package/CHANGELOG.md +31 -0
- package/MIGRATING.md +172 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +1 -1
- package/configs/oxlint-plugin.js +254 -4
- package/configs/oxlint-plugin.test.ts +145 -2
- package/configs/oxlint.json +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +41 -43
- package/dist/agent-chat/thread-workspace.d.ts +2 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +3 -4
- package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
- package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
- package/dist/agent-chat/use-keyboard-inset.js +34 -0
- package/dist/charts/cursor/touch-pin.d.ts +45 -0
- package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
- package/dist/charts/cursor/touch-pin.js +79 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
- package/dist/charts/hooks/use-discrete-cursor.js +198 -0
- package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
- package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
- package/dist/charts/hooks/use-hysteresis.js +12 -0
- package/dist/charts/hooks/useBandPlot.d.ts +7 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
- package/dist/charts/hooks/useBandPlot.js +62 -18
- package/dist/charts/hooks/useChartCursor.d.ts +40 -11
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +102 -11
- package/dist/charts/index.d.ts +0 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +0 -10
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +54 -41
- package/dist/charts/kinds/Donut.d.ts +9 -3
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +110 -48
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +145 -131
- package/dist/charts/kinds/Heatmap.d.ts +7 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +37 -16
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +83 -68
- package/dist/charts/kinds/donut-layout.d.ts +47 -0
- package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
- package/dist/charts/kinds/donut-layout.js +52 -0
- package/dist/charts/layout/auto-margin.d.ts +7 -2
- package/dist/charts/layout/auto-margin.d.ts.map +1 -1
- package/dist/charts/layout/auto-margin.js +12 -3
- package/dist/charts/layout/end-labels.d.ts +64 -0
- package/dist/charts/layout/end-labels.d.ts.map +1 -0
- package/dist/charts/layout/end-labels.js +76 -0
- package/dist/charts/primitives/Axes.d.ts +32 -4
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +62 -16
- package/dist/charts/primitives/CartesianChart.d.ts +7 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +271 -125
- package/dist/charts/primitives/ChartCard.d.ts +0 -14
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +74 -21
- package/dist/charts/primitives/ChartFrame.d.ts +28 -22
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +133 -34
- package/dist/charts/primitives/ChartLegend.d.ts +19 -15
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +212 -71
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +4 -3
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +37 -26
- package/dist/charts/primitives/HoverOverlay.module.css +13 -0
- package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/dist/charts/primitives/chart-card-context.d.ts +29 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +13 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +76 -29
- package/dist/charts/primitives/chart-layout.d.ts +112 -0
- package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
- package/dist/charts/primitives/chart-layout.js +175 -0
- package/dist/charts/primitives/chart-tier.d.ts +13 -11
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +42 -13
- package/dist/charts/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/ticks.d.ts +27 -8
- package/dist/charts/utils/ticks.d.ts.map +1 -1
- package/dist/charts/utils/ticks.js +35 -11
- package/dist/common/errors.d.ts +11 -1
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +16 -2
- package/dist/common/hit-floor.d.ts +10 -0
- package/dist/common/hit-floor.d.ts.map +1 -0
- package/dist/common/hit-floor.js +6 -0
- package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
- package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/common/use-measured-widths.d.ts +37 -0
- package/dist/common/use-measured-widths.d.ts.map +1 -0
- package/dist/common/use-measured-widths.js +51 -0
- package/dist/common/use-media-query.d.ts +6 -0
- package/dist/common/use-media-query.d.ts.map +1 -0
- package/dist/common/use-media-query.js +40 -0
- package/dist/common/validate.d.ts +9 -0
- package/dist/common/validate.d.ts.map +1 -1
- package/dist/common/validate.js +9 -4
- package/dist/content/article-layout.d.ts +4 -2
- package/dist/content/article-layout.d.ts.map +1 -1
- package/dist/content/article-layout.js +43 -3
- package/dist/content/article-layout.module.css +45 -4
- package/dist/content/article-layout.module.css.d.ts +3 -0
- package/dist/content/code-block.d.ts.map +1 -1
- package/dist/content/code-block.js +40 -2
- package/dist/content/code-block.module.css +32 -2
- package/dist/content/prose.module.css +13 -8
- package/dist/controls/actions.d.ts +29 -6
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +158 -27
- package/dist/controls/actions.module.css +14 -1
- package/dist/controls/actions.module.css.d.ts +1 -0
- package/dist/controls/controls.module.css +8 -31
- package/dist/controls/enum-filter.js +2 -2
- package/dist/controls/filter-context.d.ts +10 -0
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/filter-context.js +2 -0
- package/dist/controls/filter-pill.d.ts.map +1 -1
- package/dist/controls/filter-pill.js +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +0 -2
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/sync-button.module.css +1 -1
- package/dist/dashboard/section.d.ts.map +1 -1
- package/dist/dashboard/section.js +1 -0
- package/dist/dashboard/section.module.css +1 -26
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +4 -1
- package/dist/dashboard/stat-card.module.css +6 -9
- package/dist/dashboard/stat-group.module.css +16 -4
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-header.d.ts +14 -4
- package/dist/dashboard/widget-header.d.ts.map +1 -1
- package/dist/dashboard/widget-header.js +23 -6
- package/dist/dashboard/widget-header.module.css +145 -133
- package/dist/dashboard/widget-header.module.css.d.ts +2 -0
- package/dist/data/data-table.d.ts +23 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +339 -141
- package/dist/data/data-table.module.css +94 -0
- package/dist/data/data-table.module.css.d.ts +11 -0
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual-list.js +10 -0
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +3 -3
- package/dist/forms/form-layout.module.css +30 -5
- package/dist/forms/form-layout.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +8 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +67 -2
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +22 -2
- package/dist/shell/app-brand.d.ts.map +1 -1
- package/dist/shell/app-brand.js +1 -0
- package/dist/shell/app-brand.module.css +2 -2
- package/dist/shell/app-header.module.css +18 -8
- package/dist/shell/app-main.module.css +54 -3
- package/dist/shell/app-main.module.css.d.ts +1 -0
- package/dist/shell/app-mobile-nav.module.css +16 -4
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/index.d.ts +12 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +57 -16
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/page-aside.d.ts +22 -3
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +27 -13
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +10 -5
- package/dist/shell/page-bar.module.css +10 -34
- package/dist/shell/use-breakpoint.d.ts +3 -4
- package/dist/shell/use-breakpoint.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.js +3 -25
- package/dist/shell/use-size-class.d.ts +6 -0
- package/dist/shell/use-size-class.d.ts.map +1 -0
- package/dist/shell/use-size-class.js +19 -0
- package/dist/state/persisted-or-local.d.ts +4 -1
- package/dist/state/persisted-or-local.d.ts.map +1 -1
- package/dist/state/persisted-or-local.js +4 -3
- package/dist/state/persisted.d.ts +10 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +44 -14
- package/dist/styles.css +41 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +11 -3
- package/dist/theme/controls.module.css +7 -0
- package/dist/theme/ctl-theme.d.ts +1 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -1
- package/dist/theme/ctl-theme.js +12 -4
- package/dist/theme/floating.module.css +36 -0
- package/dist/theme/floating.module.css.d.ts +3 -0
- package/dist/theme/index.d.ts +2 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +34 -5
- package/dist/theme/nav-link.module.css +7 -0
- package/dist/tokens/index.d.ts +7 -16
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +23 -28
- package/dist/tokens/palette.d.ts +5 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +8 -30
- package/dist/tokens/size-classes.d.ts +40 -0
- package/dist/tokens/size-classes.d.ts.map +1 -0
- package/dist/tokens/size-classes.js +29 -0
- package/dist/tokens.css +8 -4
- package/llms.txt +3 -3
- package/package.json +17 -17
- package/src/agent-chat/composer.tsx +74 -47
- package/src/agent-chat/thread-workspace.tsx +8 -11
- package/src/agent-chat/use-keyboard-inset.ts +68 -0
- package/src/charts/cursor/touch-pin.ts +138 -0
- package/src/charts/hooks/use-discrete-cursor.ts +335 -0
- package/src/charts/hooks/use-hysteresis.ts +26 -0
- package/src/charts/hooks/useBandPlot.ts +90 -27
- package/src/charts/hooks/useChartCursor.ts +211 -26
- package/src/charts/index.ts +0 -8
- package/src/charts/kinds/BandStrip.tsx +11 -1
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +19 -4
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +15 -3
- package/src/charts/kinds/donut-layout.ts +82 -0
- package/src/charts/layout/auto-margin.ts +24 -5
- package/src/charts/layout/end-labels.ts +135 -0
- package/src/charts/primitives/Axes.tsx +112 -21
- package/src/charts/primitives/CartesianChart.tsx +282 -68
- package/src/charts/primitives/ChartCard.tsx +141 -48
- package/src/charts/primitives/ChartFrame.tsx +243 -60
- package/src/charts/primitives/ChartLegend.tsx +257 -73
- package/src/charts/primitives/ChartTooltip.tsx +8 -3
- package/src/charts/primitives/Crosshair.tsx +2 -2
- package/src/charts/primitives/HoverOverlay.module.css +13 -0
- package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/src/charts/primitives/HoverOverlay.tsx +15 -4
- package/src/charts/primitives/chart-card-context.ts +37 -0
- package/src/charts/primitives/chart-frame-layout.ts +214 -177
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- package/src/charts/series.ts +4 -1
- package/src/charts/utils/ticks.ts +95 -19
- package/src/common/errors.ts +25 -1
- package/src/common/hit-floor.ts +9 -0
- package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
- package/src/common/use-measured-widths.ts +88 -0
- package/src/common/use-media-query.ts +56 -0
- package/src/common/validate.ts +17 -4
- package/src/content/article-layout.module.css +45 -4
- package/src/content/article-layout.module.css.d.ts +3 -0
- package/src/content/article-layout.tsx +60 -5
- package/src/content/code-block.module.css +32 -2
- package/src/content/code-block.tsx +47 -3
- package/src/content/prose.module.css +13 -8
- package/src/controls/actions.module.css +14 -1
- package/src/controls/actions.module.css.d.ts +1 -0
- package/src/controls/actions.tsx +223 -75
- package/src/controls/controls.module.css +8 -31
- package/src/controls/enum-filter.tsx +2 -2
- package/src/controls/filter-context.tsx +11 -0
- package/src/controls/filter-pill.tsx +1 -0
- package/src/controls/index.ts +0 -1
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/sync-button.module.css +1 -1
- package/src/dashboard/section.module.css +1 -26
- package/src/dashboard/section.tsx +1 -0
- package/src/dashboard/stat-card.module.css +6 -9
- package/src/dashboard/stat-card.tsx +6 -3
- package/src/dashboard/stat-group.module.css +16 -4
- package/src/dashboard/widget-grid.tsx +7 -6
- package/src/dashboard/widget-header.module.css +145 -133
- package/src/dashboard/widget-header.module.css.d.ts +2 -0
- package/src/dashboard/widget-header.tsx +45 -11
- package/src/data/data-table.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +503 -164
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +30 -5
- package/src/forms/form-layout.module.css.d.ts +1 -0
- package/src/forms/form-layout.tsx +12 -10
- package/src/guard/index.ts +183 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/provider/index.tsx +53 -1
- package/src/shell/app-brand.module.css +2 -2
- package/src/shell/app-brand.tsx +1 -0
- package/src/shell/app-header.module.css +18 -8
- package/src/shell/app-main.module.css +54 -3
- package/src/shell/app-main.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.module.css +16 -4
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/index.tsx +200 -129
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +70 -21
- package/src/shell/page-bar.module.css +10 -34
- package/src/shell/page-bar.tsx +10 -5
- package/src/shell/use-breakpoint.ts +9 -45
- package/src/shell/use-size-class.ts +31 -0
- package/src/state/persisted-or-local.ts +10 -4
- package/src/state/persisted.ts +76 -17
- package/src/styles.css +41 -0
- package/src/surfaces.ts +9 -1
- package/src/theme/controls.module.css +7 -0
- package/src/theme/ctl-theme.tsx +15 -3
- package/src/theme/floating.module.css +36 -0
- package/src/theme/floating.module.css.d.ts +3 -0
- package/src/theme/index.ts +46 -8
- package/src/theme/nav-link.module.css +7 -0
- package/src/tokens/index.ts +20 -30
- package/src/tokens/palette.ts +6 -28
- package/src/tokens/size-classes.ts +46 -0
- package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
- /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
|
@@ -12,15 +12,43 @@
|
|
|
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
16
|
import type { CSSProperties, ReactNode } from 'react'
|
|
16
17
|
import { cx } from '../../common/props'
|
|
17
18
|
import type { BasaltProps, SlotStylesProps } from '../../common/props'
|
|
18
19
|
import { VX } from '../../tokens'
|
|
19
20
|
import { WidgetHeader } from '../../dashboard/widget-header'
|
|
20
21
|
import type { DeltaPolarity } from '../../dashboard/delta-badge'
|
|
22
|
+
import { useChartSize } from '../hooks/useChartSize'
|
|
23
|
+
import { useHysteresis } from '../hooks/use-hysteresis'
|
|
24
|
+
import { ChartCardContext } from './chart-card-context'
|
|
21
25
|
import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
|
|
22
26
|
import type { ChartState } from './ChartPending'
|
|
23
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Below this rendered height the header folds its subtitle regardless of width (wave 8's
|
|
30
|
+
* header-economy law, `docs/CHARTS-SPEC.md` §5) — a short-but-wide card (a KPI-style
|
|
31
|
+
* chart card) still needs the vertical space back. A `@container basalt-card` SIZE query would be
|
|
32
|
+
* the natural expression, but `container-type: size` on a box with no explicit height (the common,
|
|
33
|
+
* non-grid-stretched case) collapses it to 0 — CSS containment requires a definite size in the
|
|
34
|
+
* contained axis, and a ChartCard's height is ordinarily derived from its content (VERIFIED: making
|
|
35
|
+
* the root a size container drops `card-chrome.layout.test.ts`'s unstretched short-card fixture to
|
|
36
|
+
* a 0px box). `useChartSize` (the same measuring primitive `ChartFrame` uses) reads the rendered
|
|
37
|
+
* height instead, and `data-basalt-card-short` is the CSS hook `widget-header.module.css` reacts to
|
|
38
|
+
* with the same subtitle-fold declarations as the width query.
|
|
39
|
+
*/
|
|
40
|
+
const CARD_SHORT_HEIGHT = 280
|
|
41
|
+
/**
|
|
42
|
+
* Hysteresis band on the flag above (`docs/waves/PLAN.md` wave 3, P0-1). `short` folds the header,
|
|
43
|
+
* which changes the card's OWN measured height by the fold delta — a card whose unfolded height
|
|
44
|
+
* lands in `[280, 280 + Δ)` is bistable, and the async RO → rAF → debounce → setState → commit path
|
|
45
|
+
* can sit one reading behind, which turned the bistability into a perpetual flip-flop on the
|
|
46
|
+
* "Sales by channel" dashboard card (27-52 times per 2s, every viewport). Entering short still
|
|
47
|
+
* happens below `CARD_SHORT_HEIGHT`; LEAVING it needs `CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK`, sized
|
|
48
|
+
* to clear the largest fold delta (subtitle line + inset + value row) with headroom.
|
|
49
|
+
*/
|
|
50
|
+
const SHORT_EXIT_SLACK = 24
|
|
51
|
+
|
|
24
52
|
// Surfaces resolve per theme via CSS vars, so the styles are static (no useMemo/isDark).
|
|
25
53
|
// Depth = `shadow-card` (a whisper shadow + a 1px ring baked into the same value), never a
|
|
26
54
|
// `border` property (docs/DESIGN-SPEC.md §5) — so a ChartCard reads IDENTICALLY to a Mantine
|
|
@@ -31,7 +59,16 @@ import type { ChartState } from './ChartPending'
|
|
|
31
59
|
// `WidgetHeader`'s info bubble (`widget-header.module.css`'s `.infoBubble`, which is where the
|
|
32
60
|
// tooltip this file used to own now lives) opens downward past the header's own box, and must be
|
|
33
61
|
// able to overhang the card edge without being invisibly cut off.
|
|
62
|
+
//
|
|
63
|
+
// The root is a flex column and a `basalt-card` inline-size container (the card's own width drives
|
|
64
|
+
// its header chrome, `docs/CHARTS-SPEC.md` §8): the body takes the row's spare height when a grid stretches
|
|
65
|
+
// the card.
|
|
34
66
|
const cardStyle: CSSProperties = {
|
|
67
|
+
display: 'flex',
|
|
68
|
+
flexDirection: 'column',
|
|
69
|
+
minHeight: 0,
|
|
70
|
+
containerType: 'inline-size',
|
|
71
|
+
containerName: 'basalt-card',
|
|
35
72
|
borderRadius: VX.radiusCard,
|
|
36
73
|
boxShadow: VX.shadowCard,
|
|
37
74
|
backgroundColor: VX.surface.panel,
|
|
@@ -45,6 +82,11 @@ const bodyClipStyle: CSSProperties = {
|
|
|
45
82
|
borderBottomLeftRadius: VX.radiusCard,
|
|
46
83
|
borderBottomRightRadius: VX.radiusCard,
|
|
47
84
|
overflow: 'hidden',
|
|
85
|
+
flex: '1 1 auto',
|
|
86
|
+
minHeight: 0,
|
|
87
|
+
// A column, so a `ChartFrame` inside can grow into the body a stretched grid row hands the card.
|
|
88
|
+
display: 'flex',
|
|
89
|
+
flexDirection: 'column',
|
|
48
90
|
padding: '2px var(--vx-space-card-inset-x, 0.8125rem) var(--vx-space-card-inset-y, 0.6875rem)',
|
|
49
91
|
}
|
|
50
92
|
// Card inset = the `cardInsetY`/`cardInsetX` role tokens (vertical / horizontal), the SAME two
|
|
@@ -60,6 +102,13 @@ const bodyClipStyle: CSSProperties = {
|
|
|
60
102
|
const headerWrapStyle: CSSProperties = {
|
|
61
103
|
padding: 'var(--vx-space-card-inset-y, 0.6875rem) var(--vx-space-card-inset-x, 0.8125rem) 4px',
|
|
62
104
|
}
|
|
105
|
+
// A short card (wave 8) trims the header's own top inset — the subtitle line and the wide-tier
|
|
106
|
+
// value/delta row are both already gone by the time `short` is true, so the full card inset reads
|
|
107
|
+
// as dead air rather than rhythm. Bottom stays 4px (unchanged) so the header-to-body seam matches
|
|
108
|
+
// every other ChartCard.
|
|
109
|
+
const headerWrapShortStyle: CSSProperties = {
|
|
110
|
+
padding: '4px var(--vx-space-card-inset-x, 0.8125rem) 4px',
|
|
111
|
+
}
|
|
63
112
|
|
|
64
113
|
/**
|
|
65
114
|
* The three boxes a `ChartCard` paints, and therefore the slot set a consumer may class
|
|
@@ -143,6 +192,8 @@ export function ChartCard({
|
|
|
143
192
|
style,
|
|
144
193
|
children,
|
|
145
194
|
}: ChartCardProps) {
|
|
195
|
+
const [legendSlot, setLegendSlot] = useState<HTMLDivElement | null>(null)
|
|
196
|
+
const { ref: sizeRef, height: measuredHeight } = useChartSize()
|
|
146
197
|
const resolvedState = resolveChartState({ ...(state !== undefined && { state }) })
|
|
147
198
|
const hasHeader =
|
|
148
199
|
title !== undefined ||
|
|
@@ -151,59 +202,101 @@ export function ChartCard({
|
|
|
151
202
|
actions !== undefined ||
|
|
152
203
|
icon !== undefined ||
|
|
153
204
|
count !== undefined
|
|
205
|
+
// Hysteresis (P0-1, `SHORT_EXIT_SLACK`'s own doc): which threshold applies depends on the
|
|
206
|
+
// PREVIOUS committed flag, so a card whose unfolded height sits in the bistable band settles
|
|
207
|
+
// instead of flip-flopping forever.
|
|
208
|
+
const short = useHysteresis(
|
|
209
|
+
(wasShort) =>
|
|
210
|
+
measuredHeight > 0 &&
|
|
211
|
+
measuredHeight < (wasShort ? CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK : CARD_SHORT_HEIGHT),
|
|
212
|
+
false,
|
|
213
|
+
)
|
|
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
|
+
}, [])
|
|
231
|
+
const context = useMemo(
|
|
232
|
+
() => ({
|
|
233
|
+
legendSlot,
|
|
234
|
+
inCard: true,
|
|
235
|
+
short,
|
|
236
|
+
claimLegendSlot,
|
|
237
|
+
releaseLegendSlot,
|
|
238
|
+
legendSlotVersion,
|
|
239
|
+
}),
|
|
240
|
+
[legendSlot, short, claimLegendSlot, releaseLegendSlot, legendSlotVersion],
|
|
241
|
+
)
|
|
154
242
|
|
|
155
243
|
return (
|
|
156
|
-
<
|
|
157
|
-
className={cx(classNames?.root, className)}
|
|
158
|
-
style={{ ...cardStyle, ...style }}
|
|
159
|
-
{...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
|
|
160
|
-
>
|
|
161
|
-
{hasHeader && (
|
|
162
|
-
<div
|
|
163
|
-
{...(classNames?.header !== undefined && { className: classNames.header })}
|
|
164
|
-
style={headerWrapStyle}
|
|
165
|
-
>
|
|
166
|
-
<WidgetHeader
|
|
167
|
-
tier="widget"
|
|
168
|
-
title={title ?? ''}
|
|
169
|
-
{...(icon !== undefined && { icon })}
|
|
170
|
-
{...(subtitle !== undefined && { subtitle })}
|
|
171
|
-
{...(info !== undefined && { info })}
|
|
172
|
-
{...(value !== undefined && { value })}
|
|
173
|
-
{...(delta !== undefined && { delta })}
|
|
174
|
-
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
175
|
-
{...(deltaPolarity !== undefined && { deltaPolarity })}
|
|
176
|
-
{...(count !== undefined && { count })}
|
|
177
|
-
{...(actions !== undefined && {
|
|
178
|
-
actions: <span data-basalt-tier="widget">{actions}</span>,
|
|
179
|
-
})}
|
|
180
|
-
/>
|
|
181
|
-
</div>
|
|
182
|
-
)}
|
|
244
|
+
<ChartCardContext.Provider value={context}>
|
|
183
245
|
<div
|
|
184
|
-
{
|
|
185
|
-
|
|
246
|
+
ref={sizeRef}
|
|
247
|
+
className={cx(classNames?.root, className)}
|
|
248
|
+
style={{ ...cardStyle, ...style }}
|
|
249
|
+
{...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
|
|
250
|
+
{...(short && { 'data-basalt-card-short': '' })}
|
|
186
251
|
>
|
|
187
|
-
{
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
252
|
+
{hasHeader && (
|
|
253
|
+
<div
|
|
254
|
+
{...(classNames?.header !== undefined && { className: classNames.header })}
|
|
255
|
+
style={short ? headerWrapShortStyle : headerWrapStyle}
|
|
256
|
+
>
|
|
257
|
+
<WidgetHeader
|
|
258
|
+
tier="widget"
|
|
259
|
+
title={title ?? ''}
|
|
260
|
+
{...(icon !== undefined && { icon })}
|
|
261
|
+
{...(subtitle !== undefined && { subtitle })}
|
|
262
|
+
{...(info !== undefined && { info })}
|
|
263
|
+
{...(value !== undefined && { value })}
|
|
264
|
+
{...(delta !== undefined && { delta })}
|
|
265
|
+
{...(deltaPeriod !== undefined && { deltaPeriod })}
|
|
266
|
+
{...(deltaPolarity !== undefined && { deltaPolarity })}
|
|
267
|
+
{...(count !== undefined && { count })}
|
|
268
|
+
{...(actions !== undefined && {
|
|
269
|
+
actions: <span data-basalt-tier="widget">{actions}</span>,
|
|
270
|
+
})}
|
|
271
|
+
/>
|
|
272
|
+
<div ref={setLegendSlot} data-basalt-legend-slot="" />
|
|
273
|
+
</div>
|
|
205
274
|
)}
|
|
275
|
+
<div
|
|
276
|
+
{...(classNames?.body !== undefined && { className: classNames.body })}
|
|
277
|
+
style={bodyClipStyle}
|
|
278
|
+
>
|
|
279
|
+
{resolvedState === 'pending' ? (
|
|
280
|
+
<ChartPending width="100%" height={placeholderHeight} />
|
|
281
|
+
) : resolvedState === 'error' ? (
|
|
282
|
+
<ChartError
|
|
283
|
+
width="100%"
|
|
284
|
+
height={placeholderHeight}
|
|
285
|
+
error={state?.error}
|
|
286
|
+
action={stateAction}
|
|
287
|
+
/>
|
|
288
|
+
) : resolvedState === 'empty' ? (
|
|
289
|
+
<ChartEmpty
|
|
290
|
+
width="100%"
|
|
291
|
+
height={placeholderHeight}
|
|
292
|
+
{...(typeof state?.empty === 'string' && { label: state.empty })}
|
|
293
|
+
action={stateAction}
|
|
294
|
+
/>
|
|
295
|
+
) : (
|
|
296
|
+
children
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
206
299
|
</div>
|
|
207
|
-
</
|
|
300
|
+
</ChartCardContext.Provider>
|
|
208
301
|
)
|
|
209
302
|
}
|
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
-
import { useCallback, useState } from 'react'
|
|
2
|
+
import { useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
3
4
|
import type { BasaltProps } from '../../common/props'
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
deprecatedProp,
|
|
7
|
+
ignoredProp,
|
|
8
|
+
legendSlotContention,
|
|
9
|
+
plotBelowFloor,
|
|
10
|
+
} from '../../common/errors'
|
|
11
|
+
import { reportOnce, useValidateProps } from '../../common/validate'
|
|
12
|
+
import { SizeClassHintContext, useSizeClass } from '../../shell/use-size-class'
|
|
6
13
|
import { VX } from '../../tokens'
|
|
7
14
|
import { useChartSize } from '../hooks/useChartSize'
|
|
8
15
|
import { deriveLegend } from '../series'
|
|
9
16
|
import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
resolveLegendRollup,
|
|
14
|
-
resolvePlotRect,
|
|
15
|
-
} from './chart-frame-layout'
|
|
17
|
+
import { ChartCardContext } from './chart-card-context'
|
|
18
|
+
import { resolveChartLayout, resolveFrameClass } from './chart-layout'
|
|
19
|
+
import { resolveFrameHeight, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
|
|
16
20
|
import type { ResponsiveChartHeight } from './chart-frame-layout'
|
|
17
|
-
import { ChartTierProvider } from './chart-tier'
|
|
21
|
+
import { ChartTierProvider, useCoarsePointer, useViewportHeight } from './chart-tier'
|
|
18
22
|
import { ChartLegend } from './ChartLegend'
|
|
19
23
|
import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
|
|
20
24
|
import type { ChartState } from './ChartPending'
|
|
21
25
|
|
|
22
26
|
/** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
|
|
23
27
|
* through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
|
|
24
|
-
export {
|
|
25
|
-
legendEntryCap,
|
|
26
|
-
resolveLegendRollup,
|
|
27
|
-
resolvePlotRect,
|
|
28
|
-
resolveChartTier,
|
|
29
|
-
} from './chart-frame-layout'
|
|
28
|
+
export { legendEntryCap, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
|
|
30
29
|
export type { ResponsiveChartHeight } from './chart-frame-layout'
|
|
31
30
|
|
|
32
|
-
const DEFAULT_HEIGHT = 240
|
|
33
31
|
const DEFAULT_MIN_WIDTH = 200
|
|
34
32
|
|
|
35
33
|
/** Legend configuration for {@link ChartFrame}. Omit entirely (or pass `{}`) for the default
|
|
@@ -38,22 +36,12 @@ export type ChartFrameLegend = {
|
|
|
38
36
|
/** Default 'bottom'. */
|
|
39
37
|
placement?: LegendPlacement
|
|
40
38
|
/**
|
|
41
|
-
*
|
|
42
|
-
* into
|
|
39
|
+
* @deprecated Removed in 1.32.0 — the legend now fits by measured width every time and folds
|
|
40
|
+
* overflow into an `All N` disclosure (`docs/CHARTS-SPEC.md` §8); drop the prop.
|
|
43
41
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* (`entries.slice(0, cap)`); nothing here measures rows.
|
|
48
|
-
*
|
|
49
|
-
* An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
|
|
50
|
-
* AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
|
|
51
|
-
* the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
|
|
52
|
-
* 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
|
|
53
|
-
* nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
|
|
54
|
-
* number a caller stated, so under `fill` the PLOT yields the height instead
|
|
55
|
-
* ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
|
|
56
|
-
* gets what is left — visible, and yours.
|
|
42
|
+
* Until then an explicit value still WINS OUTRIGHT over the measured fit, and it counts ENTRIES,
|
|
43
|
+
* not rows: `maxRows: 3` on a 7-entry legend renders three entries. Under `fill` the PLOT yields
|
|
44
|
+
* the height ({@link resolvePlotRect}'s `legendWins`).
|
|
57
45
|
*/
|
|
58
46
|
maxRows?: number
|
|
59
47
|
/** Visually separate role: series | overlay | reference. */
|
|
@@ -68,20 +56,32 @@ export type ChartFrameLegend = {
|
|
|
68
56
|
* plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
|
|
69
57
|
*/
|
|
70
58
|
toggle?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Internal escape hatch for a kind that never wants its legend portalled into a `ChartCard`
|
|
61
|
+
* header, whatever the placement — `Donut` sets this (`docs/waves/PLAN.md` wave 3, P1-4): its
|
|
62
|
+
* side/bottom legend is the ring's only key, so it must stay under or beside the ring rather than
|
|
63
|
+
* follow every other frame's default of claiming an available header slot. `false` keeps the
|
|
64
|
+
* legend in its own band regardless of `placement`; omitted (default) behaves as today. Not
|
|
65
|
+
* something a consumer composing a kind's own `legend` prop can reach — kinds compose `ChartFrame`
|
|
66
|
+
* directly to set it.
|
|
67
|
+
*/
|
|
68
|
+
headerSlot?: boolean
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
export type ChartFrameProps = BasaltProps & {
|
|
74
72
|
/** Series identity — drives the derived legend. Pass the SAME array the kind draws + tooltips from. */
|
|
75
73
|
series: readonly SeriesStyle[]
|
|
76
74
|
/**
|
|
77
|
-
* Height in pixels, or one per
|
|
78
|
-
* Default
|
|
75
|
+
* Height in pixels, or one per container step. Used when neither `aspectRatio` nor `fill` is set.
|
|
76
|
+
* Default: derived from the measured width and container class (`resolveChartLayout`). Inside a
|
|
77
|
+
* `ChartCard` the resulting height is a MINIMUM — the frame grows into a taller card body. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
|
|
79
78
|
* this is never read, which now warns once in dev (`common/errors.ts`' `ignoredProp`).
|
|
80
79
|
*
|
|
81
|
-
* `{ base: 180,
|
|
82
|
-
* steps are compared against the frame's own MEASURED width, not the viewport,
|
|
83
|
-
* by a `PageAside` gets the short height on a desktop too. A plain number is
|
|
84
|
-
* supported ({@link resolveFrameHeight})
|
|
80
|
+
* `{ base: 180, wide: 260 }` is the declarative escape from a page of fixed-height charts: the
|
|
81
|
+
* `regular`/`wide` steps are compared against the frame's own MEASURED width, not the viewport,
|
|
82
|
+
* so a chart squeezed by a `PageAside` gets the short height on a desktop too. A plain number is
|
|
83
|
+
* an override and stays supported ({@link resolveFrameHeight}); one that leaves the plot under
|
|
84
|
+
* `VX.minPlotHeight` warns once in dev.
|
|
85
85
|
*/
|
|
86
86
|
height?: number | ResponsiveChartHeight
|
|
87
87
|
/** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set — which warns
|
|
@@ -136,6 +136,10 @@ export type PlotRect = {
|
|
|
136
136
|
width: number
|
|
137
137
|
height: number
|
|
138
138
|
hidden: ReadonlySet<string>
|
|
139
|
+
/** Whether THIS frame's legend is currently portalled into a `ChartCard` header slot — a chart
|
|
140
|
+
* composing `ChartFrame` (e.g. `CartesianChart`'s end labels, R2C-10) reads this to skip a
|
|
141
|
+
* band-legend-only affordance once the header already names every series for free. */
|
|
142
|
+
legendInHeader: boolean
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
|
|
@@ -219,6 +223,104 @@ export function ChartFrame({
|
|
|
219
223
|
}: ChartFrameProps): ReactNode {
|
|
220
224
|
const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
|
|
221
225
|
const { ref: legendRef, width: legendW, height: legendH } = useChartSize()
|
|
226
|
+
const { ref: slotRef, width: slotMeasuredW } = useChartSize()
|
|
227
|
+
const {
|
|
228
|
+
inCard,
|
|
229
|
+
legendSlot,
|
|
230
|
+
short: cardShort,
|
|
231
|
+
claimLegendSlot,
|
|
232
|
+
releaseLegendSlot,
|
|
233
|
+
legendSlotVersion,
|
|
234
|
+
} = useContext(ChartCardContext)
|
|
235
|
+
const frameId = useId()
|
|
236
|
+
const [ownsSlot, setOwnsSlot] = useState(false)
|
|
237
|
+
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
|
+
const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
|
|
242
|
+
const vertical = placement === 'left' || placement === 'right'
|
|
243
|
+
// `Donut` sets `headerSlot: false` (P1-4) — it never wants the slot, so it must never CLAIM it
|
|
244
|
+
// either, or it would silently deny a sibling frame in the same card that does want it. A
|
|
245
|
+
// vertical (left/right) legend never portals into the header either (`inHeader` below requires
|
|
246
|
+
// `!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
|
|
248
|
+
// 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.
|
|
251
|
+
const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
|
|
252
|
+
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.
|
|
277
|
+
setSlotSeedW(legendSlot.getBoundingClientRect().width)
|
|
278
|
+
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)
|
|
301
|
+
setSlotSeedW(0)
|
|
302
|
+
return undefined
|
|
303
|
+
}
|
|
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])
|
|
318
|
+
const viewportClass = useSizeClass()
|
|
319
|
+
// No `BasaltProvider` (a charts-only consumer) means no viewport hint: the unmeasured first frame
|
|
320
|
+
// resolves to the regular (desktop) class, not phone chrome.
|
|
321
|
+
const sizeClass = useContext(SizeClassHintContext) === undefined ? 'medium' : viewportClass
|
|
322
|
+
const coarse = useCoarsePointer()
|
|
323
|
+
const viewportH = useViewportHeight()
|
|
222
324
|
const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
|
|
223
325
|
|
|
224
326
|
const toggleKey = useCallback((key: string) => {
|
|
@@ -255,25 +357,91 @@ export function ChartFrame({
|
|
|
255
357
|
],
|
|
256
358
|
[fill, height, aspectRatio],
|
|
257
359
|
)
|
|
360
|
+
const legendItems = legend === false ? [] : deriveLegend(series)
|
|
361
|
+
useValidateProps(
|
|
362
|
+
'ChartFrame',
|
|
363
|
+
() =>
|
|
364
|
+
// A cap covering every entry rolls nothing up — it is how `Donut` states "never roll up".
|
|
365
|
+
legend !== false && legend.maxRows !== undefined && legend.maxRows < legendItems.length
|
|
366
|
+
? deprecatedProp(
|
|
367
|
+
'ChartFrame',
|
|
368
|
+
'legend.maxRows',
|
|
369
|
+
'nothing (the legend fits by measured width)',
|
|
370
|
+
'1.32.0',
|
|
371
|
+
'Overflow folds into an `All N` disclosure; an explicit value still wins this release.',
|
|
372
|
+
)
|
|
373
|
+
: null,
|
|
374
|
+
[legend, legendItems.length],
|
|
375
|
+
)
|
|
376
|
+
const statedHeight =
|
|
377
|
+
height === undefined
|
|
378
|
+
? undefined
|
|
379
|
+
: resolveFrameHeight(height, resolveFrameClass({ frameW: containerW, sizeClass }))
|
|
380
|
+
// Memoized on scalars (the legend by its keys+labels) so `ChartTierProvider`'s value is stable
|
|
381
|
+
// across renders that change nothing. No slot, not the owner, or not measured yet → `slotW` 0 →
|
|
382
|
+
// 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)
|
|
384
|
+
const groups = legend !== false && legend.groups === true
|
|
385
|
+
// Role and note both feed the resolver (`orderEntries`'s grouping, `legendEntryWidth`'s measured
|
|
386
|
+
// fit), so a change to either — a series moving `role`, a note appearing/disappearing — has to
|
|
387
|
+
// invalidate this memo exactly like a key or label change does; keying on key+label alone missed
|
|
388
|
+
// both and served a stale layout until something else happened to change first.
|
|
389
|
+
const legendKey = legendItems
|
|
390
|
+
.map((item) => `${item.key}\u0000${item.label}\u0000${item.role ?? ''}\u0000${item.note ?? ''}`)
|
|
391
|
+
.join('\u0001')
|
|
392
|
+
const layout = useMemo(
|
|
393
|
+
() =>
|
|
394
|
+
resolveChartLayout({
|
|
395
|
+
frameW: containerW,
|
|
396
|
+
slotW,
|
|
397
|
+
viewportH,
|
|
398
|
+
legendItems,
|
|
399
|
+
sizeClass,
|
|
400
|
+
coarse,
|
|
401
|
+
cardShort,
|
|
402
|
+
groups,
|
|
403
|
+
...(statedHeight !== undefined && { override: { height: statedHeight } }),
|
|
404
|
+
}),
|
|
405
|
+
// `legendItems` is a fresh array per render; `legendKey` is its identity.
|
|
406
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
407
|
+
[containerW, slotW, viewportH, legendKey, sizeClass, coarse, cardShort, groups, statedHeight],
|
|
408
|
+
)
|
|
258
409
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
const
|
|
410
|
+
// The resolver gates every placement: none at micro, else the card's header slot when there is
|
|
411
|
+
// one AND this frame owns it (P2-9) — `slotW` is already 0 for a non-owner or a `headerSlot:
|
|
412
|
+
// false` kind (P1-4), so `fit.where` already reads 'band' for both; `ownsSlot` here is only the
|
|
413
|
+
// (harmless) belt to that suspenders.
|
|
414
|
+
const fit = layout.legend.mode === 'dots' || layout.legend.mode === 'chips' ? layout.legend : null
|
|
415
|
+
const legendMode = fit?.mode
|
|
416
|
+
const showLegend = legendVisible && fit !== null
|
|
417
|
+
const inHeader =
|
|
418
|
+
showLegend && !vertical && legendSlot !== null && ownsSlot && fit?.where === 'header'
|
|
419
|
+
const sideLegendWidth = showLegend && vertical ? legendW : 0
|
|
420
|
+
const topBottomLegendHeight = showLegend && !vertical && !inHeader ? legendH : 0
|
|
266
421
|
|
|
422
|
+
const computedHeight =
|
|
423
|
+
aspectRatio !== undefined ? Math.round(containerW / aspectRatio) : layout.height
|
|
424
|
+
// In a card the computed height is a floor: a stretched grid row hands the body more, and the
|
|
425
|
+
// frame (flex 1 in the body) takes it. Never below what the legend band plus the plot floor need.
|
|
426
|
+
const grows = inCard && !fill
|
|
267
427
|
const resolvedHeight = fill
|
|
268
428
|
? containerH
|
|
269
|
-
:
|
|
270
|
-
? Math.
|
|
271
|
-
:
|
|
429
|
+
: grows
|
|
430
|
+
? Math.max(computedHeight, containerH)
|
|
431
|
+
: computedHeight
|
|
432
|
+
const cardMinHeight = Math.max(computedHeight, VX.minPlotHeight + topBottomLegendHeight)
|
|
272
433
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
434
|
+
useValidateProps(
|
|
435
|
+
'ChartFrame',
|
|
436
|
+
() =>
|
|
437
|
+
typeof height === 'number' &&
|
|
438
|
+
!fill &&
|
|
439
|
+
aspectRatio === undefined &&
|
|
440
|
+
height - topBottomLegendHeight < VX.minPlotHeight
|
|
441
|
+
? plotBelowFloor('ChartFrame', height, VX.minPlotHeight)
|
|
442
|
+
: null,
|
|
443
|
+
[height, fill, aspectRatio, topBottomLegendHeight],
|
|
444
|
+
)
|
|
277
445
|
const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
|
|
278
446
|
|
|
279
447
|
// The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
|
|
@@ -283,8 +451,8 @@ export function ChartFrame({
|
|
|
283
451
|
// plot pays for. Top/bottom only: a side legend costs width, not height.
|
|
284
452
|
const { maxRows, legendWins } = resolveLegendRollup({
|
|
285
453
|
...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
|
|
286
|
-
|
|
287
|
-
fillBand:
|
|
454
|
+
...(!vertical && fit !== null && fit.overflow > 0 && { fittedMaxRows: fit.visible }),
|
|
455
|
+
fillBand: showLegend && fill && !inHeader && !vertical,
|
|
288
456
|
items: legendItems,
|
|
289
457
|
containerW,
|
|
290
458
|
available: resolvedHeight - VX.minPlotHeight,
|
|
@@ -300,7 +468,7 @@ export function ChartFrame({
|
|
|
300
468
|
})
|
|
301
469
|
|
|
302
470
|
const legendNode =
|
|
303
|
-
legend === false || resolvedState !== null ? null : (
|
|
471
|
+
legend === false || resolvedState !== null || !showLegend ? null : (
|
|
304
472
|
<div ref={legendRef} style={legendWrapperStyle(vertical)}>
|
|
305
473
|
<ChartLegend
|
|
306
474
|
items={legendItems}
|
|
@@ -310,6 +478,7 @@ export function ChartFrame({
|
|
|
310
478
|
{...(chartId !== undefined && { chartId })}
|
|
311
479
|
{...(legend.groups !== undefined && { groups: legend.groups })}
|
|
312
480
|
{...(maxRows !== undefined && { maxRows })}
|
|
481
|
+
{...(legendMode !== undefined && { mode: legendMode })}
|
|
313
482
|
{...(legend.highlighted !== undefined && { highlighted: legend.highlighted })}
|
|
314
483
|
{...(legend.onHighlight !== undefined && { onHighlight: legend.onHighlight })}
|
|
315
484
|
/>
|
|
@@ -317,15 +486,26 @@ export function ChartFrame({
|
|
|
317
486
|
)
|
|
318
487
|
|
|
319
488
|
return (
|
|
320
|
-
<ChartTierProvider
|
|
489
|
+
<ChartTierProvider containerClass={layout.containerClass}>
|
|
321
490
|
<div
|
|
322
491
|
ref={containerRef}
|
|
323
492
|
{...(className !== undefined && { className })}
|
|
324
|
-
style={{
|
|
493
|
+
style={{
|
|
494
|
+
...outerStyle(fill, vertical),
|
|
495
|
+
...(grows && { flex: '1 1 0', minHeight: cardMinHeight }),
|
|
496
|
+
...style,
|
|
497
|
+
}}
|
|
325
498
|
{...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
|
|
326
499
|
{...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
|
|
327
500
|
>
|
|
328
|
-
{
|
|
501
|
+
{inHeader &&
|
|
502
|
+
legendNode !== null &&
|
|
503
|
+
legendSlot !== null &&
|
|
504
|
+
createPortal(legendNode, legendSlot)}
|
|
505
|
+
{!inHeader &&
|
|
506
|
+
legendNode !== null &&
|
|
507
|
+
(placement === 'top' || placement === 'left') &&
|
|
508
|
+
legendNode}
|
|
329
509
|
{plot.width > 0 &&
|
|
330
510
|
plot.height > 0 &&
|
|
331
511
|
(resolvedState === 'pending' ? (
|
|
@@ -339,9 +519,12 @@ export function ChartFrame({
|
|
|
339
519
|
{...(typeof state?.empty === 'string' && { label: state.empty })}
|
|
340
520
|
/>
|
|
341
521
|
) : (
|
|
342
|
-
children({ ...plot, hidden })
|
|
522
|
+
children({ ...plot, hidden, legendInHeader: inHeader })
|
|
343
523
|
))}
|
|
344
|
-
{
|
|
524
|
+
{!inHeader &&
|
|
525
|
+
legendNode !== null &&
|
|
526
|
+
(placement === 'bottom' || placement === 'right') &&
|
|
527
|
+
legendNode}
|
|
345
528
|
</div>
|
|
346
529
|
</ChartTierProvider>
|
|
347
530
|
)
|