basalt-ui 1.31.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +101 -17
- package/agent/rules/basalt-mantine.md +4 -4
- package/configs/oxlint-plugin.js +159 -42
- package/configs/oxlint-plugin.test.ts +182 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +18 -66
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +5 -26
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +10 -68
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +49 -76
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +5 -23
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -40
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +5 -133
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +16 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +17 -1
- package/dist/charts/primitives/chart-card-context.d.ts +17 -13
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
- package/dist/charts/primitives/chart-card-context.js +10 -5
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +25 -31
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/controls/controls.module.css +5 -0
- package/dist/controls/sync-button.module.css +1 -0
- package/dist/dashboard/stat-group.module.css +19 -64
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +33 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +179 -277
- package/dist/forms/form-layout.module.css +3 -13
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -5
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +12 -10
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +0 -11
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +13 -14
- package/dist/shell/use-breakpoint.d.ts +1 -1
- package/dist/theme/floating.module.css +1 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/llms.txt +2 -2
- package/package.json +2 -1
- package/src/charts/kinds/BandStrip.tsx +18 -64
- package/src/charts/kinds/DualPanel.tsx +4 -24
- package/src/charts/kinds/MirroredBars.tsx +12 -66
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -122
- package/src/charts/primitives/ChartCard.tsx +5 -27
- package/src/charts/primitives/ChartFrame.tsx +37 -76
- package/src/charts/primitives/ChartLegend.tsx +7 -154
- package/src/charts/primitives/ChartTooltip.tsx +16 -0
- package/src/charts/primitives/HoverOverlay.tsx +52 -21
- package/src/charts/primitives/chart-card-context.ts +25 -16
- package/src/charts/primitives/chart-frame-layout.ts +44 -56
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/controls/controls.module.css +5 -0
- package/src/controls/sync-button.module.css +1 -0
- package/src/dashboard/stat-group.module.css +19 -64
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.tsx +127 -218
- package/src/forms/form-layout.module.css +3 -13
- package/src/guard/index.ts +21 -16
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +18 -17
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +15 -37
- package/src/shell/use-breakpoint.ts +2 -2
- package/src/theme/floating.module.css +1 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
|
@@ -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
|
})
|
|
@@ -286,38 +286,46 @@ const legendEntryWidth = (item: LegendEntry): number =>
|
|
|
286
286
|
)
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
|
-
* The greedy
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* entry always fits" guarantee a bare row-fit needs
|
|
293
|
-
*
|
|
294
|
-
* would always
|
|
289
|
+
* The ONE greedy wrap every legend fit runs — the same wrap the flex container performs, measured
|
|
290
|
+
* rather than assumed. Each item costs `widthOf(item)` (plus `gap` after the row's first) and
|
|
291
|
+
* `extraAfter(i)` more once placed (a group divider is its own flex item). `allowZero` (default
|
|
292
|
+
* false) drops the "the first entry always fits" guarantee a bare row-fit needs: a reserved pass
|
|
293
|
+
* (the `All N` chip already took its room) must be able to answer 0, or the SAME entry the caller
|
|
294
|
+
* is about to draw the chip beside would always count as fitting even when the two provably don't.
|
|
295
295
|
*/
|
|
296
|
-
function
|
|
297
|
-
items: readonly
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
296
|
+
function fitGreedyRows<T>(
|
|
297
|
+
items: readonly T[],
|
|
298
|
+
fit: {
|
|
299
|
+
widthOf: (item: T) => number
|
|
300
|
+
gap: number
|
|
301
|
+
width: number
|
|
302
|
+
rows: number
|
|
303
|
+
allowZero?: boolean
|
|
304
|
+
extraAfter?: (index: number) => number
|
|
305
|
+
},
|
|
301
306
|
): number {
|
|
302
307
|
let row = 1
|
|
303
308
|
let x = 0
|
|
304
309
|
let fitted = 0
|
|
305
|
-
for (const item of items) {
|
|
306
|
-
const w =
|
|
307
|
-
const next = x === 0 ? w : x +
|
|
308
|
-
if (x === 0 && allowZero && next > width) return 0
|
|
309
|
-
if (next > width && x > 0) {
|
|
310
|
+
for (const [i, item] of items.entries()) {
|
|
311
|
+
const w = fit.widthOf(item)
|
|
312
|
+
const next = x === 0 ? w : x + fit.gap + w
|
|
313
|
+
if (x === 0 && fit.allowZero === true && next > fit.width) return 0
|
|
314
|
+
if (next > fit.width && x > 0) {
|
|
310
315
|
row += 1
|
|
311
|
-
if (row > rows) return fitted
|
|
316
|
+
if (row > fit.rows) return fitted
|
|
312
317
|
x = w
|
|
313
318
|
} else {
|
|
314
319
|
x = next
|
|
315
320
|
}
|
|
316
321
|
fitted += 1
|
|
322
|
+
x += fit.extraAfter?.(i) ?? 0
|
|
317
323
|
}
|
|
318
324
|
return fitted
|
|
319
325
|
}
|
|
320
326
|
|
|
327
|
+
const chipFit = { widthOf: legendEntryWidth, gap: VX.legendGap }
|
|
328
|
+
|
|
321
329
|
/** How many of `items` fit in `rows` wrapped rows of `width` — the same greedy wrap the flex
|
|
322
330
|
* container performs, measured rather than assumed (`docs/CHARTS-SPEC.md` §1). */
|
|
323
331
|
export function entriesWithinRows(
|
|
@@ -325,7 +333,7 @@ export function entriesWithinRows(
|
|
|
325
333
|
width: number,
|
|
326
334
|
rows: number,
|
|
327
335
|
): number {
|
|
328
|
-
return
|
|
336
|
+
return fitGreedyRows(items, { ...chipFit, width, rows })
|
|
329
337
|
}
|
|
330
338
|
|
|
331
339
|
/**
|
|
@@ -342,44 +350,15 @@ export function entriesWithinChipRows(
|
|
|
342
350
|
rows: number,
|
|
343
351
|
): number {
|
|
344
352
|
const total = items.length
|
|
345
|
-
const fittedNoReserve =
|
|
353
|
+
const fittedNoReserve = entriesWithinRows(items, width, rows)
|
|
346
354
|
if (fittedNoReserve >= total) return fittedNoReserve
|
|
347
355
|
const reserve = measureText(`All ${total}`, VX.legendFontSize) + VX.legendGap
|
|
348
|
-
return
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
* own flag: without it the first dot always counts as fitted regardless of `width`, which is wrong
|
|
355
|
-
* for the reserved pass once reserving has left no usable room at all. */
|
|
356
|
-
function fitDotRows(
|
|
357
|
-
items: readonly LegendEntry[],
|
|
358
|
-
width: number,
|
|
359
|
-
rows: number,
|
|
360
|
-
dividerAfter: ReadonlySet<number>,
|
|
361
|
-
allowZero = false,
|
|
362
|
-
): number {
|
|
363
|
-
let row = 1
|
|
364
|
-
let x = 0
|
|
365
|
-
let fitted = 0
|
|
366
|
-
for (let i = 0; i < items.length; i += 1) {
|
|
367
|
-
// `DOT_PITCH` already IS one dot's full footprint (its own size plus the gap to a neighbour,
|
|
368
|
-
// `LEGEND_DOT_SIZE + DOTS_HIT_GAP`) — adding `LEGEND_DOT_SIZE` again here double-counted every
|
|
369
|
-
// dot after the row's first, wrapping a legend to `All N` well before it actually overflowed.
|
|
370
|
-
const next = x === 0 ? LEGEND_DOT_SIZE : x + DOT_PITCH
|
|
371
|
-
if (x === 0 && allowZero && next > width) return 0
|
|
372
|
-
if (next > width && x > 0) {
|
|
373
|
-
row += 1
|
|
374
|
-
if (row > rows) return fitted
|
|
375
|
-
x = LEGEND_DOT_SIZE
|
|
376
|
-
} else {
|
|
377
|
-
x = next
|
|
378
|
-
}
|
|
379
|
-
fitted += 1
|
|
380
|
-
if (dividerAfter.has(i)) x += DOT_PITCH
|
|
381
|
-
}
|
|
382
|
-
return fitted
|
|
356
|
+
return fitGreedyRows(items, {
|
|
357
|
+
...chipFit,
|
|
358
|
+
width: Math.max(width - reserve, 0),
|
|
359
|
+
rows,
|
|
360
|
+
allowZero: true,
|
|
361
|
+
})
|
|
383
362
|
}
|
|
384
363
|
|
|
385
364
|
/**
|
|
@@ -398,11 +377,20 @@ export function entriesWithinDotRows(
|
|
|
398
377
|
rows: number,
|
|
399
378
|
dividerAfter: ReadonlySet<number> = new Set(),
|
|
400
379
|
): number {
|
|
380
|
+
// A dot is `LEGEND_DOT_SIZE` wide and `DOT_PITCH` already IS its full footprint (size plus the
|
|
381
|
+
// gap to a neighbour) — so the gap is the pitch LESS the dot; adding the dot again double-counted
|
|
382
|
+
// every dot after the row's first and wrapped to `All N` well before it overflowed.
|
|
383
|
+
const dotFit = {
|
|
384
|
+
widthOf: () => LEGEND_DOT_SIZE,
|
|
385
|
+
gap: DOT_PITCH - LEGEND_DOT_SIZE,
|
|
386
|
+
rows,
|
|
387
|
+
extraAfter: (i: number) => (dividerAfter.has(i) ? DOT_PITCH : 0),
|
|
388
|
+
}
|
|
401
389
|
const total = items.length
|
|
402
|
-
const fittedNoReserve =
|
|
390
|
+
const fittedNoReserve = fitGreedyRows(items, { ...dotFit, width })
|
|
403
391
|
if (fittedNoReserve >= total) return fittedNoReserve
|
|
404
392
|
const reserve = measureText(`All ${total}`, VX.legendFontSize) + DOT_PITCH
|
|
405
|
-
return
|
|
393
|
+
return fitGreedyRows(items, { ...dotFit, width: Math.max(width - reserve, 0), allowZero: true })
|
|
406
394
|
}
|
|
407
395
|
|
|
408
396
|
/**
|