@astryxdesign/charts 0.5.2-canary.e4f8e4e → 0.5.2-canary.ea04834
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/README.md +2 -2
- package/dist/ChartTooltip.d.ts.map +1 -1
- package/dist/ChartTooltip.js +45 -8
- package/dist/charts.css +30 -27
- package/package.json +3 -3
- package/src/Chart.test.tsx +136 -4
- package/src/ChartAxis.test.tsx +131 -0
- package/src/ChartGrid.test.tsx +121 -0
- package/src/ChartLegend.test.tsx +54 -0
- package/src/ChartSwatch.test.tsx +50 -0
- package/src/ChartTooltip.test.tsx +326 -0
- package/src/ChartTooltip.tsx +59 -11
- package/src/marks/marks.test.tsx +443 -0
- package/src/useChartColors.test.ts +51 -0
package/README.md
CHANGED
|
@@ -25,8 +25,8 @@ It ships to npm **only under the `@canary` dist-tag** — there is never a stabl
|
|
|
25
25
|
## Status
|
|
26
26
|
|
|
27
27
|
Under active development. The config-model chart and its marks/chrome are in place;
|
|
28
|
-
API and visuals are still being refined. See the
|
|
29
|
-
|
|
28
|
+
API and visuals are still being refined. See [`docs/`](./docs/) for the plan, design research, readiness audit, and
|
|
29
|
+
verification checklist.
|
|
30
30
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChartTooltip.d.ts","sourceRoot":"","sources":["../src/ChartTooltip.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ChartTooltip.d.ts","sourceRoot":"","sources":["../src/ChartTooltip.tsx"],"names":[],"mappings":"AA2BA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAcf,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAC,SAAS,EAAoB,MAAM,SAAS,CAAC;AAG1D,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAEtE,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,KAAK,SAAS,CAAC;IAC5E;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,qBAAqB,CAAC;CACnC;AA4FD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,EAC3B,MAAW,EACX,MAAM,EACN,cAAqB,EACrB,aAAoB,EACpB,SAAkB,GACnB,EAAE,iBAAiB,+BAyRnB"}
|
package/dist/ChartTooltip.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* @file ChartTooltip.tsx
|
|
5
5
|
* @output Grouped chart tooltip — shows all series values at the hovered x-position.
|
|
6
6
|
* Composable: drop into `<Chart tooltip>` or render directly inside a chart
|
|
7
|
-
* to take full control of props.
|
|
7
|
+
* to take full control of props. The card stays beside its owning chart
|
|
8
|
+
* and uses a manual popover to escape clipping and nested layer stacks.
|
|
8
9
|
* @position Reads from ChartContext — must render inside an <Chart>.
|
|
9
10
|
*
|
|
10
11
|
* @example
|
|
@@ -28,7 +29,7 @@ import { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
|
28
29
|
import { createPortal } from 'react-dom';
|
|
29
30
|
import * as stylex from '@stylexjs/stylex';
|
|
30
31
|
import '@astryxdesign/core/theme/tokens.stylex';
|
|
31
|
-
import { colorVars, radiusVars, shadowVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
32
|
+
import { appearanceVars, colorVars, radiusVars, shadowVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
32
33
|
import { Text } from '@astryxdesign/core';
|
|
33
34
|
import { VStack, HStack } from '@astryxdesign/core';
|
|
34
35
|
import { useChart } from "./ChartContext.js";
|
|
@@ -36,6 +37,33 @@ import { ChartSwatch, swatchVariantForType } from "./ChartSwatch.js";
|
|
|
36
37
|
import { deriveTooltipSeriesValues, shouldRenderHoverDot } from "./tooltip.js";
|
|
37
38
|
import { isBandScale, xPixel } from "./utils.js";
|
|
38
39
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
40
|
+
function showCard(card) {
|
|
41
|
+
// The fallback display must be applied first: showPopover() rejects elements
|
|
42
|
+
// that are still display:none.
|
|
43
|
+
card.style.display = 'block';
|
|
44
|
+
if (typeof card.showPopover === 'function') {
|
|
45
|
+
try {
|
|
46
|
+
if (!card.matches(':popover-open')) {
|
|
47
|
+
card.showPopover();
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// The reduced browser path below still makes the card visible.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function hideCard(card) {
|
|
55
|
+
if (typeof card.hidePopover === 'function') {
|
|
56
|
+
try {
|
|
57
|
+
if (card.matches(':popover-open')) {
|
|
58
|
+
card.hidePopover();
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// The reduced browser path below still hides the card.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
card.style.display = 'none';
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
/** Default tooltip body — used when `render` is not provided. */
|
|
40
68
|
const DefaultTooltipContent = /*#__PURE__*/memo(function DefaultTooltipContent({
|
|
41
69
|
xValue,
|
|
@@ -97,6 +125,7 @@ export function ChartTooltip({
|
|
|
97
125
|
height
|
|
98
126
|
} = useChart();
|
|
99
127
|
const [hoveredIndex, setHoveredIndex] = useState(null);
|
|
128
|
+
const [portalTarget, setPortalTarget] = useState(null);
|
|
100
129
|
const cardRef = useRef(null);
|
|
101
130
|
// Whether the card is currently suppressed (no content, or a custom `render`
|
|
102
131
|
// opted out by returning null). positionCard reads this so a same-index
|
|
@@ -108,6 +137,13 @@ export function ChartTooltip({
|
|
|
108
137
|
// Recomputed only when the resolved map identity changes (per layout pass).
|
|
109
138
|
const resolvedKeys = useMemo(() => new Set(resolved.keys()), [resolved]);
|
|
110
139
|
|
|
140
|
+
// Host the manual popover beside the chart rather than at document.body.
|
|
141
|
+
// Native top-layer promotion still escapes chart clipping, while the DOM
|
|
142
|
+
// position keeps nested theme variables and the owning Drawer relationship.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
setPortalTarget(svgRef.current?.parentElement ?? null);
|
|
145
|
+
}, [svgRef]);
|
|
146
|
+
|
|
111
147
|
// ─── Card positioning ──────────────────────────────────────────────────
|
|
112
148
|
// Imperative — the card is portaled, and we don't want pointer-move to
|
|
113
149
|
// trigger React renders.
|
|
@@ -118,9 +154,10 @@ export function ChartTooltip({
|
|
|
118
154
|
return;
|
|
119
155
|
}
|
|
120
156
|
if (!svg || !e.nearest || cardHiddenRef.current) {
|
|
121
|
-
card
|
|
157
|
+
hideCard(card);
|
|
122
158
|
return;
|
|
123
159
|
}
|
|
160
|
+
showCard(card);
|
|
124
161
|
const svgRect = svg.getBoundingClientRect();
|
|
125
162
|
const screenX = svgRect.left + margin.left + e.nearest.px;
|
|
126
163
|
const screenY = svgRect.top + margin.top;
|
|
@@ -157,7 +194,6 @@ export function ChartTooltip({
|
|
|
157
194
|
y = Math.max(gap, Math.min(y, viewportHeight - cardHeight - gap));
|
|
158
195
|
card.style.left = `${x}px`;
|
|
159
196
|
card.style.top = `${y}px`;
|
|
160
|
-
card.style.display = 'block';
|
|
161
197
|
}, [svgRef, margin, placement]);
|
|
162
198
|
|
|
163
199
|
// ─── Subscribe to pointer events ───────────────────────────────────────
|
|
@@ -283,7 +319,7 @@ export function ChartTooltip({
|
|
|
283
319
|
if (hidden) {
|
|
284
320
|
const card = cardRef.current;
|
|
285
321
|
if (card) {
|
|
286
|
-
card
|
|
322
|
+
hideCard(card);
|
|
287
323
|
}
|
|
288
324
|
}
|
|
289
325
|
}, [render, hoveredIndex, datum, xValue, seriesValues]);
|
|
@@ -304,13 +340,14 @@ export function ChartTooltip({
|
|
|
304
340
|
rows: seriesValues
|
|
305
341
|
});
|
|
306
342
|
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
307
|
-
children: [hoverIndicatorElement, dots, /*#__PURE__*/createPortal(/*#__PURE__*/_jsx("div", {
|
|
343
|
+
children: [hoverIndicatorElement, dots, portalTarget != null && /*#__PURE__*/createPortal(/*#__PURE__*/_jsx("div", {
|
|
308
344
|
ref: cardRef,
|
|
309
345
|
role: "tooltip",
|
|
346
|
+
popover: "manual",
|
|
310
347
|
...{
|
|
311
|
-
className: "xixxii4 x1prclbq x1hviunn xce4md1 xrrkdod x14hfi27 x47corl xuxw1ft
|
|
348
|
+
className: "xixxii4 x1anvkwx x10im51j xrxpjvj x1prclbq x1hviunn xce4md1 xrrkdod x14hfi27 x47corl xuxw1ft xoqq3by x1s85apg"
|
|
312
349
|
},
|
|
313
350
|
children: cardContent
|
|
314
|
-
}),
|
|
351
|
+
}), portalTarget)]
|
|
315
352
|
});
|
|
316
353
|
}
|
package/dist/charts.css
CHANGED
|
@@ -3,31 +3,34 @@
|
|
|
3
3
|
|
|
4
4
|
@layer astryx-base {
|
|
5
5
|
@property --x-backgroundColor { syntax: "*"; inherits: false;}
|
|
6
|
-
.
|
|
7
|
-
.
|
|
8
|
-
.
|
|
9
|
-
.
|
|
10
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
.
|
|
22
|
-
.
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
.
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
.
|
|
6
|
+
.x1anvkwx:not(#\#){inset:auto}
|
|
7
|
+
.xbvija:not(#\#):not(#\#){border-radius:1.5px}
|
|
8
|
+
.x1cum3z5:not(#\#):not(#\#){border-radius:2px}
|
|
9
|
+
.x1hviunn:not(#\#):not(#\#){border-radius:var(--radius-container)}
|
|
10
|
+
.x98rzlu:not(#\#):not(#\#){flex:1}
|
|
11
|
+
.x10im51j:not(#\#):not(#\#){margin-block:0}
|
|
12
|
+
.xrxpjvj:not(#\#):not(#\#){margin-inline:0}
|
|
13
|
+
.xckhbgb:not(#\#):not(#\#){margin-inline:1px}
|
|
14
|
+
.xb3r6kr:not(#\#):not(#\#){overflow:hidden}
|
|
15
|
+
.xce4md1:not(#\#):not(#\#){padding-block:var(--spacing-2)}
|
|
16
|
+
.xrrkdod:not(#\#):not(#\#){padding-inline:var(--spacing-3)}
|
|
17
|
+
.x1prclbq:not(#\#):not(#\#):not(#\#){background-color:var(--color-background-popover)}
|
|
18
|
+
.xl8spv7:not(#\#):not(#\#):not(#\#){background-color:var(--x-backgroundColor)}
|
|
19
|
+
.x14hfi27:not(#\#):not(#\#):not(#\#){box-shadow:var(--shadow-med)}
|
|
20
|
+
.x1s85apg:not(#\#):not(#\#):not(#\#){display:none}
|
|
21
|
+
.x2lah0s:not(#\#):not(#\#):not(#\#){flex-shrink:0}
|
|
22
|
+
.x47corl:not(#\#):not(#\#):not(#\#){pointer-events:none}
|
|
23
|
+
.xixxii4:not(#\#):not(#\#):not(#\#){position:fixed}
|
|
24
|
+
.xnpr05h:not(#\#):not(#\#):not(#\#){stroke-width:1}
|
|
25
|
+
.x113i6qz:not(#\#):not(#\#):not(#\#){stroke:var(--color-border-emphasized)}
|
|
26
|
+
.x1lo1t26:not(#\#):not(#\#):not(#\#){stroke:var(--color-text-primary)}
|
|
27
|
+
.xuxw1ft:not(#\#):not(#\#):not(#\#){white-space:nowrap}
|
|
28
|
+
.xoqq3by:not(#\#):not(#\#):not(#\#){z-index:var(--appearance-layer-nesting)}
|
|
29
|
+
.xuoj239:not(#\#):not(#\#):not(#\#):not(#\#){height:3px}
|
|
30
|
+
.xdk7pt:not(#\#):not(#\#):not(#\#):not(#\#){height:8px}
|
|
31
|
+
.x1yztbdb:not(#\#):not(#\#):not(#\#):not(#\#){margin-bottom:16px}
|
|
32
|
+
.xeuugli:not(#\#):not(#\#):not(#\#):not(#\#){min-width:0}
|
|
33
|
+
.xh8yej3:not(#\#):not(#\#):not(#\#):not(#\#){width:100%}
|
|
34
|
+
.x1fsd2vl:not(#\#):not(#\#):not(#\#):not(#\#){width:10px}
|
|
35
|
+
.x1xc55vz:not(#\#):not(#\#):not(#\#):not(#\#){width:8px}
|
|
33
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/charts",
|
|
3
|
-
"version": "0.5.2-canary.
|
|
3
|
+
"version": "0.5.2-canary.ea04834",
|
|
4
4
|
"description": "Astryx charts — a config-model data visualization library (d3-based). Published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@stylexjs/stylex": ">=0.10.0",
|
|
48
|
-
"@astryxdesign/core": "0.5.2-canary.
|
|
48
|
+
"@astryxdesign/core": "0.5.2-canary.ea04834",
|
|
49
49
|
"react": ">=19.0.0",
|
|
50
50
|
"react-dom": ">=19.0.0"
|
|
51
51
|
},
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"d3-shape": "^3.2.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@astryxdesign/cli": "0.5.2-canary.
|
|
58
|
+
"@astryxdesign/cli": "0.5.2-canary.ea04834",
|
|
59
59
|
"@types/d3-scale": "^4.0.9",
|
|
60
60
|
"@types/d3-shape": "^3.1.8"
|
|
61
61
|
},
|
package/src/Chart.test.tsx
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Chart.test.tsx
|
|
5
|
-
* @input Uses vitest, @testing-library/react, Chart root + bar
|
|
6
|
-
* @output Unit tests for Chart accessibility (WCAG 1.1.1)
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Chart root + bar/line marks
|
|
6
|
+
* @output Unit tests for Chart accessibility (WCAG 1.1.1) and the chart
|
|
7
|
+
* root's functional contract — measurement gate, per-series render
|
|
8
|
+
* delegation, event layer geometry, palette assignment, and the
|
|
9
|
+
* declarative legend/tooltip slots (issue #4295 viz coverage)
|
|
7
10
|
* @position Testing; validates Chart.tsx accessible name + data-table fallback
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
|
|
11
|
-
import {render, screen, act} from '@testing-library/react';
|
|
14
|
+
import {render, screen, act, within} from '@testing-library/react';
|
|
12
15
|
import {Chart} from './Chart';
|
|
13
|
-
import {bar} from './marks';
|
|
16
|
+
import {bar, line} from './marks';
|
|
14
17
|
|
|
15
18
|
const data = [
|
|
16
19
|
{month: 'Jan', revenue: 100, profit: 20},
|
|
@@ -80,6 +83,25 @@ describe('Chart accessible name', () => {
|
|
|
80
83
|
screen.getByRole('img', {name: 'Quarterly revenue'}),
|
|
81
84
|
).toBeInTheDocument();
|
|
82
85
|
});
|
|
86
|
+
|
|
87
|
+
it('describes the svg from the subtitle and mirrors the title in an svg <title>', () => {
|
|
88
|
+
const {container} = render(
|
|
89
|
+
<Chart
|
|
90
|
+
data={data}
|
|
91
|
+
xKey="month"
|
|
92
|
+
series={[bar('revenue')]}
|
|
93
|
+
title="Quarterly revenue"
|
|
94
|
+
subtitle="Revenue per month"
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
reportWidth(600);
|
|
98
|
+
|
|
99
|
+
const svg = container.querySelector('svg')!;
|
|
100
|
+
expect(svg.querySelector('title')!.textContent).toBe('Quarterly revenue');
|
|
101
|
+
const desc = svg.querySelector('desc')!;
|
|
102
|
+
expect(desc.textContent).toBe('Revenue per month');
|
|
103
|
+
expect(svg.getAttribute('aria-describedby')).toBe(desc.id);
|
|
104
|
+
});
|
|
83
105
|
});
|
|
84
106
|
|
|
85
107
|
describe('Chart data table fallback', () => {
|
|
@@ -127,3 +149,113 @@ describe('Chart data table fallback', () => {
|
|
|
127
149
|
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
128
150
|
});
|
|
129
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('Chart measurement gate', () => {
|
|
154
|
+
it('renders only an empty height-reserving placeholder until the container reports a width', () => {
|
|
155
|
+
const {container} = render(
|
|
156
|
+
<Chart data={data} xKey="month" series={[bar('revenue')]} height={300} />,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(container.querySelector('svg')).toBeNull();
|
|
160
|
+
const placeholder = container.firstElementChild as HTMLElement;
|
|
161
|
+
expect(placeholder.tagName).toBe('DIV');
|
|
162
|
+
expect(placeholder.style.height).toBe('300px');
|
|
163
|
+
expect(placeholder.childElementCount).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('Chart render delegation', () => {
|
|
168
|
+
it('renders one clipped group per series', () => {
|
|
169
|
+
const {container} = render(
|
|
170
|
+
<Chart
|
|
171
|
+
data={data}
|
|
172
|
+
xKey="month"
|
|
173
|
+
series={[bar('revenue'), line('profit')]}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
reportWidth(600);
|
|
177
|
+
|
|
178
|
+
const plot = container.querySelector('g[clip-path]');
|
|
179
|
+
expect(plot).not.toBeNull();
|
|
180
|
+
const seriesGroups = Array.from(plot!.children).filter(
|
|
181
|
+
el => el.tagName === 'g',
|
|
182
|
+
);
|
|
183
|
+
expect(seriesGroups).toHaveLength(2);
|
|
184
|
+
// Each series' render() output lands in its own group: bar paths in the
|
|
185
|
+
// first, the line's stroke path in the second.
|
|
186
|
+
expect(seriesGroups[0].querySelectorAll('path').length).toBeGreaterThan(0);
|
|
187
|
+
expect(seriesGroups[1].querySelectorAll('path').length).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('sizes the transparent event-capture rect to the plot area inside the default margins', () => {
|
|
191
|
+
const {container} = render(
|
|
192
|
+
<Chart data={data} xKey="month" series={[bar('revenue')]} height={300} />,
|
|
193
|
+
);
|
|
194
|
+
reportWidth(600);
|
|
195
|
+
|
|
196
|
+
// 600 - 48 (left) - 24 (right) = 528; 300 - 24 (top) - 32 (bottom) = 244.
|
|
197
|
+
const eventRect = container.querySelector(
|
|
198
|
+
'svg > g > rect[fill="transparent"]',
|
|
199
|
+
);
|
|
200
|
+
expect(eventRect).not.toBeNull();
|
|
201
|
+
expect(eventRect).toHaveAttribute('width', '528');
|
|
202
|
+
expect(eventRect).toHaveAttribute('height', '244');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('assigns distinct palette colors to series that do not declare one', () => {
|
|
206
|
+
const {container} = render(
|
|
207
|
+
<Chart
|
|
208
|
+
data={data}
|
|
209
|
+
xKey="month"
|
|
210
|
+
series={[line('revenue'), line('profit')]}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
reportWidth(600);
|
|
214
|
+
|
|
215
|
+
const strokes = Array.from(
|
|
216
|
+
container.querySelectorAll('g[clip-path] path[stroke]'),
|
|
217
|
+
).map(p => p.getAttribute('stroke'));
|
|
218
|
+
expect(strokes).toHaveLength(2);
|
|
219
|
+
expect(strokes[0]).toBeTruthy();
|
|
220
|
+
expect(strokes[1]).toBeTruthy();
|
|
221
|
+
expect(strokes[0]).not.toBe(strokes[1]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('Chart legend slot', () => {
|
|
226
|
+
it('derives legend items from series labels when legend is enabled', () => {
|
|
227
|
+
render(
|
|
228
|
+
<Chart
|
|
229
|
+
data={data}
|
|
230
|
+
xKey="month"
|
|
231
|
+
series={[bar('revenue', {label: 'Revenue'}), line('profit')]}
|
|
232
|
+
legend
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
reportWidth(600);
|
|
236
|
+
|
|
237
|
+
const list = screen.getByRole('list', {name: 'Chart legend'});
|
|
238
|
+
const rows = within(list).getAllByRole('listitem');
|
|
239
|
+
expect(rows).toHaveLength(2);
|
|
240
|
+
expect(within(rows[0]).getByText('Revenue')).toBeInTheDocument();
|
|
241
|
+
// Label falls back to the dataKey when not provided.
|
|
242
|
+
expect(within(rows[1]).getByText('profit')).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('Chart tooltip slot', () => {
|
|
247
|
+
it('mounts the grouped tooltip portal only when tooltip is enabled', () => {
|
|
248
|
+
const {unmount} = render(
|
|
249
|
+
<Chart data={data} xKey="month" series={[bar('revenue')]} />,
|
|
250
|
+
);
|
|
251
|
+
reportWidth(600);
|
|
252
|
+
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
|
253
|
+
unmount();
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<Chart data={data} xKey="month" series={[bar('revenue')]} tooltip />,
|
|
257
|
+
);
|
|
258
|
+
reportWidth(600);
|
|
259
|
+
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ChartAxis.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Chart, ChartAxis, bar mark
|
|
6
|
+
* @output Functional tests for axis rendering — group semantics, band/linear
|
|
7
|
+
* tick labels, default edge-line rules, custom formatting, truncation,
|
|
8
|
+
* and label capping
|
|
9
|
+
* @position Colocated test for ChartAxis.tsx (issue #4295 viz coverage)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {render, screen, act, within} from '@testing-library/react';
|
|
14
|
+
import {Chart} from './Chart';
|
|
15
|
+
import {ChartAxis} from './ChartAxis';
|
|
16
|
+
import {bar} from './marks/bar';
|
|
17
|
+
|
|
18
|
+
const DATA = [
|
|
19
|
+
{month: 'Jan', sales: 4},
|
|
20
|
+
{month: 'Feb', sales: 10},
|
|
21
|
+
{month: 'Mar', sales: 2},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
25
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
resizeCallback = undefined;
|
|
29
|
+
vi.stubGlobal(
|
|
30
|
+
'ResizeObserver',
|
|
31
|
+
class {
|
|
32
|
+
constructor(cb: ResizeObserverCallback) {
|
|
33
|
+
resizeCallback = cb;
|
|
34
|
+
}
|
|
35
|
+
observe() {}
|
|
36
|
+
unobserve() {}
|
|
37
|
+
disconnect() {}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function reportWidth(width: number) {
|
|
47
|
+
act(() => {
|
|
48
|
+
resizeCallback?.(
|
|
49
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
50
|
+
{} as ResizeObserver,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderChart(axes: React.ReactNode, data = DATA) {
|
|
56
|
+
const utils = render(
|
|
57
|
+
<Chart data={data} xKey="month" series={[bar('sales')]} axes={axes} />,
|
|
58
|
+
);
|
|
59
|
+
reportWidth(600);
|
|
60
|
+
return utils;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('ChartAxis bottom (band scale)', () => {
|
|
64
|
+
it('renders a labeled group with one tick label per category', () => {
|
|
65
|
+
renderChart(<ChartAxis position="bottom" />);
|
|
66
|
+
const axis = screen.getByRole('group', {name: 'bottom axis'});
|
|
67
|
+
expect(within(axis).getByText('Jan')).toBeInTheDocument();
|
|
68
|
+
expect(within(axis).getByText('Feb')).toBeInTheDocument();
|
|
69
|
+
expect(within(axis).getByText('Mar')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is translated to the bottom edge of the plot and draws its edge line by default', () => {
|
|
73
|
+
renderChart(<ChartAxis position="bottom" />);
|
|
74
|
+
const axis = screen.getByRole('group', {name: 'bottom axis'});
|
|
75
|
+
// Inner height for the default 300px chart: 300 - 24 - 32 = 244.
|
|
76
|
+
expect(axis).toHaveAttribute('transform', 'translate(0,244)');
|
|
77
|
+
const edge = axis.querySelector('line')!;
|
|
78
|
+
expect(edge).not.toBeNull();
|
|
79
|
+
expect(edge).toHaveAttribute('x2', '528');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('caps labels to maxTicks by evenly skipping categories', () => {
|
|
83
|
+
renderChart(<ChartAxis position="bottom" maxTicks={2} />);
|
|
84
|
+
const axis = screen.getByRole('group', {name: 'bottom axis'});
|
|
85
|
+
// 3 categories capped at 2 → step 2 → indices 0 and 2 survive.
|
|
86
|
+
expect(within(axis).getByText('Jan')).toBeInTheDocument();
|
|
87
|
+
expect(within(axis).getByText('Mar')).toBeInTheDocument();
|
|
88
|
+
expect(within(axis).queryByText('Feb')).not.toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('truncates long category labels with an ellipsis', () => {
|
|
92
|
+
renderChart(<ChartAxis position="bottom" truncate={4} />, [
|
|
93
|
+
{month: 'January', sales: 4},
|
|
94
|
+
{month: 'Feb', sales: 10},
|
|
95
|
+
]);
|
|
96
|
+
const axis = screen.getByRole('group', {name: 'bottom axis'});
|
|
97
|
+
expect(within(axis).getByText('Janu…')).toBeInTheDocument();
|
|
98
|
+
// Short labels are left alone.
|
|
99
|
+
expect(within(axis).getByText('Feb')).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('ChartAxis left (linear scale)', () => {
|
|
104
|
+
it('renders d3-formatted value ticks without an edge line by default', () => {
|
|
105
|
+
renderChart(<ChartAxis position="left" />);
|
|
106
|
+
const axis = screen.getByRole('group', {name: 'left axis'});
|
|
107
|
+
// Bar series pins a zero baseline → domain [0, 10] → ticks 0,2,...,10.
|
|
108
|
+
expect(within(axis).getByText('0')).toBeInTheDocument();
|
|
109
|
+
expect(within(axis).getByText('4')).toBeInTheDocument();
|
|
110
|
+
expect(within(axis).getByText('10')).toBeInTheDocument();
|
|
111
|
+
// Documented default: only the bottom axis draws its edge line.
|
|
112
|
+
expect(axis.querySelector('line')).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('applies a custom tick formatter', () => {
|
|
116
|
+
renderChart(
|
|
117
|
+
<ChartAxis position="left" tickFormat={v => `$${String(v)}`} />,
|
|
118
|
+
);
|
|
119
|
+
const axis = screen.getByRole('group', {name: 'left axis'});
|
|
120
|
+
expect(within(axis).getByText('$0')).toBeInTheDocument();
|
|
121
|
+
expect(within(axis).getByText('$10')).toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('showTicks draws a perpendicular mark per tick and forces the axis line on', () => {
|
|
125
|
+
renderChart(<ChartAxis position="left" showTicks />);
|
|
126
|
+
const axis = screen.getByRole('group', {name: 'left axis'});
|
|
127
|
+
const lines = axis.querySelectorAll('line');
|
|
128
|
+
// 6 ticks (0..10 by 2) + the forced edge line.
|
|
129
|
+
expect(lines).toHaveLength(7);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ChartGrid.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Chart, ChartGrid, bar mark
|
|
6
|
+
* @output Functional tests for grid lines — horizontal default, the skipped
|
|
7
|
+
* y=0 line, and vertical lines at band centers
|
|
8
|
+
* @position Colocated test for ChartGrid.tsx (issue #4295 viz coverage)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
12
|
+
import {render, act} from '@testing-library/react';
|
|
13
|
+
import {Chart} from './Chart';
|
|
14
|
+
import {ChartGrid} from './ChartGrid';
|
|
15
|
+
import {bar} from './marks/bar';
|
|
16
|
+
|
|
17
|
+
const DATA = [
|
|
18
|
+
{month: 'Jan', sales: 4},
|
|
19
|
+
{month: 'Feb', sales: 10},
|
|
20
|
+
{month: 'Mar', sales: 2},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
24
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
resizeCallback = undefined;
|
|
28
|
+
vi.stubGlobal(
|
|
29
|
+
'ResizeObserver',
|
|
30
|
+
class {
|
|
31
|
+
constructor(cb: ResizeObserverCallback) {
|
|
32
|
+
resizeCallback = cb;
|
|
33
|
+
}
|
|
34
|
+
observe() {}
|
|
35
|
+
unobserve() {}
|
|
36
|
+
disconnect() {}
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function reportWidth(width: number) {
|
|
46
|
+
act(() => {
|
|
47
|
+
resizeCallback?.(
|
|
48
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
49
|
+
{} as ResizeObserver,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderGrid(grid: React.ReactNode) {
|
|
55
|
+
const utils = render(
|
|
56
|
+
<Chart data={DATA} xKey="month" series={[bar('sales')]} grid={grid} />,
|
|
57
|
+
);
|
|
58
|
+
reportWidth(600);
|
|
59
|
+
// Marks render paths and the event layer is a rect, so every <line> in the
|
|
60
|
+
// svg belongs to the grid (no axes are mounted in these tests).
|
|
61
|
+
const lines = Array.from(utils.container.querySelectorAll('svg line'));
|
|
62
|
+
return {...utils, lines};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('ChartGrid', () => {
|
|
66
|
+
it('draws horizontal lines across the plot for every y tick except the y=0 baseline', () => {
|
|
67
|
+
const {lines} = renderGrid(<ChartGrid />);
|
|
68
|
+
// Domain [0, 10] → ticks 0,2,4,6,8,10; the 0 line is skipped → 5 lines.
|
|
69
|
+
expect(lines).toHaveLength(5);
|
|
70
|
+
for (const l of lines) {
|
|
71
|
+
expect(l.getAttribute('x1')).toBe('0');
|
|
72
|
+
expect(l.getAttribute('x2')).toBe('528'); // full plot width
|
|
73
|
+
expect(l.getAttribute('y1')).toBe(l.getAttribute('y2')); // horizontal
|
|
74
|
+
// y=0 maps to the plot bottom (244) — never double-drawn over the axis.
|
|
75
|
+
expect(l.getAttribute('y1')).not.toBe('244');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('draws a vertical line at each band center when vertical is enabled', () => {
|
|
80
|
+
const {lines} = renderGrid(<ChartGrid horizontal={false} vertical />);
|
|
81
|
+
expect(lines).toHaveLength(3); // one per category
|
|
82
|
+
for (const l of lines) {
|
|
83
|
+
expect(l.getAttribute('x1')).toBe(l.getAttribute('x2')); // vertical
|
|
84
|
+
expect(l.getAttribute('y1')).toBe('0');
|
|
85
|
+
expect(l.getAttribute('y2')).toBe('244'); // full plot height
|
|
86
|
+
}
|
|
87
|
+
// Band centers are strictly increasing across categories.
|
|
88
|
+
const xs = lines.map(l => Number(l.getAttribute('x1')));
|
|
89
|
+
expect(xs[0]).toBeLessThan(xs[1]);
|
|
90
|
+
expect(xs[1]).toBeLessThan(xs[2]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('combines horizontal and vertical lines when both are enabled', () => {
|
|
94
|
+
const {lines} = renderGrid(<ChartGrid horizontal vertical />);
|
|
95
|
+
expect(lines).toHaveLength(8); // 5 horizontal + 3 vertical
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('places vertical lines at d3 tick values on a linear x scale', () => {
|
|
99
|
+
const numeric = [
|
|
100
|
+
{month: 0, sales: 1},
|
|
101
|
+
{month: 5, sales: 7},
|
|
102
|
+
{month: 10, sales: 3},
|
|
103
|
+
];
|
|
104
|
+
const utils = render(
|
|
105
|
+
<Chart
|
|
106
|
+
data={numeric}
|
|
107
|
+
xKey="month"
|
|
108
|
+
series={[bar('sales')]}
|
|
109
|
+
grid={<ChartGrid horizontal={false} vertical />}
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
reportWidth(600);
|
|
113
|
+
const lines = Array.from(utils.container.querySelectorAll('svg line'));
|
|
114
|
+
// Linear domain [0, 10] → ticks 0,2,4,6,8,10 → 6 evenly spaced lines.
|
|
115
|
+
expect(lines).toHaveLength(6);
|
|
116
|
+
const xs = lines.map(l => Number(l.getAttribute('x1')));
|
|
117
|
+
expect(xs[0]).toBe(0);
|
|
118
|
+
expect(xs[5]).toBe(528);
|
|
119
|
+
expect(xs[1] - xs[0]).toBeCloseTo(528 / 5);
|
|
120
|
+
});
|
|
121
|
+
});
|