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
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type { CSSProperties,
|
|
2
|
-
import { useState } from 'react'
|
|
1
|
+
import type { CSSProperties, MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
3
4
|
import type { BasaltProps } from '../../common/props'
|
|
4
5
|
import { alpha, VX } from '../../tokens'
|
|
6
|
+
import { useDismissOnOutside } from '../cursor/touch-pin'
|
|
5
7
|
import type { SeriesRole, LegendPlacement } from '../series'
|
|
6
|
-
import {
|
|
8
|
+
import { DOTS_HIT_GAP, LEGEND_DOT_SIZE, LEGEND_ROW_GAP, orderEntries } from './chart-frame-layout'
|
|
9
|
+
import { useChartMetrics, useCoarsePointer } from './chart-tier'
|
|
7
10
|
|
|
8
11
|
export type LegendEntry = {
|
|
9
12
|
key: string
|
|
@@ -51,43 +54,77 @@ const LEGEND_ITEM_BUTTON: CSSProperties = {
|
|
|
51
54
|
textAlign: 'inherit',
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
const GROUP_ORDER: SeriesRole[] = ['series', 'overlay', 'reference']
|
|
55
|
-
|
|
56
57
|
/** Stable empty set — a fresh `new Set()` default would be a new identity on every render. */
|
|
57
58
|
const NO_HIDDEN: ReadonlySet<string> = new Set()
|
|
58
59
|
|
|
59
|
-
const
|
|
60
|
+
const DISCLOSURE_PANEL: CSSProperties = {
|
|
61
|
+
position: 'fixed',
|
|
62
|
+
zIndex: VX.zIndexFloating,
|
|
63
|
+
display: 'flex',
|
|
64
|
+
flexDirection: 'column',
|
|
65
|
+
gap: LEGEND_ROW_GAP,
|
|
66
|
+
// theme-allow raw-scroll-container — a fixed floating panel of one legend, not app chrome
|
|
67
|
+
overflowY: 'auto',
|
|
68
|
+
// `<dialog open>` brings UA chrome (centring margin, border, fit-content box) this resets.
|
|
69
|
+
margin: 0,
|
|
70
|
+
border: 'none',
|
|
71
|
+
width: 'auto',
|
|
72
|
+
height: 'auto',
|
|
73
|
+
padding: 10,
|
|
74
|
+
boxSizing: 'border-box',
|
|
75
|
+
backgroundColor: VX.surface.overlay,
|
|
76
|
+
boxShadow: VX.shadowOverlay,
|
|
77
|
+
color: VX.muted,
|
|
78
|
+
borderRadius: VX.radiusCard,
|
|
79
|
+
}
|
|
60
80
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
items: LegendEntry[],
|
|
64
|
-
groups: boolean,
|
|
65
|
-
): { entries: LegendEntry[]; dividerAfter: Set<number> } {
|
|
66
|
-
if (!groups) return { entries: items, dividerAfter: new Set() }
|
|
81
|
+
/** A popover never collapses below one usable row, however little room the chip leaves. */
|
|
82
|
+
const DISCLOSURE_MIN_HEIGHT = 96
|
|
67
83
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
/** A popover hangs off its chip (flipping above when there is no room below); a sheet is docked. */
|
|
85
|
+
function disclosurePlacement(chip: DOMRect, coarse: boolean): CSSProperties {
|
|
86
|
+
if (coarse) {
|
|
87
|
+
return {
|
|
88
|
+
left: 0,
|
|
89
|
+
right: 0,
|
|
90
|
+
bottom: 0,
|
|
91
|
+
maxHeight: '60dvh',
|
|
92
|
+
borderBottomLeftRadius: 0,
|
|
93
|
+
borderBottomRightRadius: 0,
|
|
94
|
+
padding: '12px 16px calc(12px + env(safe-area-inset-bottom, 0px))',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const below = window.innerHeight - chip.bottom
|
|
98
|
+
const left = Math.max(8, Math.min(chip.left, window.innerWidth - 248))
|
|
99
|
+
const base = { left, right: 'auto', minWidth: 200, maxWidth: 'calc(100vw - 16px)' }
|
|
100
|
+
return below >= 200 || below >= chip.top
|
|
101
|
+
? { ...base, top: chip.bottom + 4, maxHeight: Math.max(below - 12, DISCLOSURE_MIN_HEIGHT) }
|
|
102
|
+
: {
|
|
103
|
+
...base,
|
|
104
|
+
bottom: window.innerHeight - chip.top + 4,
|
|
105
|
+
maxHeight: Math.max(chip.top - 12, DISCLOSURE_MIN_HEIGHT),
|
|
106
|
+
}
|
|
78
107
|
}
|
|
79
108
|
|
|
80
|
-
function wrapperStyle(placement: LegendPlacement, fontSize: number): CSSProperties {
|
|
109
|
+
function wrapperStyle(placement: LegendPlacement, fontSize: number, dots: boolean): CSSProperties {
|
|
81
110
|
const vertical = placement === 'left' || placement === 'right'
|
|
111
|
+
const gap = dots ? DOTS_HIT_GAP : VX.legendGap
|
|
82
112
|
return {
|
|
83
113
|
display: 'flex',
|
|
84
114
|
flexDirection: vertical ? 'column' : 'row',
|
|
85
115
|
flexWrap: vertical ? 'nowrap' : 'wrap',
|
|
86
116
|
alignItems: vertical ? 'flex-start' : 'center',
|
|
87
|
-
justifyContent:
|
|
88
|
-
columnGap:
|
|
89
|
-
|
|
90
|
-
|
|
117
|
+
justifyContent: 'flex-start',
|
|
118
|
+
columnGap: gap,
|
|
119
|
+
// Always the tight row gap (R2C-9): the wider dots COLUMN gap is a hit-overlap cap, not
|
|
120
|
+
// breathing room between wrapped rows — a dots-mode header legend is 1 row by law
|
|
121
|
+
// (`LEGEND_ROWS.header` in `chart-layout.ts`) anyway, so this only ever shows in the band.
|
|
122
|
+
rowGap: LEGEND_ROW_GAP,
|
|
123
|
+
// Caps each entry's `[data-basalt-hit]` overlay at the real inter-entry gap (styles.css).
|
|
124
|
+
...({ '--vx-hit-gap': `${gap}px` } as CSSProperties),
|
|
125
|
+
// A dots-mode row has no text baseline to breathe around — the header-fold law (wave 8) is
|
|
126
|
+
// the reason this row exists at all, so it gets the same tight treatment.
|
|
127
|
+
padding: dots ? '4px 0 2px' : '8px 0 2px',
|
|
91
128
|
fontSize,
|
|
92
129
|
color: VX.muted,
|
|
93
130
|
}
|
|
@@ -205,6 +242,27 @@ const LEGEND_CHILD_STYLE: CSSProperties = {
|
|
|
205
242
|
opacity: 0.75,
|
|
206
243
|
}
|
|
207
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The whole entry collapses to its color, in 'dots' mode (`docs/CHARTS-SPEC.md` §5: a short or
|
|
247
|
+
* `compact`-container header has no room for a text label per entry) — a plain 8px circle
|
|
248
|
+
* regardless of the series' own shape, since a two-tone `split`/`splitLine` swatch cannot express
|
|
249
|
+
* two colors in one dot. The name survives via the button's own `aria-label`; sighted users get
|
|
250
|
+
* color-coded identity only, same trade the `compact` container class already made for the y axis.
|
|
251
|
+
*/
|
|
252
|
+
function LegendDot({ item }: { item: LegendEntry }) {
|
|
253
|
+
return (
|
|
254
|
+
<span
|
|
255
|
+
style={{
|
|
256
|
+
width: LEGEND_DOT_SIZE,
|
|
257
|
+
height: LEGEND_DOT_SIZE,
|
|
258
|
+
borderRadius: '50%', // theme-allow raw-surface — a circle shape, not a surface corner
|
|
259
|
+
backgroundColor: item.color,
|
|
260
|
+
flexShrink: 0,
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
208
266
|
/** Compact swatch for a folded child entry — smaller than {@link LegendSwatch}, line/bar only
|
|
209
267
|
* (folded companions are simple line or bar series; `split`/`splitLine` never fold). */
|
|
210
268
|
function LegendChildSwatch({ item }: { item: LegendEntry }) {
|
|
@@ -256,24 +314,19 @@ function LegendChild({ item }: { item: LegendEntry }) {
|
|
|
256
314
|
* static legends without hover interactivity. `chartId` namespaces `split` clipPath ids so two
|
|
257
315
|
* legends on the same page never collide. `groups` renders series → hairline divider → overlay →
|
|
258
316
|
* reference bands instead of one flat smear. `maxRows` caps the number of rendered ENTRIES and
|
|
259
|
-
* rolls the remainder into
|
|
260
|
-
* the name (`entries.slice(0, cap)` below is the whole of it
|
|
261
|
-
*
|
|
262
|
-
* `ChartTierMetrics.legendMaxRows`, so it stays until a minor can rename both.
|
|
317
|
+
* rolls the remainder into an `All N` disclosure chip — it is an entry count, not a row count, in
|
|
318
|
+
* spite of the name (`entries.slice(0, cap)` below is the whole of it). `ChartFrame` derives the
|
|
319
|
+
* cap from the measured fit (`resolveChartLayout().legend`); a caller-stated value wins.
|
|
263
320
|
*
|
|
264
321
|
* `onToggle` makes an entry a real toggle: clicking it hides the series in the plot, the tooltip,
|
|
265
322
|
* and the auto y-domain together (`ChartFrame` owns the `hidden` set — see `docs/CHARTS-SPEC.md`
|
|
266
|
-
* §5).
|
|
323
|
+
* §5). A hidden entry is announced via `aria-pressed`.
|
|
267
324
|
*
|
|
268
|
-
* The
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* unnamed, and a `+6 more` that is a `<span>` gives a phone reader no way to learn them — a
|
|
274
|
-
* categorical encoding the chart draws and refuses to decode. The chip is a `<button>` carrying
|
|
275
|
-
* `aria-expanded`: expanded, every entry renders, the legend band grows, and `ChartFrame`'s
|
|
276
|
-
* observer re-flows the frame around it (the plot keeps `VX.minPlotHeight`).
|
|
325
|
+
* **The rollup is a disclosure, not a label.** `All N` is a `<button>` carrying `aria-expanded`
|
|
326
|
+
* that opens EVERY entry, toggles included, in a popover under the chip on a fine pointer and a
|
|
327
|
+
* bottom sheet on a coarse one. Both are fixed-position and portalled, so opening one never grows
|
|
328
|
+
* the legend band or moves the plot. Escape, an outside press, a scroll or a resize closes it, and
|
|
329
|
+
* focus returns to the chip on every close.
|
|
277
330
|
*/
|
|
278
331
|
export function ChartLegend({
|
|
279
332
|
items,
|
|
@@ -285,6 +338,7 @@ export function ChartLegend({
|
|
|
285
338
|
placement = 'bottom',
|
|
286
339
|
groups = false,
|
|
287
340
|
maxRows,
|
|
341
|
+
mode = 'chips',
|
|
288
342
|
className,
|
|
289
343
|
style,
|
|
290
344
|
}: BasaltProps & {
|
|
@@ -299,40 +353,119 @@ export function ChartLegend({
|
|
|
299
353
|
placement?: LegendPlacement
|
|
300
354
|
groups?: boolean
|
|
301
355
|
maxRows?: number
|
|
356
|
+
/**
|
|
357
|
+
* `'dots'` collapses every VISIBLE entry to a plain color dot with no label (the name still
|
|
358
|
+
* reaches assistive tech via `aria-label`) — `resolveChartLayout`'s header-slot legend forces
|
|
359
|
+
* this for a short `ChartCard` (wave 11, `docs/CHARTS-SPEC.md` §5) so the legend band's height and
|
|
360
|
+
* width both shrink before it eats plot space. The `All N` disclosure panel is unaffected: it
|
|
361
|
+
* always shows the full swatch + label, `mode` only governs the inline row. Default `'chips'`
|
|
362
|
+
* (today's swatch + label rendering) for every other caller.
|
|
363
|
+
*/
|
|
364
|
+
mode?: 'dots' | 'chips'
|
|
302
365
|
}) {
|
|
303
|
-
const tier =
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
366
|
+
const tier = useChartMetrics()
|
|
367
|
+
const coarse = useCoarsePointer()
|
|
368
|
+
const [disclosure, setDisclosure] = useState<CSSProperties | null>(null)
|
|
369
|
+
// Whichever button opened the panel — the `All N` chip, or (dots mode) any entry — so focus
|
|
370
|
+
// returns to where the user actually tapped, not to a fixed chip that might not exist.
|
|
371
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
372
|
+
const panelRef = useRef<HTMLDialogElement>(null)
|
|
373
|
+
const open = disclosure !== null
|
|
374
|
+
|
|
375
|
+
const panelId = useId()
|
|
376
|
+
const wasOpen = useRef(false)
|
|
377
|
+
|
|
378
|
+
const openDisclosure = (trigger: HTMLButtonElement) => {
|
|
379
|
+
triggerRef.current = trigger
|
|
380
|
+
setDisclosure(disclosurePlacement(trigger.getBoundingClientRect(), coarse))
|
|
308
381
|
}
|
|
382
|
+
|
|
383
|
+
// Focus moves into the panel on open and back to the trigger on EVERY close path.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (open) panelRef.current?.focus()
|
|
386
|
+
else if (wasOpen.current) triggerRef.current?.focus()
|
|
387
|
+
wasOpen.current = open
|
|
388
|
+
}, [open])
|
|
389
|
+
|
|
390
|
+
// The panel is fixed to where the trigger WAS — a scroll outside it (any scroller, hence capture)
|
|
391
|
+
// or a resize leaves it hanging off nothing; the panel's own scroll is not one. Shared with the
|
|
392
|
+
// chart cursors' own outside-dismiss (`useChartCursor`/`useDiscreteCursor`, see `touch-pin.ts`).
|
|
393
|
+
const isInsidePanel = useCallback(
|
|
394
|
+
(target: Node): boolean =>
|
|
395
|
+
panelRef.current?.contains(target) === true || triggerRef.current?.contains(target) === true,
|
|
396
|
+
[],
|
|
397
|
+
)
|
|
398
|
+
useDismissOnOutside({
|
|
399
|
+
enabled: open,
|
|
400
|
+
isInside: isInsidePanel,
|
|
401
|
+
onDismiss: () => setDisclosure(null),
|
|
402
|
+
dismissOnResize: true,
|
|
403
|
+
scrollExemptInside: true,
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// The coarse sheet claims `aria-modal` (below), which is a claim that Tab cannot leave it — a
|
|
407
|
+
// native `<dialog open>` (no `showModal()`) does not enforce that on its own (B14). Wrap Tab at
|
|
408
|
+
// the panel's own first/last focusable rather than the fine popover, which is dismissed by an
|
|
409
|
+
// outside click/Escape just as easily and never claimed to be modal in the first place.
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (!open || !coarse) return
|
|
412
|
+
const panel = panelRef.current
|
|
413
|
+
if (panel === null) return
|
|
414
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
415
|
+
if (e.key !== 'Tab') return
|
|
416
|
+
const focusable = panel.querySelectorAll<HTMLElement>(
|
|
417
|
+
'button, [tabindex]:not([tabindex="-1"])',
|
|
418
|
+
)
|
|
419
|
+
const first = focusable[0]
|
|
420
|
+
const last = focusable[focusable.length - 1]
|
|
421
|
+
if (first === undefined || last === undefined) return
|
|
422
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
423
|
+
e.preventDefault()
|
|
424
|
+
last.focus()
|
|
425
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
426
|
+
e.preventDefault()
|
|
427
|
+
first.focus()
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
panel.addEventListener('keydown', onKeyDown)
|
|
431
|
+
return () => panel.removeEventListener('keydown', onKeyDown)
|
|
432
|
+
}, [open, coarse])
|
|
433
|
+
|
|
434
|
+
const handleEnter = (key: string) => onHighlight?.(key)
|
|
309
435
|
const handleLeave = () => onHighlight?.(null)
|
|
310
|
-
const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
|
|
311
|
-
const key = e.currentTarget.dataset['legendKey']
|
|
312
|
-
if (key !== undefined) onToggle?.(key)
|
|
313
|
-
}
|
|
314
436
|
|
|
315
437
|
const idPrefix = chartId ? `${chartId}-` : ''
|
|
316
438
|
const vertical = placement === 'left' || placement === 'right'
|
|
317
439
|
const { entries, dividerAfter } = orderEntries(items, groups)
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const visible = rolledUp > 0 && !expanded ? entries.slice(0, cap) : entries
|
|
440
|
+
const rolledUp = maxRows === undefined ? 0 : Math.max(entries.length - maxRows, 0)
|
|
441
|
+
const visible = rolledUp > 0 ? entries.slice(0, maxRows) : entries
|
|
321
442
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
443
|
+
// The disclosure panel is a second copy of the same entries: it gets its own id namespace and
|
|
444
|
+
// no `data-legend-key`, so a page query for the band's entries never finds the panel's twins.
|
|
445
|
+
const entryButton = (item: LegendEntry, inPanel = false): ReactNode => {
|
|
446
|
+
// A dots-mode entry names nothing on its own face — its whole point is the color — so a tap
|
|
447
|
+
// opens the same All-N sheet the overflow chip does, rather than silently hiding a series the
|
|
448
|
+
// reader was trying to identify (R2C-8: "what is this?" used to toggle it off instead). Gated
|
|
449
|
+
// on `mode` alone: with `legend.toggle: false` (no `onToggle`) a dots entry still has no OTHER
|
|
450
|
+
// way to name itself, so it still needs the title + disclosure — `onToggle !== undefined` used
|
|
451
|
+
// to suppress both just because toggling happened to be off.
|
|
452
|
+
const isDotTrigger = !inPanel && mode === 'dots'
|
|
453
|
+
return (
|
|
325
454
|
<button
|
|
326
455
|
key={item.key}
|
|
327
456
|
type="button"
|
|
328
|
-
data-
|
|
457
|
+
data-basalt-hit
|
|
458
|
+
{...(!inPanel && { 'data-legend-key': item.key })}
|
|
329
459
|
// The note is the whole point for a series that is invisible in the plot, so it has to
|
|
330
460
|
// reach a screen reader too — an explicit aria-label would otherwise replace it.
|
|
331
461
|
aria-label={item.note ? `${item.label} — ${item.note}` : item.label}
|
|
332
|
-
{...(
|
|
462
|
+
{...(isDotTrigger && { title: item.label, 'aria-haspopup': 'dialog' as const })}
|
|
463
|
+
{...(isDotTrigger
|
|
464
|
+
? { 'aria-expanded': open, 'aria-controls': panelId }
|
|
465
|
+
: onToggle !== undefined && { 'aria-pressed': !hidden.has(item.key) })}
|
|
333
466
|
style={{
|
|
334
467
|
...LEGEND_ITEM_BUTTON,
|
|
335
|
-
cursor: onToggle === undefined ? 'default' : 'pointer',
|
|
468
|
+
cursor: onToggle === undefined && !isDotTrigger ? 'default' : 'pointer',
|
|
336
469
|
// Toggled-off wins over hover-dimming: a hidden series must read as hidden even while
|
|
337
470
|
// it is the one being hovered.
|
|
338
471
|
opacity: hidden.has(item.key)
|
|
@@ -342,20 +475,33 @@ export function ChartLegend({
|
|
|
342
475
|
: 0.3,
|
|
343
476
|
textDecoration: hidden.has(item.key) ? 'line-through' : 'none',
|
|
344
477
|
}}
|
|
345
|
-
{...(
|
|
346
|
-
|
|
478
|
+
{...(isDotTrigger
|
|
479
|
+
? { onClick: (e: MouseEvent<HTMLButtonElement>) => openDisclosure(e.currentTarget) }
|
|
480
|
+
: onToggle !== undefined && { onClick: () => onToggle(item.key) })}
|
|
481
|
+
onMouseEnter={() => handleEnter(item.key)}
|
|
347
482
|
onMouseLeave={handleLeave}
|
|
348
|
-
onFocus={handleEnter}
|
|
483
|
+
onFocus={() => handleEnter(item.key)}
|
|
349
484
|
onBlur={handleLeave}
|
|
350
485
|
>
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
486
|
+
{!inPanel && mode === 'dots' ? (
|
|
487
|
+
<LegendDot item={item} />
|
|
488
|
+
) : (
|
|
489
|
+
<>
|
|
490
|
+
<LegendSwatch item={item} idPrefix={inPanel ? `${idPrefix}disclosure-` : idPrefix} />
|
|
491
|
+
<span>{item.label}</span>
|
|
492
|
+
{item.note ? <span style={LEGEND_NOTE_STYLE}>{item.note}</span> : null}
|
|
493
|
+
{item.children?.map((child) => (
|
|
494
|
+
<LegendChild key={child.key} item={child} />
|
|
495
|
+
))}
|
|
496
|
+
</>
|
|
497
|
+
)}
|
|
498
|
+
</button>
|
|
358
499
|
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const nodes: ReactNode[] = []
|
|
503
|
+
visible.forEach((item, i) => {
|
|
504
|
+
nodes.push(entryButton(item))
|
|
359
505
|
if (dividerAfter.has(i))
|
|
360
506
|
nodes.push(<LegendDivider key={`divider-${item.key}`} columnLayout={vertical} />)
|
|
361
507
|
})
|
|
@@ -364,11 +510,14 @@ export function ChartLegend({
|
|
|
364
510
|
<button
|
|
365
511
|
key="legend-more"
|
|
366
512
|
type="button"
|
|
367
|
-
|
|
368
|
-
|
|
513
|
+
data-basalt-hit
|
|
514
|
+
aria-expanded={open}
|
|
515
|
+
aria-haspopup="dialog"
|
|
516
|
+
aria-controls={panelId}
|
|
517
|
+
onClick={(e) => (open ? setDisclosure(null) : openDisclosure(e.currentTarget))}
|
|
369
518
|
style={{ ...LEGEND_ITEM_BUTTON, cursor: 'pointer', textDecoration: 'underline' }}
|
|
370
519
|
>
|
|
371
|
-
{
|
|
520
|
+
{`All ${entries.length}`}
|
|
372
521
|
</button>,
|
|
373
522
|
)
|
|
374
523
|
}
|
|
@@ -376,9 +525,44 @@ export function ChartLegend({
|
|
|
376
525
|
return (
|
|
377
526
|
<div
|
|
378
527
|
{...(className !== undefined && { className })}
|
|
379
|
-
style={{ ...wrapperStyle(placement, tier.legendFontSize), ...style }}
|
|
528
|
+
style={{ ...wrapperStyle(placement, tier.legendFontSize, mode === 'dots'), ...style }}
|
|
380
529
|
>
|
|
381
530
|
{nodes}
|
|
531
|
+
{open &&
|
|
532
|
+
createPortal(
|
|
533
|
+
<>
|
|
534
|
+
{coarse && (
|
|
535
|
+
<div
|
|
536
|
+
aria-hidden
|
|
537
|
+
onPointerDown={(e) => {
|
|
538
|
+
e.stopPropagation()
|
|
539
|
+
setDisclosure(null)
|
|
540
|
+
}}
|
|
541
|
+
style={{
|
|
542
|
+
position: 'fixed',
|
|
543
|
+
inset: 0,
|
|
544
|
+
// One below the panel it sits behind — still `VX.zIndexFloating`-derived, never a
|
|
545
|
+
// second unrelated literal (B14).
|
|
546
|
+
zIndex: VX.zIndexFloating - 1,
|
|
547
|
+
backgroundColor: alpha(VX.neutral, 0.35),
|
|
548
|
+
}}
|
|
549
|
+
/>
|
|
550
|
+
)}
|
|
551
|
+
<dialog
|
|
552
|
+
ref={panelRef}
|
|
553
|
+
id={panelId}
|
|
554
|
+
open
|
|
555
|
+
tabIndex={-1}
|
|
556
|
+
aria-label="Legend"
|
|
557
|
+
{...(coarse && { 'aria-modal': true })}
|
|
558
|
+
data-basalt-legend-disclosure={coarse ? 'sheet' : 'popover'}
|
|
559
|
+
style={{ ...DISCLOSURE_PANEL, fontSize: tier.legendFontSize, ...disclosure }}
|
|
560
|
+
>
|
|
561
|
+
{entries.map((item) => entryButton(item, true))}
|
|
562
|
+
</dialog>
|
|
563
|
+
</>,
|
|
564
|
+
document.body,
|
|
565
|
+
)}
|
|
382
566
|
</div>
|
|
383
567
|
)
|
|
384
568
|
}
|
|
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
4
|
import { VX } from '../../tokens'
|
|
5
5
|
import { fmtTooltipDate } from '../utils/format'
|
|
6
|
-
import {
|
|
6
|
+
import { useChartMetrics } from './chart-tier'
|
|
7
7
|
|
|
8
8
|
// Panel bg + shadow-card, radius 8 (docs/DESIGN-SPEC.md §5's "Tooltip/popover/menu" idiom) — the
|
|
9
9
|
// same depth-via-shadow treatment as ChartCard, never a `border` property. Surfaces resolve per
|
|
@@ -11,7 +11,7 @@ import { useChartTierMetrics } from './chart-tier'
|
|
|
11
11
|
const TOOLTIP_STYLES: CSSProperties = {
|
|
12
12
|
position: 'fixed',
|
|
13
13
|
pointerEvents: 'none',
|
|
14
|
-
zIndex:
|
|
14
|
+
zIndex: VX.zIndexFloating,
|
|
15
15
|
backgroundColor: VX.surface.panel,
|
|
16
16
|
borderRadius: 'var(--vx-radius-floating)',
|
|
17
17
|
padding: '0',
|
|
@@ -191,7 +191,7 @@ export function ChartTooltipFloat({
|
|
|
191
191
|
const ref = useRef<HTMLDivElement>(null)
|
|
192
192
|
const [box, setBox] = useState<{ width: number; height: number } | null>(null)
|
|
193
193
|
const isClient = useIsClient()
|
|
194
|
-
const { tooltipMinWidth } =
|
|
194
|
+
const { tooltipMinWidth } = useChartMetrics()
|
|
195
195
|
|
|
196
196
|
useLayoutEffect(() => {
|
|
197
197
|
const el = ref.current
|
|
@@ -215,6 +215,11 @@ export function ChartTooltipFloat({
|
|
|
215
215
|
const vw = window.innerWidth
|
|
216
216
|
const vh = window.innerHeight
|
|
217
217
|
|
|
218
|
+
// An anchor outside the viewport means the chart that owns it has scrolled off-screen. Clamping
|
|
219
|
+
// would paint a follower's box over unrelated chrome (the bottom nav — `R2C-3`); render nothing
|
|
220
|
+
// instead.
|
|
221
|
+
if (anchor.y < 0 || anchor.y > vh) return null
|
|
222
|
+
|
|
218
223
|
const flipsLeft = anchor.x + TOOLTIP_GAP + width + VIEWPORT_MARGIN > vw
|
|
219
224
|
const left = flipsLeft
|
|
220
225
|
? Math.max(VIEWPORT_MARGIN, anchor.x - TOOLTIP_GAP - width)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { VX } from '../../tokens'
|
|
3
|
-
import {
|
|
3
|
+
import { useChartMetrics } from './chart-tier'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The shared vertical hover crosshair — one implementation for what used to be five hand-rolled
|
|
@@ -38,6 +38,6 @@ export function SeriesDot({
|
|
|
38
38
|
color: string
|
|
39
39
|
r?: number
|
|
40
40
|
}): ReactNode {
|
|
41
|
-
const { dotR } =
|
|
41
|
+
const { dotR } = useChartMetrics()
|
|
42
42
|
return <circle cx={cx} cy={cy} r={r ?? dotR} fill={color} stroke={VX.dotStroke} strokeWidth={2} />
|
|
43
43
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `HoverOverlay` — the transparent plot rect every cartesian kind scrubs through. A tap focuses it
|
|
3
|
+
* on pointerdown, so the UA's own outline would ring the whole plot on every phone tap (`R2C-15`);
|
|
4
|
+
* keyboard focus keeps a VX accent inset instead.
|
|
5
|
+
*/
|
|
6
|
+
.overlay {
|
|
7
|
+
outline: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.overlay:focus-visible {
|
|
11
|
+
outline: 1px solid var(--vx-accent);
|
|
12
|
+
outline-offset: -1px;
|
|
13
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from 'react'
|
|
2
|
+
import styles from './HoverOverlay.module.css'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Transparent <rect> that captures pointer events (mouse + touch + pen) for tooltip + crosshair
|
|
@@ -11,6 +12,8 @@ export function HoverOverlay({
|
|
|
11
12
|
width,
|
|
12
13
|
height,
|
|
13
14
|
onMove,
|
|
15
|
+
onDown,
|
|
16
|
+
onUp,
|
|
14
17
|
onLeave,
|
|
15
18
|
onKeyDown,
|
|
16
19
|
onBlur,
|
|
@@ -22,6 +25,11 @@ export function HoverOverlay({
|
|
|
22
25
|
width: number
|
|
23
26
|
height: number
|
|
24
27
|
onMove: PointerEventHandler<SVGRectElement>
|
|
28
|
+
/** Coarse-pointer tap: resolves + shows the tooltip immediately, with no preceding `pointermove`
|
|
29
|
+
* (`docs/CHARTS-SPEC.md` §4, Tooltip). The press is provisional; `onUp` commits it. */
|
|
30
|
+
onDown?: PointerEventHandler<SVGRectElement>
|
|
31
|
+
/** Commits the provisional touch press so the pin survives the lift. */
|
|
32
|
+
onUp?: PointerEventHandler<SVGRectElement>
|
|
25
33
|
onLeave: PointerEventHandler<SVGRectElement>
|
|
26
34
|
/** Present = the overlay is focusable and scrubs on ←/→. */
|
|
27
35
|
onKeyDown?: KeyboardEventHandler<SVGRectElement>
|
|
@@ -35,7 +43,12 @@ export function HoverOverlay({
|
|
|
35
43
|
valueText?: string
|
|
36
44
|
}) {
|
|
37
45
|
return (
|
|
46
|
+
// `touch-action: pan-y` lives on the plot's `<svg>` (each kind's own), NOT here: Chrome ignores
|
|
47
|
+
// `touch-action` on an SVG child `<rect>`, so the browser claims a horizontal pan and cancels
|
|
48
|
+
// the pointer (`R2C-2`). The `overlay` class owns the focus ring: no UA outline on tap, a VX
|
|
49
|
+
// accent inset under `:focus-visible` (`R2C-15`).
|
|
38
50
|
<rect
|
|
51
|
+
className={styles.overlay}
|
|
39
52
|
width={width}
|
|
40
53
|
height={height}
|
|
41
54
|
fill="transparent"
|
|
@@ -54,11 +67,9 @@ export function HoverOverlay({
|
|
|
54
67
|
onKeyDown,
|
|
55
68
|
...(onBlur !== undefined && { onBlur }),
|
|
56
69
|
})}
|
|
57
|
-
// `pan-y`, not `none`: `none` would turn a full-width chart into a scroll dead zone on a
|
|
58
|
-
// phone. `pan-y` lets vertical page scroll pass through while a horizontal drag still
|
|
59
|
-
// reaches this overlay to scrub the chart.
|
|
60
|
-
style={{ touchAction: 'pan-y' }}
|
|
61
70
|
onPointerMove={onMove}
|
|
71
|
+
{...(onDown !== undefined && { onPointerDown: onDown })}
|
|
72
|
+
{...(onUp !== undefined && { onPointerUp: onUp })}
|
|
62
73
|
onPointerLeave={onLeave}
|
|
63
74
|
onPointerCancel={onLeave}
|
|
64
75
|
/>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `ChartCard`'s internal context: `inCard` (a frame inside a card treats its computed height as a
|
|
5
|
+
* MINIMUM and grows into the card body), the legend slot — the empty header-row box a chart will portal its legend into
|
|
6
|
+
* (`docs/CHARTS-SPEC.md` §5, Legend), so a card has one chrome band, not two — and `short`, the same
|
|
7
|
+
* `measuredHeight < CARD_SHORT_HEIGHT` flag `ChartCard` already computes for the header-fold law
|
|
8
|
+
* (`data-basalt-card-short`), threaded through so `ChartFrame` can fold its header-slot legend to
|
|
9
|
+
* 'dots' before it eats the plot floor (`docs/CHARTS-SPEC.md` §5, wave 11). `null`/`false` until the slot
|
|
10
|
+
* mounts, and outside any card. Deliberately not exported from any barrel.
|
|
11
|
+
*
|
|
12
|
+
* `claimLegendSlot`/`releaseLegendSlot` arbitrate the slot when a card hosts more than one
|
|
13
|
+
* `ChartFrame` (`docs/waves/PLAN.md` wave 3, P2-9): the first frame to claim an id keeps it for as
|
|
14
|
+
* long as it holds it, every later claimant is told no and falls back to its own band legend
|
|
15
|
+
* instead of a second frame silently portalling into the same node. Outside any card there is no
|
|
16
|
+
* slot to contend for, so the default always grants the claim.
|
|
17
|
+
*
|
|
18
|
+
* `legendSlotVersion` bumps every time the owner releases the slot (round 3's carried gap): a
|
|
19
|
+
* denied frame's claim attempt runs once, in its own mount effect, so with no signal that the slot
|
|
20
|
+
* freed up it would sit denied forever even after the owning frame unmounts. Frames read it only to
|
|
21
|
+
* put it in a dependency array — the effect re-running is the entire point, not the number itself.
|
|
22
|
+
*/
|
|
23
|
+
export const ChartCardContext = createContext<{
|
|
24
|
+
legendSlot: HTMLDivElement | null
|
|
25
|
+
inCard: boolean
|
|
26
|
+
short: boolean
|
|
27
|
+
claimLegendSlot: (id: string) => boolean
|
|
28
|
+
releaseLegendSlot: (id: string) => void
|
|
29
|
+
legendSlotVersion: number
|
|
30
|
+
}>({
|
|
31
|
+
legendSlot: null,
|
|
32
|
+
inCard: false,
|
|
33
|
+
short: false,
|
|
34
|
+
claimLegendSlot: () => true,
|
|
35
|
+
releaseLegendSlot: () => {},
|
|
36
|
+
legendSlotVersion: 0,
|
|
37
|
+
})
|