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