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
|
@@ -59,3 +59,65 @@ describe('resolveCursorPoint', () => {
|
|
|
59
59
|
expect(resolveCursorPoint(i, '2026-08-02')).toBeNull()
|
|
60
60
|
})
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
describe("resolveCursorPoint — 'leading' resolution (bucket-keyed domains)", () => {
|
|
64
|
+
const weekly = (days: string[]) => buildDomainIndex(daily(days), (p) => p.date, 'leading')
|
|
65
|
+
|
|
66
|
+
test('resolves an exact key, same as nearest', () => {
|
|
67
|
+
const i = weekly(['01', '08', '15'])
|
|
68
|
+
expect(resolveCursorPoint(i, '2026-08-08')?.date).toBe('2026-08-08')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("a target anywhere inside a bucket resolves to that bucket's LEADING key, not the nearest one", () => {
|
|
72
|
+
// The bug this replaces: a weekly chart keyed by the Monday of each week (step 7 days),
|
|
73
|
+
// hovered from a daily sibling landing in the back half of the bucket, used to resolve to the
|
|
74
|
+
// FOLLOWING week under nearest-match. Every back-half day must resolve to the bucket it is
|
|
75
|
+
// actually inside.
|
|
76
|
+
const i = weekly(['01', '08', '15'])
|
|
77
|
+
expect(resolveCursorPoint(i, '2026-08-02')?.date).toBe('2026-08-01')
|
|
78
|
+
expect(resolveCursorPoint(i, '2026-08-05')?.date).toBe('2026-08-01')
|
|
79
|
+
expect(resolveCursorPoint(i, '2026-08-07')?.date).toBe('2026-08-01')
|
|
80
|
+
expect(resolveCursorPoint(i, '2026-08-09')?.date).toBe('2026-08-08')
|
|
81
|
+
expect(resolveCursorPoint(i, '2026-08-14')?.date).toBe('2026-08-08')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('a key BEFORE the first bucket resolves to null — no bucket contains it', () => {
|
|
85
|
+
// Deliberately NOT 'nearest''s one-step tolerance. The first key is the leading edge of the
|
|
86
|
+
// first bucket, so anything earlier is outside every bucket; snapping it onto the first one
|
|
87
|
+
// would paint a crosshair on a bucket that provably does not contain the hovered key.
|
|
88
|
+
const i = weekly(['02', '09', '16'])
|
|
89
|
+
expect(resolveCursorPoint(i, '2026-08-01')).toBeNull()
|
|
90
|
+
expect(resolveCursorPoint(i, '2026-08-02')?.date).toBe('2026-08-02')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('the final bucket ends at last + step — that instant is already outside it', () => {
|
|
94
|
+
const i = weekly(['01', '08', '15']) // step = 7, so the last bucket covers [Aug 15, Aug 22)
|
|
95
|
+
expect(resolveCursorPoint(i, '2026-08-21')?.date).toBe('2026-08-15') // last day inside
|
|
96
|
+
expect(resolveCursorPoint(i, '2026-08-22')).toBeNull() // exactly last + step, exclusive
|
|
97
|
+
expect(resolveCursorPoint(i, '2026-08-23')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('returns null well beyond the domain, so unrelated charts never sync', () => {
|
|
101
|
+
const i = weekly(['01', '08', '15'])
|
|
102
|
+
expect(resolveCursorPoint(i, '2026-09-30')).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('categorical domains fall back to exact match only, same as nearest', () => {
|
|
106
|
+
const points = [{ date: 'Direct' }, { date: 'Referral' }]
|
|
107
|
+
const i = buildDomainIndex(points, (p) => p.date, 'leading')
|
|
108
|
+
expect(resolveCursorPoint(i, 'Direct')?.date).toBe('Direct')
|
|
109
|
+
expect(resolveCursorPoint(i, 'Organic')).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('a single-point chart resolves only its own key', () => {
|
|
113
|
+
const i = weekly(['01'])
|
|
114
|
+
expect(resolveCursorPoint(i, '2026-08-01')?.date).toBe('2026-08-01')
|
|
115
|
+
expect(resolveCursorPoint(i, '2026-08-02')).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('an explicit resolution argument overrides the index-built default', () => {
|
|
119
|
+
// Built as 'nearest', resolved as 'leading' — the per-call argument wins.
|
|
120
|
+
const i = index(daily(['01', '08', '15']))
|
|
121
|
+
expect(resolveCursorPoint(i, '2026-08-05', 'leading')?.date).toBe('2026-08-01')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -22,6 +22,27 @@ export function parseKey(key: string): number | null {
|
|
|
22
22
|
return Number.isNaN(t) ? null : t
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* How a foreign key maps onto a chart's own points.
|
|
27
|
+
*
|
|
28
|
+
* `'nearest'` (default) treats every key as a POINT — the foreign key resolves to whichever own
|
|
29
|
+
* point sits closest to it. Correct when a chart's x-domain is a set of instants.
|
|
30
|
+
*
|
|
31
|
+
* `'leading'` treats every key as the LEADING EDGE of a bucket covering `[key, nextKey)` — the
|
|
32
|
+
* foreign key resolves to the LAST own key `<=` it. Use this when a chart's keys are bucket
|
|
33
|
+
* starts (a weekly series keyed by the Monday of each week, a monthly series keyed by the 1st):
|
|
34
|
+
* under `'nearest'`, a target landing in the back half of a bucket resolves to the FOLLOWING
|
|
35
|
+
* bucket instead of the one it's actually inside, and the shared crosshair lands one column off
|
|
36
|
+
* for every back-half hover.
|
|
37
|
+
*
|
|
38
|
+
* The two modes differ at the DOMAIN EDGES too, and deliberately: `'nearest'` tolerates one step
|
|
39
|
+
* past each end (the target is an instant, so the closest point is still a defensible answer),
|
|
40
|
+
* while `'leading'` bounds strictly to `[first, last + step)` — outside that range no bucket
|
|
41
|
+
* CONTAINS the key, and answering with the nearest one would paint a crosshair on a bucket that
|
|
42
|
+
* provably excludes it.
|
|
43
|
+
*/
|
|
44
|
+
export type CursorResolution = 'nearest' | 'leading'
|
|
45
|
+
|
|
25
46
|
export type DomainIndex<T> = {
|
|
26
47
|
byKey: Map<string, T>
|
|
27
48
|
/** Parsed positions, ascending. Empty when the domain isn't parseable. */
|
|
@@ -30,10 +51,16 @@ export type DomainIndex<T> = {
|
|
|
30
51
|
points: T[]
|
|
31
52
|
/** Median spacing — the tolerance within which a foreign key still resolves. */
|
|
32
53
|
step: number
|
|
54
|
+
/** The resolution strategy this index was built for. */
|
|
55
|
+
resolution: CursorResolution
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
/** Build the lookup structure once per `data` identity. */
|
|
36
|
-
export function buildDomainIndex<T>(
|
|
59
|
+
export function buildDomainIndex<T>(
|
|
60
|
+
data: readonly T[],
|
|
61
|
+
getKey: (d: T) => string,
|
|
62
|
+
resolution: CursorResolution = 'nearest',
|
|
63
|
+
): DomainIndex<T> {
|
|
37
64
|
const byKey = new Map<string, T>()
|
|
38
65
|
const parsed: { value: number; point: T }[] = []
|
|
39
66
|
let parseable = true
|
|
@@ -48,7 +75,9 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
|
|
|
48
75
|
|
|
49
76
|
// Under two parseable points there is no step to reason about, so nearest-match would be
|
|
50
77
|
// unbounded — fall back to exact match.
|
|
51
|
-
if (!parseable || parsed.length < 2)
|
|
78
|
+
if (!parseable || parsed.length < 2) {
|
|
79
|
+
return { byKey, values: [], points: [], step: 0, resolution }
|
|
80
|
+
}
|
|
52
81
|
|
|
53
82
|
parsed.sort((a, b) => a.value - b.value)
|
|
54
83
|
const values = parsed.map((p) => p.value)
|
|
@@ -60,15 +89,39 @@ export function buildDomainIndex<T>(data: readonly T[], getKey: (d: T) => string
|
|
|
60
89
|
}
|
|
61
90
|
diffs.sort((a, b) => a - b)
|
|
62
91
|
|
|
63
|
-
return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0 }
|
|
92
|
+
return { byKey, values, points, step: diffs[Math.floor(diffs.length / 2)] ?? 0, resolution }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `'leading'` resolution: the last own position `<= target`. The caller has already rejected
|
|
96
|
+
* anything outside `[first, last + step)`, so a containing bucket is guaranteed to exist. */
|
|
97
|
+
function resolveLeading<T>(index: DomainIndex<T>, target: number): T {
|
|
98
|
+
let lo = 0
|
|
99
|
+
let hi = index.values.length - 1
|
|
100
|
+
let result = 0
|
|
101
|
+
while (lo <= hi) {
|
|
102
|
+
const mid = (lo + hi) >> 1
|
|
103
|
+
if ((index.values[mid] as number) <= target) {
|
|
104
|
+
result = mid
|
|
105
|
+
lo = mid + 1
|
|
106
|
+
} else {
|
|
107
|
+
hi = mid - 1
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return index.points[result] as T
|
|
64
111
|
}
|
|
65
112
|
|
|
66
113
|
/**
|
|
67
|
-
* The own point a broadcast `key` maps to: exact match first, else
|
|
68
|
-
*
|
|
69
|
-
*
|
|
114
|
+
* The own point a broadcast `key` maps to: exact match first, else — per `index.resolution` — the
|
|
115
|
+
* nearest parsed position or the last position at/before the target, both within one domain step.
|
|
116
|
+
* Out of range → null, so charts over unrelated domains never paint a cursor for each other —
|
|
117
|
+
* which is what makes "shared by default" safe. A categorical (unparseable) domain falls back to
|
|
118
|
+
* exact-match-only in BOTH resolutions, since "nearest"/"leading" over a category is meaningless.
|
|
70
119
|
*/
|
|
71
|
-
export function resolveCursorPoint<T>(
|
|
120
|
+
export function resolveCursorPoint<T>(
|
|
121
|
+
index: DomainIndex<T>,
|
|
122
|
+
key: string,
|
|
123
|
+
resolution: CursorResolution = index.resolution,
|
|
124
|
+
): T | null {
|
|
72
125
|
const exact = index.byKey.get(key)
|
|
73
126
|
if (exact !== undefined) return exact
|
|
74
127
|
|
|
@@ -77,6 +130,17 @@ export function resolveCursorPoint<T>(index: DomainIndex<T>, key: string): T | n
|
|
|
77
130
|
|
|
78
131
|
const first = index.values[0] as number
|
|
79
132
|
const last = index.values[index.values.length - 1] as number
|
|
133
|
+
|
|
134
|
+
if (resolution === 'leading') {
|
|
135
|
+
// Containment, NOT tolerance. A bucket keyed `k` covers `[k, nextKey)`, so a target before the
|
|
136
|
+
// first key sits in no bucket at all, and `last + step` is the first instant past the final
|
|
137
|
+
// one. Borrowing `'nearest'`'s symmetric ±step window here would snap a pre-domain target onto
|
|
138
|
+
// the first bucket and a post-domain one onto the last — painting a crosshair on a bucket that
|
|
139
|
+
// provably does not contain the hovered key, which is the exact lie this mode exists to remove.
|
|
140
|
+
if (target < first || target >= last + index.step) return null
|
|
141
|
+
return resolveLeading(index, target)
|
|
142
|
+
}
|
|
143
|
+
|
|
80
144
|
if (target < first - index.step || target > last + index.step) return null
|
|
81
145
|
|
|
82
146
|
let lo = 0
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { localPoint } from '@visx/event'
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
3
|
import type { KeyboardEvent, PointerEvent } from 'react'
|
|
4
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
4
5
|
import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
|
|
5
6
|
import { useCursorState, useCursorStore } from '../cursor/scope'
|
|
6
7
|
|
|
@@ -38,6 +39,7 @@ export function useChartCursor<T>({
|
|
|
38
39
|
getKey,
|
|
39
40
|
xScale,
|
|
40
41
|
marginLeft,
|
|
42
|
+
resolution = 'nearest',
|
|
41
43
|
}: {
|
|
42
44
|
data: readonly T[]
|
|
43
45
|
chartId: string
|
|
@@ -45,6 +47,12 @@ export function useChartCursor<T>({
|
|
|
45
47
|
/** Own x scale — maps a domain key to a plot-local x offset. */
|
|
46
48
|
xScale: (key: string) => number | undefined
|
|
47
49
|
marginLeft: number
|
|
50
|
+
/**
|
|
51
|
+
* How a sibling's broadcast key resolves against this chart's own points. Default `'nearest'`
|
|
52
|
+
* (point domains). Pass `'leading'` when `getKey` returns a bucket's leading edge (a weekly
|
|
53
|
+
* series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
|
|
54
|
+
*/
|
|
55
|
+
resolution?: CursorResolution
|
|
48
56
|
}): ChartCursor<T> {
|
|
49
57
|
const store = useCursorStore()
|
|
50
58
|
const cursor = useCursorState()
|
|
@@ -58,7 +66,10 @@ export function useChartCursor<T>({
|
|
|
58
66
|
const xScaleRef = useRef(xScale)
|
|
59
67
|
xScaleRef.current = xScale
|
|
60
68
|
|
|
61
|
-
const index = useMemo(
|
|
69
|
+
const index = useMemo(
|
|
70
|
+
() => buildDomainIndex(data, getKeyRef.current, resolution),
|
|
71
|
+
[data, resolution],
|
|
72
|
+
)
|
|
62
73
|
|
|
63
74
|
const frameRef = useRef<number | null>(null)
|
|
64
75
|
const pendingRef = useRef<CursorAnchor | null>(null)
|
package/src/charts/index.ts
CHANGED
|
@@ -91,6 +91,7 @@ export { AreaGradient, areaFillUrl } from './primitives/AreaGradient'
|
|
|
91
91
|
|
|
92
92
|
// ── Hooks ────────────────────────────────────────────────────────────────
|
|
93
93
|
export { useChartCursor, type ChartCursor, type CursorAnchor } from './hooks/useChartCursor'
|
|
94
|
+
export type { CursorResolution } from './cursor/resolve'
|
|
94
95
|
export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
|
|
95
96
|
|
|
96
97
|
// ── Utils ────────────────────────────────────────────────────────────────
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
10
10
|
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
12
|
+
import { CartesianChart } from '../primitives/CartesianChart'
|
|
13
|
+
import type { ChartSeries } from '../series'
|
|
11
14
|
import { Bars } from './Bars'
|
|
12
15
|
import type { BarsBar } from './Bars'
|
|
13
16
|
|
|
@@ -89,6 +92,88 @@ describe('Bars — lines[].strokeOpacity dims the plotted stroke', () => {
|
|
|
89
92
|
})
|
|
90
93
|
})
|
|
91
94
|
|
|
95
|
+
describe('Bars — formatX', () => {
|
|
96
|
+
test('a custom formatX renders on the bottom axis instead of the default DD.MM', () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<Bars<Row>
|
|
99
|
+
data={rows}
|
|
100
|
+
chartId="bars-formatx"
|
|
101
|
+
getX={(d) => d.date}
|
|
102
|
+
getValue={(d, key) => d[key as 'shown' | 'quiet' | 'trend']}
|
|
103
|
+
positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
|
|
104
|
+
formatX={(key) => `X:${key}`}
|
|
105
|
+
/>,
|
|
106
|
+
)
|
|
107
|
+
expect(container.innerHTML).toContain('X:2026-08-01')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('Bars — cursorResolution threads through to sibling resolution', () => {
|
|
112
|
+
// Daily calendar Aug 01–14 (a plain CartesianChart driver) alongside a Bars sibling folded into
|
|
113
|
+
// 2 weekly buckets keyed by each week's leading day (Aug 01, Aug 08) — the exact shape of the
|
|
114
|
+
// playground's "Weekly digest" pairing. Proves the prop reaches THIS kind, not just
|
|
115
|
+
// `CartesianChart` (a kind dropping the forward would silently fall back to 'nearest').
|
|
116
|
+
const dailyRows: Row[] = Array.from({ length: 14 }, (_, i) => ({
|
|
117
|
+
date: `2026-08-${String(i + 1).padStart(2, '0')}`,
|
|
118
|
+
shown: i,
|
|
119
|
+
quiet: i,
|
|
120
|
+
trend: i,
|
|
121
|
+
}))
|
|
122
|
+
const foldedRows: Row[] = [
|
|
123
|
+
{ date: '2026-08-01', shown: 0, quiet: 0, trend: 0 },
|
|
124
|
+
{ date: '2026-08-08', shown: 1, quiet: 1, trend: 1 },
|
|
125
|
+
]
|
|
126
|
+
const dailySeries: ChartSeries<Row>[] = [
|
|
127
|
+
{ key: 'shown', label: 'Shown', color: '#111', mark: 'line', getValue: (d) => d.shown },
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
function renderPair(cursorResolution?: 'leading') {
|
|
131
|
+
render(
|
|
132
|
+
<ChartCursorScope>
|
|
133
|
+
<CartesianChart<Row>
|
|
134
|
+
data={dailyRows}
|
|
135
|
+
chartId="bars-daily"
|
|
136
|
+
getX={(d) => d.date}
|
|
137
|
+
series={dailySeries}
|
|
138
|
+
ariaLabel="Daily"
|
|
139
|
+
>
|
|
140
|
+
{() => null}
|
|
141
|
+
</CartesianChart>
|
|
142
|
+
<Bars<Row>
|
|
143
|
+
data={foldedRows}
|
|
144
|
+
chartId="bars-folded"
|
|
145
|
+
getX={(d) => d.date}
|
|
146
|
+
getValue={(d, key) => d[key as 'shown']}
|
|
147
|
+
positiveBars={[{ key: 'shown', label: 'Shown', color: '#111' }]}
|
|
148
|
+
ariaLabel="Folded"
|
|
149
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
150
|
+
/>
|
|
151
|
+
</ChartCursorScope>,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Drives the daily chart to Aug 05 — the back half of the Aug 01–07 bucket (midpoint Aug 04) —
|
|
156
|
+
// via 5 keyboard ArrowRights from an unfocused slider (1st press lands on index 0).
|
|
157
|
+
function driveDailyToAug05() {
|
|
158
|
+
const dailySlider = screen.getByRole('slider', { name: 'Daily' })
|
|
159
|
+
for (let i = 0; i < 5; i++) fireEvent.keyDown(dailySlider, { key: 'ArrowRight' })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("cursorResolution='leading' resolves the back-half day to the bucket it's INSIDE, not the following one", () => {
|
|
163
|
+
renderPair('leading')
|
|
164
|
+
driveDailyToAug05()
|
|
165
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
166
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('01.08')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("without the prop (default 'nearest'), the same daily hover lands on the FOLLOWING bucket instead", () => {
|
|
170
|
+
renderPair()
|
|
171
|
+
driveDailyToAug05()
|
|
172
|
+
const foldedSlider = screen.getByRole('slider', { name: 'Folded' })
|
|
173
|
+
expect(foldedSlider.getAttribute('aria-valuetext')).toBe('08.08')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
92
177
|
describe('BarsBar<T> — the `= unknown` default stays assignable', () => {
|
|
93
178
|
test('an existing untyped BarsBar[] array is assignable to BarsBar<Row>[] contravariantly', () => {
|
|
94
179
|
const bars: BarsBar[] = [{ key: 'shown', label: 'Shown', color: '#111' }]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { curveMonotoneX } from '@visx/curve'
|
|
2
2
|
import { LinePath } from '@visx/shape'
|
|
3
3
|
import { memo, useMemo } from 'react'
|
|
4
|
+
import type { CursorResolution } from '../cursor/resolve'
|
|
4
5
|
import { VX } from '../../tokens'
|
|
5
6
|
import type { ChartMargin } from '../../tokens'
|
|
6
7
|
import { CartesianChart } from '../primitives/CartesianChart'
|
|
@@ -108,6 +109,14 @@ export type BarsProps<T> = {
|
|
|
108
109
|
|
|
109
110
|
/** X tick count override. Default: as many as fit. */
|
|
110
111
|
xTicks?: number
|
|
112
|
+
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
113
|
+
formatX?: (key: string) => string
|
|
114
|
+
/**
|
|
115
|
+
* How a sibling chart's broadcast cursor key resolves against this chart's points. Default
|
|
116
|
+
* `'nearest'`. Pass `'leading'` when `getX` returns a bucket's leading edge (a weekly series
|
|
117
|
+
* keyed by its Monday) — see `CursorResolution`.
|
|
118
|
+
*/
|
|
119
|
+
cursorResolution?: CursorResolution
|
|
111
120
|
|
|
112
121
|
/** `false` disables the tooltip entirely (and with it the crosshair dots). */
|
|
113
122
|
tooltip?: CartesianTooltipConfig<T> | false
|
|
@@ -152,6 +161,8 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
152
161
|
barLayout = 'stacked',
|
|
153
162
|
barOpacity,
|
|
154
163
|
xTicks,
|
|
164
|
+
formatX,
|
|
165
|
+
cursorResolution,
|
|
155
166
|
tooltip,
|
|
156
167
|
margin,
|
|
157
168
|
legend,
|
|
@@ -269,6 +280,8 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
269
280
|
y={yConfig}
|
|
270
281
|
{...(y2 !== undefined && { y2 })}
|
|
271
282
|
{...(xTicks !== undefined && { xTicks })}
|
|
283
|
+
{...(formatX !== undefined && { formatX })}
|
|
284
|
+
{...(cursorResolution !== undefined && { cursorResolution })}
|
|
272
285
|
{...(zones !== undefined && { zones })}
|
|
273
286
|
{...(mappedRefLines !== undefined && { refLines: mappedRefLines })}
|
|
274
287
|
{...(height !== undefined && { height })}
|