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.
Files changed (91) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/MIGRATING.md +101 -17
  3. package/agent/rules/basalt-mantine.md +4 -4
  4. package/configs/oxlint-plugin.js +159 -42
  5. package/configs/oxlint-plugin.test.ts +182 -0
  6. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/kinds/BandStrip.js +18 -66
  8. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  9. package/dist/charts/kinds/DualPanel.js +5 -26
  10. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  11. package/dist/charts/kinds/MirroredBars.js +10 -68
  12. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  13. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  14. package/dist/charts/kinds/band-chrome.js +71 -0
  15. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  16. package/dist/charts/primitives/CartesianChart.js +49 -76
  17. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  18. package/dist/charts/primitives/ChartCard.js +5 -23
  19. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  20. package/dist/charts/primitives/ChartFrame.js +23 -40
  21. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +5 -133
  23. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  24. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  25. package/dist/charts/primitives/ChartTooltip.js +16 -1
  26. package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
  27. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  28. package/dist/charts/primitives/HoverOverlay.js +17 -1
  29. package/dist/charts/primitives/chart-card-context.d.ts +17 -13
  30. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
  31. package/dist/charts/primitives/chart-card-context.js +10 -5
  32. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  33. package/dist/charts/primitives/chart-frame-layout.js +25 -31
  34. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  35. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  36. package/dist/charts/primitives/legend-disclosure.js +153 -0
  37. package/dist/controls/controls.module.css +5 -0
  38. package/dist/controls/sync-button.module.css +1 -0
  39. package/dist/dashboard/stat-group.module.css +19 -64
  40. package/dist/dashboard/widget-grid.module.css +11 -38
  41. package/dist/data/data-table-state.d.ts +83 -0
  42. package/dist/data/data-table-state.d.ts.map +1 -0
  43. package/dist/data/data-table-state.js +139 -0
  44. package/dist/data/data-table.d.ts +33 -2
  45. package/dist/data/data-table.d.ts.map +1 -1
  46. package/dist/data/data-table.js +179 -277
  47. package/dist/forms/form-layout.module.css +3 -13
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +6 -5
  50. package/dist/notifications/center.d.ts.map +1 -1
  51. package/dist/provider/index.d.ts.map +1 -1
  52. package/dist/provider/index.js +12 -10
  53. package/dist/shell/aside-docking.d.ts +31 -0
  54. package/dist/shell/aside-docking.d.ts.map +1 -0
  55. package/dist/shell/aside-docking.js +19 -0
  56. package/dist/shell/index.d.ts +0 -11
  57. package/dist/shell/index.d.ts.map +1 -1
  58. package/dist/shell/index.js +13 -14
  59. package/dist/shell/use-breakpoint.d.ts +1 -1
  60. package/dist/theme/floating.module.css +1 -0
  61. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  62. package/llms.txt +2 -2
  63. package/package.json +2 -1
  64. package/src/charts/kinds/BandStrip.tsx +18 -64
  65. package/src/charts/kinds/DualPanel.tsx +4 -24
  66. package/src/charts/kinds/MirroredBars.tsx +12 -66
  67. package/src/charts/kinds/band-chrome.tsx +91 -0
  68. package/src/charts/primitives/CartesianChart.tsx +79 -122
  69. package/src/charts/primitives/ChartCard.tsx +5 -27
  70. package/src/charts/primitives/ChartFrame.tsx +37 -76
  71. package/src/charts/primitives/ChartLegend.tsx +7 -154
  72. package/src/charts/primitives/ChartTooltip.tsx +16 -0
  73. package/src/charts/primitives/HoverOverlay.tsx +52 -21
  74. package/src/charts/primitives/chart-card-context.ts +25 -16
  75. package/src/charts/primitives/chart-frame-layout.ts +44 -56
  76. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  77. package/src/controls/controls.module.css +5 -0
  78. package/src/controls/sync-button.module.css +1 -0
  79. package/src/dashboard/stat-group.module.css +19 -64
  80. package/src/dashboard/widget-grid.module.css +11 -38
  81. package/src/data/data-table-state.ts +218 -0
  82. package/src/data/data-table.tsx +127 -218
  83. package/src/forms/form-layout.module.css +3 -13
  84. package/src/guard/index.ts +21 -16
  85. package/src/notifications/center.tsx +3 -0
  86. package/src/provider/index.tsx +18 -17
  87. package/src/shell/aside-docking.ts +54 -0
  88. package/src/shell/index.tsx +15 -37
  89. package/src/shell/use-breakpoint.ts +2 -2
  90. package/src/theme/floating.module.css +1 -0
  91. 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 [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))
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>) => openDisclosure(e.currentTarget) }
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 ? setDisclosure(null) : openDisclosure(e.currentTarget))}
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
- {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
- )}
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
- * `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.
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
- claimLegendSlot: (id: string) => boolean
28
- releaseLegendSlot: (id: string) => void
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
- claimLegendSlot: () => true,
35
- releaseLegendSlot: () => {},
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 chip-width wrap {@link entriesWithinRows} runs — split out so
290
- * {@link entriesWithinChipRows} can run it a second time against a narrower, reserve-adjusted
291
- * width without duplicating the wrap arithmetic. `allowZero` (default false) drops the "the first
292
- * entry always fits" guarantee a bare row-fit needs (a raw width-vs-0 check is meaningless
293
- * otherwise): without it, the SAME entry the caller is about to also draw an `All N` chip beside
294
- * would always be counted as fitting even when the two together provably don't.
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 fitChipRows(
297
- items: readonly LegendEntry[],
298
- width: number,
299
- rows: number,
300
- allowZero = false,
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 = legendEntryWidth(item)
307
- const next = x === 0 ? w : x + VX.legendGap + w
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 fitChipRows(items, width, rows)
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 = fitChipRows(items, width, rows)
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 fitChipRows(items, Math.max(width - reserve, 0), rows, true)
349
- }
350
-
351
- /** The greedy dot-pitch wrap `entriesWithinDotRows` runs twice — once optimistically, once against
352
- * a width that already gave up the `All N` chip's room. A group divider is its own flex item, so it
353
- * costs one more pitch before the next dot. `allowZero` (default false) mirrors {@link fitChipRows}'s
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 = fitDotRows(items, width, rows, dividerAfter)
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 fitDotRows(items, Math.max(width - reserve, 0), rows, dividerAfter, true)
393
+ return fitGreedyRows(items, { ...dotFit, width: Math.max(width - reserve, 0), allowZero: true })
406
394
  }
407
395
 
408
396
  /**