basalt-ui 1.16.0 → 1.18.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 +14 -0
- package/agent/rules/basalt-charts.md +58 -4
- package/agent/skills/basalt-design/SKILL.md +2 -2
- package/dist/charts/cursor/resolve.d.ts +29 -5
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +25 -4
- package/dist/charts/hooks/useChartCursor.d.ts +8 -1
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +6 -2
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +9 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +4 -0
- package/dist/charts/kinds/DualPanel.d.ts +24 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +39 -9
- package/dist/charts/kinds/MultiLine.d.ts +9 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +10 -8
- package/dist/charts/kinds/StackedArea.d.ts +9 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +16 -1
- package/dist/charts/kinds/StackedArea.test.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.test.d.ts.map +1 -0
- package/dist/charts/kinds/ZonedLine.d.ts +9 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +4 -0
- package/dist/charts/primitives/CartesianChart.d.ts +40 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +16 -6
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +1 -1
- package/dist/charts/primitives/ChartTooltip.d.ts +12 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +5 -3
- package/dist/charts/primitives/ChartTooltip.test.d.ts +2 -0
- package/dist/charts/primitives/ChartTooltip.test.d.ts.map +1 -0
- package/dist/charts/series.d.ts +9 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/domain.d.ts +15 -0
- package/dist/charts/utils/domain.d.ts.map +1 -1
- package/dist/charts/utils/domain.js +6 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/cursor/resolve.test.ts +62 -0
- package/src/charts/cursor/resolve.ts +71 -7
- package/src/charts/hooks/useChartCursor.ts +12 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/Bars.test.tsx +85 -0
- package/src/charts/kinds/Bars.tsx +13 -0
- package/src/charts/kinds/DualPanel.test.tsx +391 -1
- package/src/charts/kinds/DualPanel.tsx +74 -8
- package/src/charts/kinds/MultiLine.test.tsx +181 -0
- package/src/charts/kinds/MultiLine.tsx +19 -8
- package/src/charts/kinds/StackedArea.test.tsx +114 -0
- package/src/charts/kinds/StackedArea.tsx +25 -1
- package/src/charts/kinds/ZonedLine.test.tsx +110 -0
- package/src/charts/kinds/ZonedLine.tsx +13 -0
- package/src/charts/primitives/CartesianChart.test.tsx +284 -3
- package/src/charts/primitives/CartesianChart.tsx +64 -12
- package/src/charts/primitives/ChartFrame.test.tsx +42 -0
- package/src/charts/primitives/ChartFrame.tsx +9 -2
- package/src/charts/primitives/ChartTooltip.test.tsx +44 -0
- package/src/charts/primitives/ChartTooltip.tsx +14 -2
- package/src/charts/series.ts +8 -2
- package/src/charts/utils/domain.test.ts +31 -1
- package/src/charts/utils/domain.ts +19 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
2
2
|
import { describe, expect, test } from 'bun:test'
|
|
3
3
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
4
5
|
import type { ChartSeries } from '../series'
|
|
5
6
|
import { CartesianChart, resolveAxisDomain } from './CartesianChart'
|
|
6
7
|
|
|
@@ -44,9 +45,27 @@ describe('resolveAxisDomain', () => {
|
|
|
44
45
|
expect(min).toBeLessThanOrEqual(-40)
|
|
45
46
|
})
|
|
46
47
|
|
|
47
|
-
test('autoMaxFloor raises a low upper bound', () => {
|
|
48
|
+
test('autoMaxFloor raises a low upper bound, then pads it (mirrors autoMinCeil)', () => {
|
|
49
|
+
// The floor clamps the RAW upper bound first (44 -> 500), padding applies after: 500 * 1.1.
|
|
48
50
|
const [, max] = resolveAxisDomain({ autoMaxFloor: 500 }, rows, both)
|
|
49
|
-
expect(max).
|
|
51
|
+
expect(max).toBeCloseTo(550, 5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('autoMaxFloor at exactly the padded case (dataMax 3.2, pad 1.1, floor 6) yields 6.6', () => {
|
|
55
|
+
const single: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
|
|
56
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, single)
|
|
57
|
+
expect(max).toBeCloseTo(6.6, 5)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('autoMaxFloor and autoMinCeil pad symmetrically — both clamp first, pad second', () => {
|
|
61
|
+
// Upper: floor 6 clamps 3.2 -> 6, then pads to 6.6 (multiply, away from zero).
|
|
62
|
+
const positive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => 3.2 }]
|
|
63
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: 6, autoPad: 1.1 }, rows, positive)
|
|
64
|
+
expect(max).toBeCloseTo(6.6, 5)
|
|
65
|
+
// Lower: ceil -6 clamps -3.2 -> -6, then pads to -6.6 (multiply, away from zero) — the mirror.
|
|
66
|
+
const negative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: () => -3.2 }]
|
|
67
|
+
const [min] = resolveAxisDomain({ autoMinCeil: -6, autoPad: 1.1 }, rows, negative)
|
|
68
|
+
expect(min).toBeCloseTo(-6.6, 5)
|
|
50
69
|
})
|
|
51
70
|
|
|
52
71
|
test('the domain follows the VISIBLE series — hiding the tall one shrinks the axis', () => {
|
|
@@ -69,6 +88,33 @@ describe('resolveAxisDomain', () => {
|
|
|
69
88
|
})
|
|
70
89
|
})
|
|
71
90
|
|
|
91
|
+
describe('resolveAxisDomain — autoMaxFloor composed with an all-negative series', () => {
|
|
92
|
+
// getValue -> -d.a - 20 over rows a=[10, 40, 25] yields [-30, -60, -45]: data max is -30, data
|
|
93
|
+
// min is -60 — deliberately NOT a flat series, so this exercises the real clamp-then-pad path
|
|
94
|
+
// rather than the degenerate flat-collapse branch covered separately below.
|
|
95
|
+
const allNegative: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => -d.a - 20 }]
|
|
96
|
+
|
|
97
|
+
test('a floor above the data max clamps to the floor, then pads TOWARD zero', () => {
|
|
98
|
+
const [, max] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
|
|
99
|
+
// clamp: Math.max(-30, -10) = -10; padAutoUpper divides for a negative candidate (toward zero).
|
|
100
|
+
expect(max).toBeCloseTo(-10 / 1.1, 10)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('without a floor, the existing all-negative behavior is unchanged', () => {
|
|
104
|
+
const [, max] = resolveAxisDomain(undefined, rows, allNegative)
|
|
105
|
+
// no floor -> candidate stays the raw data max, -30.
|
|
106
|
+
expect(max).toBeCloseTo(-30 / 1.1, 10)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('autoMaxFloor / autoMinCeil clamp-then-pad symmetrically through resolveAxisDomain', () => {
|
|
110
|
+
// Mirror series: same magnitudes, positive instead of negative (data min 30, not data max -30).
|
|
111
|
+
const allPositive: ChartSeries<Row>[] = [{ ...seriesFor('a'), getValue: (d) => d.a + 20 }]
|
|
112
|
+
const [, maxFromFloor] = resolveAxisDomain({ autoMaxFloor: -10 }, rows, allNegative)
|
|
113
|
+
const [minFromCeil] = resolveAxisDomain({ autoMinCeil: 10 }, rows, allPositive)
|
|
114
|
+
expect(maxFromFloor).toBeCloseTo(-minFromCeil, 10)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
72
118
|
describe('resolveAxisDomain — overlay bounds', () => {
|
|
73
119
|
test('a zone past the data stretches the axis instead of clipping', () => {
|
|
74
120
|
const [, max] = resolveAxisDomain(undefined, rows, both, [0, 200])
|
|
@@ -127,6 +173,69 @@ describe('AxisConfig.nice — threaded to BOTH the probe and the real scale', ()
|
|
|
127
173
|
})
|
|
128
174
|
})
|
|
129
175
|
|
|
176
|
+
describe('cursorResolution — threads through to sibling resolution (CartesianChart)', () => {
|
|
177
|
+
// Daily calendar Aug 01–14, plus a sibling folded into 2 weekly buckets keyed by each week's
|
|
178
|
+
// leading day (Aug 01, Aug 08) — the exact shape of the playground's "Weekly digest" pairing.
|
|
179
|
+
const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
|
|
180
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
181
|
+
a: i,
|
|
182
|
+
b: i,
|
|
183
|
+
}))
|
|
184
|
+
const foldedRows: Row[] = [
|
|
185
|
+
{ date: '2026-08-01', a: 0, b: 0 },
|
|
186
|
+
{ date: '2026-08-08', a: 1, b: 1 },
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
190
|
+
render(
|
|
191
|
+
<ChartCursorScope>
|
|
192
|
+
<CartesianChart<Row>
|
|
193
|
+
data={dailyRows}
|
|
194
|
+
chartId="daily"
|
|
195
|
+
getX={(d) => d.date}
|
|
196
|
+
series={[seriesFor('a')]}
|
|
197
|
+
ariaLabel="Daily"
|
|
198
|
+
>
|
|
199
|
+
{() => null}
|
|
200
|
+
</CartesianChart>
|
|
201
|
+
<CartesianChart<Row>
|
|
202
|
+
data={foldedRows}
|
|
203
|
+
chartId="folded"
|
|
204
|
+
getX={(d) => d.date}
|
|
205
|
+
series={[seriesFor('a')]}
|
|
206
|
+
ariaLabel="Folded"
|
|
207
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
208
|
+
>
|
|
209
|
+
{() => null}
|
|
210
|
+
</CartesianChart>
|
|
211
|
+
</ChartCursorScope>,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (its midpoint is
|
|
216
|
+
// Aug 04) — via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
217
|
+
function driveDailyToAug05() {
|
|
218
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
219
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
223
|
+
renderPair('leading')
|
|
224
|
+
driveDailyToAug05()
|
|
225
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
226
|
+
// Aug 05 is inside the Aug01-07 bucket — 'leading' must resolve to its leading key, Aug 01.
|
|
227
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket — proves cursorResolution actually threads through, not just the resolver it wraps", () => {
|
|
231
|
+
renderPair()
|
|
232
|
+
driveDailyToAug05()
|
|
233
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
234
|
+
// Aug 05 is closer to Aug 08 (distance 3) than Aug 01 (distance 4) — 'nearest' picks Aug 08.
|
|
235
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
130
239
|
describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', () => {
|
|
131
240
|
test('toggling a series off via its legend entry updates the ctx the row reads', async () => {
|
|
132
241
|
render(
|
|
@@ -158,3 +267,175 @@ describe('tooltip extraRows — ctx.visible/ctx.hidden track legend toggling', (
|
|
|
158
267
|
expect((await screen.findByTestId('extra-ctx')).textContent).toContain('visible:a|hidden:b')
|
|
159
268
|
})
|
|
160
269
|
})
|
|
270
|
+
|
|
271
|
+
describe('tooltip.onFollow — the follower renders when a SIBLING owns the cursor', () => {
|
|
272
|
+
function renderPair(onFollow?: boolean) {
|
|
273
|
+
render(
|
|
274
|
+
<ChartCursorScope>
|
|
275
|
+
<CartesianChart<Row>
|
|
276
|
+
data={rows}
|
|
277
|
+
chartId="of-source"
|
|
278
|
+
getX={(d) => d.date}
|
|
279
|
+
series={[seriesFor('a')]}
|
|
280
|
+
ariaLabel="Source"
|
|
281
|
+
legend={false}
|
|
282
|
+
>
|
|
283
|
+
{() => null}
|
|
284
|
+
</CartesianChart>
|
|
285
|
+
<CartesianChart<Row>
|
|
286
|
+
data={rows}
|
|
287
|
+
chartId="of-follower"
|
|
288
|
+
getX={(d) => d.date}
|
|
289
|
+
series={[seriesFor('b')]}
|
|
290
|
+
ariaLabel="Follower"
|
|
291
|
+
legend={false}
|
|
292
|
+
tooltip={{ follow: false, ...(onFollow !== undefined && { onFollow }) }}
|
|
293
|
+
>
|
|
294
|
+
{() => null}
|
|
295
|
+
</CartesianChart>
|
|
296
|
+
</ChartCursorScope>,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
test('onFollow: true renders the follower tooltip rows while the source owns the cursor', async () => {
|
|
301
|
+
renderPair(true)
|
|
302
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
303
|
+
|
|
304
|
+
expect(await screen.findByText('b')).toBeTruthy()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('without onFollow (default false), the follower renders no tooltip at all', async () => {
|
|
308
|
+
renderPair()
|
|
309
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
310
|
+
|
|
311
|
+
// The SOURCE still renders its own tooltip ('a') — only the follower ('b') stays silent.
|
|
312
|
+
expect(await screen.findByText('a')).toBeTruthy()
|
|
313
|
+
expect(screen.queryByText('b')).toBeNull()
|
|
314
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
// `follow` is deliberately left at its default (true, pointer-tracking) here — unlike every
|
|
318
|
+
// other case in this file, which hardcodes `follow: false` on the follower. That hardcoding
|
|
319
|
+
// means `useAnchoredPosition`'s FIRST clause (`tooltipCfg?.follow === false`) always already
|
|
320
|
+
// satisfies the anchored branch, so those tests can't tell `|| isFollowerRender` apart from a
|
|
321
|
+
// no-op. With `follow` left at its pointer-tracking default, anchoring can only come from
|
|
322
|
+
// `isFollowerRender` — and a follower's own `cursor.anchor` (set only by pointer/keyboard
|
|
323
|
+
// events ON THIS CHART, never by a sibling's) stays null for the whole test, since only the
|
|
324
|
+
// SOURCE is ever interacted with. So: `isFollowerRender` present → anchored position resolves
|
|
325
|
+
// from the crosshair and the tooltip mounts; `isFollowerRender` absent → the pointer-tracking
|
|
326
|
+
// branch falls through to the follower's own (never-set) `cursor.anchor`, which is null, and
|
|
327
|
+
// `ChartTooltipFloat` renders nothing at all for a null anchor. Presence/absence of the
|
|
328
|
+
// follower tooltip is what's observable here — happy-dom zeroes every `getBoundingClientRect`,
|
|
329
|
+
// so the anchor's actual pixel coordinates can't discriminate the two branches.
|
|
330
|
+
test('onFollow: true with follow left at its pointer-tracking default still anchors to the crosshair', async () => {
|
|
331
|
+
render(
|
|
332
|
+
<ChartCursorScope>
|
|
333
|
+
<CartesianChart<Row>
|
|
334
|
+
data={rows}
|
|
335
|
+
chartId="of-anchor-source"
|
|
336
|
+
getX={(d) => d.date}
|
|
337
|
+
series={[seriesFor('a')]}
|
|
338
|
+
ariaLabel="Source"
|
|
339
|
+
legend={false}
|
|
340
|
+
>
|
|
341
|
+
{() => null}
|
|
342
|
+
</CartesianChart>
|
|
343
|
+
<CartesianChart<Row>
|
|
344
|
+
data={rows}
|
|
345
|
+
chartId="of-anchor-follower"
|
|
346
|
+
getX={(d) => d.date}
|
|
347
|
+
series={[seriesFor('b')]}
|
|
348
|
+
ariaLabel="Follower"
|
|
349
|
+
legend={false}
|
|
350
|
+
tooltip={{ onFollow: true }}
|
|
351
|
+
>
|
|
352
|
+
{() => null}
|
|
353
|
+
</CartesianChart>
|
|
354
|
+
</ChartCursorScope>,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
358
|
+
|
|
359
|
+
expect(await screen.findByText('b')).toBeTruthy()
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('tooltip.onFollow — aria-live stays SOURCE-only', () => {
|
|
364
|
+
test('the source tooltip is aria-live; the follower tooltip is not', async () => {
|
|
365
|
+
render(
|
|
366
|
+
<ChartCursorScope>
|
|
367
|
+
<CartesianChart<Row>
|
|
368
|
+
data={rows}
|
|
369
|
+
chartId="al-source"
|
|
370
|
+
getX={(d) => d.date}
|
|
371
|
+
series={[seriesFor('a')]}
|
|
372
|
+
ariaLabel="Source"
|
|
373
|
+
legend={false}
|
|
374
|
+
>
|
|
375
|
+
{() => null}
|
|
376
|
+
</CartesianChart>
|
|
377
|
+
<CartesianChart<Row>
|
|
378
|
+
data={rows}
|
|
379
|
+
chartId="al-follower"
|
|
380
|
+
getX={(d) => d.date}
|
|
381
|
+
series={[seriesFor('b')]}
|
|
382
|
+
ariaLabel="Follower"
|
|
383
|
+
legend={false}
|
|
384
|
+
tooltip={{ follow: false, onFollow: true }}
|
|
385
|
+
>
|
|
386
|
+
{() => null}
|
|
387
|
+
</CartesianChart>
|
|
388
|
+
</ChartCursorScope>,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
392
|
+
await screen.findByText('b')
|
|
393
|
+
|
|
394
|
+
const tooltips = screen.getAllByRole('tooltip')
|
|
395
|
+
expect(tooltips).toHaveLength(2)
|
|
396
|
+
const sourceTooltip = tooltips.find((t) => within(t).queryByText('a') !== null)
|
|
397
|
+
const followerTooltip = tooltips.find((t) => within(t).queryByText('b') !== null)
|
|
398
|
+
expect(sourceTooltip?.getAttribute('aria-live')).toBe('polite')
|
|
399
|
+
expect(followerTooltip?.hasAttribute('aria-live')).toBe(false)
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe('tooltip.onFollow — a follower whose domain never resolves the broadcast key renders nothing', () => {
|
|
404
|
+
test('an unrelated calendar stays silent, not an empty tooltip shell', async () => {
|
|
405
|
+
const foreignRows: Row[] = [{ date: '2027-01-15', a: 99, b: 99 }]
|
|
406
|
+
|
|
407
|
+
render(
|
|
408
|
+
<ChartCursorScope>
|
|
409
|
+
<CartesianChart<Row>
|
|
410
|
+
data={rows}
|
|
411
|
+
chartId="unres-source"
|
|
412
|
+
getX={(d) => d.date}
|
|
413
|
+
series={[seriesFor('a')]}
|
|
414
|
+
ariaLabel="Source"
|
|
415
|
+
legend={false}
|
|
416
|
+
>
|
|
417
|
+
{() => null}
|
|
418
|
+
</CartesianChart>
|
|
419
|
+
<CartesianChart<Row>
|
|
420
|
+
data={foreignRows}
|
|
421
|
+
chartId="unres-follower"
|
|
422
|
+
getX={(d) => d.date}
|
|
423
|
+
series={[seriesFor('b')]}
|
|
424
|
+
ariaLabel="Follower"
|
|
425
|
+
legend={false}
|
|
426
|
+
tooltip={{ follow: false, onFollow: true }}
|
|
427
|
+
>
|
|
428
|
+
{() => null}
|
|
429
|
+
</CartesianChart>
|
|
430
|
+
</ChartCursorScope>,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
|
|
434
|
+
|
|
435
|
+
// The SOURCE still renders its own tooltip — only the follower with no resolvable point stays
|
|
436
|
+
// silent, rather than mounting an empty tooltip shell.
|
|
437
|
+
expect(await screen.findByText('a')).toBeTruthy()
|
|
438
|
+
expect(screen.queryByText('b')).toBeNull()
|
|
439
|
+
expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
@@ -5,11 +5,12 @@ import { useMemo, useRef, useState } from 'react'
|
|
|
5
5
|
import type { ReactNode } from 'react'
|
|
6
6
|
import { VX } from '../../tokens'
|
|
7
7
|
import type { ChartMargin } from '../../tokens'
|
|
8
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
8
9
|
import { useChartCursor } from '../hooks/useChartCursor'
|
|
9
10
|
import { autoMargin, probeAxisLabels } from '../layout/auto-margin'
|
|
10
11
|
import { deriveTooltipRows } from '../series'
|
|
11
12
|
import type { ChartLegendConfig, ChartSeries } from '../series'
|
|
12
|
-
import { padAutoLower } from '../utils/domain'
|
|
13
|
+
import { padAutoLower, padAutoUpper } from '../utils/domain'
|
|
13
14
|
import { fmtAxisDate } from '../utils/format'
|
|
14
15
|
import { smartTicks, smartTicksEvery } from '../utils/ticks'
|
|
15
16
|
import { AxisBottomDate, AxisLeftNumeric, AxisRightNumeric } from './Axes'
|
|
@@ -41,7 +42,11 @@ export type AxisConfig<T> = {
|
|
|
41
42
|
| [number, number]
|
|
42
43
|
| 'auto'
|
|
43
44
|
| ((data: readonly T[], visible: readonly ChartSeries<T>[]) => [number, number])
|
|
44
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* When 'auto': the raw upper bound is at least this, and padding is applied AFTER — mirroring
|
|
47
|
+
* `autoMinCeil`, which clamps first and pads second. A floor that padded before the clamp used
|
|
48
|
+
* to land the axis top exactly on the floor value with zero headroom.
|
|
49
|
+
*/
|
|
45
50
|
autoMaxFloor?: number
|
|
46
51
|
/** When 'auto': the lower bound is at most this. Default 0 (forced zero baseline). `Infinity`
|
|
47
52
|
* pads down from the raw data minimum instead. */
|
|
@@ -82,12 +87,37 @@ export type CartesianTooltipConfig<T> = {
|
|
|
82
87
|
follow?: boolean
|
|
83
88
|
/** Right-aligned badge in the tooltip header (e.g. a status label). */
|
|
84
89
|
label?: (d: T) => { text: string; color: string } | null
|
|
90
|
+
/**
|
|
91
|
+
* Overrides the tooltip header's date text. Default: `TooltipHeader`'s own `fmtTooltipDate`
|
|
92
|
+
* behavior, unchanged. `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
|
|
93
|
+
* LOCAL `Date` from it — a UTC ISO key then names the wrong day next to `formatX`, the tooltip
|
|
94
|
+
* badge, and every sibling chart, which all resolve locally. Receives the raw `getX` key
|
|
95
|
+
* alongside the hovered datum, so a caller can format from either.
|
|
96
|
+
*/
|
|
97
|
+
formatHeader?: (key: string, d: T) => string
|
|
85
98
|
/** Rows rendered BEFORE the derived per-series rows (a total, a context line). The second
|
|
86
99
|
* argument carries the same `visible`/`hidden` the plot draws from, so a hand-authored row
|
|
87
100
|
* stays in sync with legend toggling instead of naming a series the plot no longer draws. */
|
|
88
101
|
prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
89
102
|
/** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
|
|
90
103
|
extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
|
|
104
|
+
/**
|
|
105
|
+
* Render this chart's tooltip when it is a cursor FOLLOWER (the crosshair came from a sibling
|
|
106
|
+
* chart sharing the cursor), not only when this chart is the SOURCE. Default false — today's
|
|
107
|
+
* behaviour, nothing moves. A follower has no pointer under it, so its tooltip is always ANCHORED
|
|
108
|
+
* to the crosshair — the same positioning `follow: false` gives the source — regardless of what
|
|
109
|
+
* `follow` is set to on this chart: there is no other valid position for a chart nobody is
|
|
110
|
+
* hovering. `follow` keeps governing the SOURCE's own tooltip exactly as before; the two options
|
|
111
|
+
* only interact for a follower, where `onFollow` always wins on positioning. Only the SOURCE's
|
|
112
|
+
* tooltip is `aria-live` — followers announce nothing, so a shared-cursor page doesn't fire N
|
|
113
|
+
* live-region updates per pointer move.
|
|
114
|
+
*
|
|
115
|
+
* Cost, since it scales with the page rather than the chart: anchoring reads one
|
|
116
|
+
* `getBoundingClientRect` per hovered frame (the same cost `follow: false` documents), so N
|
|
117
|
+
* opted-in followers pay N of them per frame. That is the reason this is opt-in per chart rather
|
|
118
|
+
* than a page-level switch — turn it on for the charts a reader actually correlates.
|
|
119
|
+
*/
|
|
120
|
+
onFollow?: boolean
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
/** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
|
|
@@ -149,6 +179,14 @@ export type CartesianChartProps<T> = {
|
|
|
149
179
|
series: ChartSeries<T>,
|
|
150
180
|
visible: readonly ChartSeries<T>[],
|
|
151
181
|
) => number | null
|
|
182
|
+
/**
|
|
183
|
+
* How a sibling chart's broadcast cursor key resolves against THIS chart's points. Default
|
|
184
|
+
* `'nearest'`, correct when `getX` returns an instant. Pass `'leading'` when it returns a
|
|
185
|
+
* bucket's leading edge (a weekly series keyed by its Monday): under `'nearest'` a hover landing
|
|
186
|
+
* in the back half of a bucket resolves to the FOLLOWING bucket, so the shared crosshair sits
|
|
187
|
+
* one column right of the data the reader is pointing at.
|
|
188
|
+
*/
|
|
189
|
+
cursorResolution?: CursorResolution
|
|
152
190
|
ariaLabel?: string
|
|
153
191
|
isPending?: boolean
|
|
154
192
|
/** Draw ONLY the marks. Everything around them is the primitive's job. */
|
|
@@ -196,11 +234,10 @@ export function resolveAxisDomain<T>(
|
|
|
196
234
|
|
|
197
235
|
const pad = cfg?.autoPad ?? DEFAULT_AUTO_PAD
|
|
198
236
|
const ceil = cfg?.autoMinCeil ?? 0
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
const
|
|
203
|
-
const upper = Math.max(paddedMax, cfg?.autoMaxFloor ?? -Infinity)
|
|
237
|
+
// Symmetric with the lower bound: clamp the RAW value against the floor/ceiling first, then pad
|
|
238
|
+
// once, away from zero. Padding before the clamp (the old law) lands a floored axis top exactly
|
|
239
|
+
// on the floor with zero headroom — a target line pinned there sits glued to the plot edge.
|
|
240
|
+
const upper = padAutoUpper(Math.max(max, cfg?.autoMaxFloor ?? -Infinity), pad)
|
|
204
241
|
const lower = padAutoLower(Math.min(min, ceil), pad)
|
|
205
242
|
// A flat series (every value identical, the all-zero case included) collapses to `[n, n]`,
|
|
206
243
|
// which is a scale with no extent: every point maps to the same pixel and the line renders as a
|
|
@@ -241,6 +278,7 @@ export function CartesianChart<T>({
|
|
|
241
278
|
tooltip,
|
|
242
279
|
margin: marginOverride,
|
|
243
280
|
cursorValue,
|
|
281
|
+
cursorResolution,
|
|
244
282
|
ariaLabel,
|
|
245
283
|
isPending,
|
|
246
284
|
children,
|
|
@@ -274,6 +312,7 @@ export function CartesianChart<T>({
|
|
|
274
312
|
{...(tooltip !== undefined && { tooltip })}
|
|
275
313
|
{...(marginOverride !== undefined && { marginOverride })}
|
|
276
314
|
{...(cursorValue !== undefined && { cursorValue })}
|
|
315
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
277
316
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
278
317
|
plot={plot}
|
|
279
318
|
highlighted={highlighted}
|
|
@@ -311,6 +350,7 @@ function CartesianPlot<T>({
|
|
|
311
350
|
tooltip,
|
|
312
351
|
marginOverride,
|
|
313
352
|
cursorValue,
|
|
353
|
+
cursorResolution,
|
|
314
354
|
ariaLabel,
|
|
315
355
|
plot,
|
|
316
356
|
highlighted,
|
|
@@ -430,6 +470,7 @@ function CartesianPlot<T>({
|
|
|
430
470
|
getKey: getX,
|
|
431
471
|
xScale,
|
|
432
472
|
marginLeft: margin.left,
|
|
473
|
+
...(cursorResolution !== undefined && { resolution: cursorResolution }),
|
|
433
474
|
})
|
|
434
475
|
|
|
435
476
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
@@ -438,6 +479,7 @@ function CartesianPlot<T>({
|
|
|
438
479
|
const point = cursor.point
|
|
439
480
|
const cursorX = point === null ? null : (xScale(getX(point)) ?? null)
|
|
440
481
|
const badge = tooltipCfg?.label === undefined || point === null ? null : tooltipCfg.label(point)
|
|
482
|
+
const formatHeader = tooltipCfg?.formatHeader
|
|
441
483
|
|
|
442
484
|
// Each series falls back to ITS OWN axis's tick format, so a right-axis value is never
|
|
443
485
|
// formatted with the left axis's rules (an explicit per-series `formatValue` still wins).
|
|
@@ -454,10 +496,17 @@ function CartesianPlot<T>({
|
|
|
454
496
|
const rows =
|
|
455
497
|
tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : []
|
|
456
498
|
|
|
499
|
+
// This chart renders its tooltip either as the cursor SOURCE (always) or, opted in via
|
|
500
|
+
// `onFollow`, as a FOLLOWER (a sibling owns the cursor). A follower has no pointer under it, so
|
|
501
|
+
// it is always positioned via the anchored (crosshair) path below, regardless of `follow`.
|
|
502
|
+
const isFollowerRender = !cursor.isSource && tooltipCfg?.onFollow === true
|
|
503
|
+
const showTooltip = tooltipEnabled && point !== null && (cursor.isSource || isFollowerRender)
|
|
504
|
+
|
|
457
505
|
// Anchored mode resolves the crosshair's plot-local x into viewport space; following mode uses
|
|
458
|
-
// the pointer position `useChartCursor` already recorded.
|
|
459
|
-
|
|
460
|
-
|
|
506
|
+
// the pointer position `useChartCursor` already recorded. A follower render always anchors, since
|
|
507
|
+
// `follow`'s pointer-tracking path has no pointer to read on this chart.
|
|
508
|
+
const useAnchoredPosition = tooltipCfg?.follow === false || isFollowerRender
|
|
509
|
+
const anchorX = useAnchoredPosition && point !== null && cursorX !== null ? cursorX : null
|
|
461
510
|
const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
|
|
462
511
|
const tooltipAnchor =
|
|
463
512
|
svgRect === undefined || anchorX === null
|
|
@@ -570,10 +619,13 @@ function CartesianPlot<T>({
|
|
|
570
619
|
</Group>
|
|
571
620
|
</svg>
|
|
572
621
|
|
|
573
|
-
{
|
|
574
|
-
<ChartTooltipFloat anchor={tooltipAnchor}>
|
|
622
|
+
{showTooltip && (
|
|
623
|
+
<ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
|
|
575
624
|
<TooltipHeader
|
|
576
625
|
date={getX(point)}
|
|
626
|
+
{...(formatHeader !== undefined && {
|
|
627
|
+
format: (key: string) => formatHeader(key, point),
|
|
628
|
+
})}
|
|
577
629
|
{...(badge !== null && { label: badge.text, labelColor: badge.color })}
|
|
578
630
|
/>
|
|
579
631
|
<TooltipBody>
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* to the DOM harness would only be worth it if a future assertion here needed a real measured size
|
|
14
14
|
* (a live `ResizeObserver` reading) rather than this SSR fallback rect.
|
|
15
15
|
*/
|
|
16
|
+
import { render } from '@testing-library/react'
|
|
16
17
|
import { describe, expect, test } from 'bun:test'
|
|
17
18
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
18
19
|
import { ChartFrame } from './ChartFrame'
|
|
20
|
+
import { HoverOverlay } from './HoverOverlay'
|
|
19
21
|
import type { SeriesStyle } from '../series'
|
|
20
22
|
|
|
21
23
|
const series: SeriesStyle[] = [{ key: 'a', label: 'Series A', color: '#000', mark: 'line' }]
|
|
@@ -66,6 +68,46 @@ describe('a non-pending ChartFrame is unaffected — the legend and body both re
|
|
|
66
68
|
})
|
|
67
69
|
})
|
|
68
70
|
|
|
71
|
+
describe('ariaLabel: the label is announced WITHOUT swallowing the interactive slider', () => {
|
|
72
|
+
// The regression this guards: `role="img"` marks every descendant presentational per the ARIA
|
|
73
|
+
// spec, so a screen reader would announce the label and then never expose `HoverOverlay`'s
|
|
74
|
+
// `role="slider"` at all — the keyboard-scrubbable affordance becomes unreachable with no error
|
|
75
|
+
// anywhere. `role="group"` announces the same label while keeping descendants in the tree.
|
|
76
|
+
test('the outer container is role="group" with the label, never role="img"', () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
|
|
79
|
+
{() => <svg />}
|
|
80
|
+
</ChartFrame>,
|
|
81
|
+
)
|
|
82
|
+
const outer = container.firstElementChild as HTMLElement
|
|
83
|
+
expect(outer.getAttribute('role')).toBe('group')
|
|
84
|
+
expect(outer.getAttribute('aria-label')).toBe('Revenue over time')
|
|
85
|
+
expect(container.querySelector('[role="img"]')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('the slider stays a reachable descendant of the labeled container', () => {
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<ChartFrame series={series} ariaLabel="Revenue over time" legend={false}>
|
|
91
|
+
{() => (
|
|
92
|
+
<svg>
|
|
93
|
+
<HoverOverlay
|
|
94
|
+
width={100}
|
|
95
|
+
height={100}
|
|
96
|
+
onMove={() => {}}
|
|
97
|
+
onLeave={() => {}}
|
|
98
|
+
onKeyDown={() => {}}
|
|
99
|
+
/>
|
|
100
|
+
</svg>
|
|
101
|
+
)}
|
|
102
|
+
</ChartFrame>,
|
|
103
|
+
)
|
|
104
|
+
const outer = container.firstElementChild as HTMLElement
|
|
105
|
+
expect(outer.getAttribute('role')).toBe('group')
|
|
106
|
+
const slider = outer.querySelector('[role="slider"]')
|
|
107
|
+
expect(slider).not.toBeNull()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
69
111
|
const twoSeries: SeriesStyle[] = [
|
|
70
112
|
{ key: 'a', label: 'Series A', color: '#000', mark: 'line' },
|
|
71
113
|
{ key: 'b', label: 'Series B', color: '#111', mark: 'bar' },
|
|
@@ -53,9 +53,16 @@ export type ChartFrameProps = {
|
|
|
53
53
|
*/
|
|
54
54
|
isPending?: boolean
|
|
55
55
|
/**
|
|
56
|
-
* Accessible text alternative for the chart, applied as `aria-label` (+ `role="
|
|
56
|
+
* Accessible text alternative for the chart, applied as `aria-label` (+ `role="group"`) on the
|
|
57
57
|
* outer container so screen readers announce something other than an unlabeled graphic. Every
|
|
58
58
|
* kind composing `ChartFrame` should accept and forward this from its own props.
|
|
59
|
+
*
|
|
60
|
+
* MUST stay `role="group"`, never `role="img"`. Per the ARIA spec, every descendant of an
|
|
61
|
+
* `role="img"` element is presentational, which erases the `HoverOverlay`'s `role="slider"` from
|
|
62
|
+
* the accessibility tree entirely — a screen reader announces the label and then the
|
|
63
|
+
* keyboard-scrubbable slider underneath it is simply unreachable, silently, with no error
|
|
64
|
+
* anywhere. `role="group"` announces the same label while keeping descendants exposed. Do not
|
|
65
|
+
* "simplify" this back to `img` — it looks like a no-op refactor and it is not.
|
|
59
66
|
*/
|
|
60
67
|
ariaLabel?: string
|
|
61
68
|
/** Draw the SVG marks given the plot rect that already excludes the legend band, plus the set
|
|
@@ -189,7 +196,7 @@ export function ChartFrame({
|
|
|
189
196
|
<div
|
|
190
197
|
ref={containerRef}
|
|
191
198
|
style={outerStyle(fill, vertical)}
|
|
192
|
-
{...(ariaLabel !== undefined && { role: '
|
|
199
|
+
{...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
|
|
193
200
|
{...(isPending && { 'aria-busy': 'true' })}
|
|
194
201
|
>
|
|
195
202
|
{legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TooltipHeader` — the `format` seam that lets a caller override `fmtTooltipDate`. Added because
|
|
3
|
+
* `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
|
|
4
|
+
* domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
|
|
5
|
+
*/
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import { describe, expect, test } from 'bun:test'
|
|
8
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
9
|
+
import { ChartTooltipFloat, TooltipHeader } from './ChartTooltip'
|
|
10
|
+
|
|
11
|
+
describe('TooltipHeader — format prop', () => {
|
|
12
|
+
test('defaults to fmtTooltipDate when omitted', () => {
|
|
13
|
+
const html = renderToStaticMarkup(<TooltipHeader date="2026-04-21" />)
|
|
14
|
+
expect(html).toContain('Tue Apr 21 2026')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('a custom format overrides fmtTooltipDate entirely', () => {
|
|
18
|
+
const html = renderToStaticMarkup(
|
|
19
|
+
<TooltipHeader date="2026-04-21" format={(d) => `custom:${d}`} />,
|
|
20
|
+
)
|
|
21
|
+
expect(html).toContain('custom:2026-04-21')
|
|
22
|
+
expect(html).not.toContain('Tue Apr 21 2026')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('ChartTooltipFloat — ariaLive', () => {
|
|
27
|
+
test('defaults to an aria-live="polite" region', () => {
|
|
28
|
+
render(
|
|
29
|
+
<ChartTooltipFloat anchor={{ x: 10, y: 10 }}>
|
|
30
|
+
<span>content</span>
|
|
31
|
+
</ChartTooltipFloat>,
|
|
32
|
+
)
|
|
33
|
+
expect(screen.getByRole('tooltip').getAttribute('aria-live')).toBe('polite')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('ariaLive: false drops the live-region attribute entirely — a follower is visual only', () => {
|
|
37
|
+
render(
|
|
38
|
+
<ChartTooltipFloat anchor={{ x: 10, y: 10 }} ariaLive={false}>
|
|
39
|
+
<span>content</span>
|
|
40
|
+
</ChartTooltipFloat>,
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByRole('tooltip').hasAttribute('aria-live')).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -26,10 +26,16 @@ export function TooltipHeader({
|
|
|
26
26
|
date,
|
|
27
27
|
label,
|
|
28
28
|
labelColor,
|
|
29
|
+
format = fmtTooltipDate,
|
|
29
30
|
}: {
|
|
30
31
|
date: string
|
|
31
32
|
label?: string
|
|
32
33
|
labelColor?: string
|
|
34
|
+
/** Overrides `fmtTooltipDate` for the header text. Default: today's `fmtTooltipDate` behavior,
|
|
35
|
+
* unchanged. The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and
|
|
36
|
+
* builds a LOCAL `Date`, so a UTC ISO domain key names the wrong day next to `formatX`/the
|
|
37
|
+
* tooltip badge, which both resolve locally — a caller with a UTC key needs to supply its own. */
|
|
38
|
+
format?: (date: string) => string
|
|
33
39
|
}) {
|
|
34
40
|
return (
|
|
35
41
|
<div
|
|
@@ -50,7 +56,7 @@ export function TooltipHeader({
|
|
|
50
56
|
color: VX.ink,
|
|
51
57
|
}}
|
|
52
58
|
>
|
|
53
|
-
{
|
|
59
|
+
{format(date)}
|
|
54
60
|
</span>
|
|
55
61
|
{label !== undefined && (
|
|
56
62
|
<span
|
|
@@ -149,9 +155,15 @@ const VIEWPORT_MARGIN = 8
|
|
|
149
155
|
*/
|
|
150
156
|
export function ChartTooltipFloat({
|
|
151
157
|
anchor,
|
|
158
|
+
ariaLive = true,
|
|
152
159
|
children,
|
|
153
160
|
}: {
|
|
154
161
|
anchor: { x: number; y: number } | null
|
|
162
|
+
/** Whether this tooltip announces itself to screen readers on every position change. Default
|
|
163
|
+
* true. Pass false for a cursor FOLLOWER's tooltip — N followers on a shared-cursor page all
|
|
164
|
+
* announcing on every pointer move would make the page unusable with a screen reader; only the
|
|
165
|
+
* cursor SOURCE should be `aria-live`. */
|
|
166
|
+
ariaLive?: boolean
|
|
155
167
|
children: ReactNode
|
|
156
168
|
}) {
|
|
157
169
|
const ref = useRef<HTMLDivElement>(null)
|
|
@@ -190,7 +202,7 @@ export function ChartTooltipFloat({
|
|
|
190
202
|
<div
|
|
191
203
|
ref={ref}
|
|
192
204
|
role="tooltip"
|
|
193
|
-
aria-live
|
|
205
|
+
{...(ariaLive && { 'aria-live': 'polite' as const })}
|
|
194
206
|
style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
|
|
195
207
|
>
|
|
196
208
|
{children}
|