@automattic/charts 4.1.1 → 4.2.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 +19 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +875 -237
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +106 -6
- package/dist/index.d.ts +106 -6
- package/dist/index.js +875 -239
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/bar-chart/bar-chart.tsx +67 -17
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +6 -0
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/types.ts +2 -2
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/private/base-legend.tsx +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +127 -40
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +6 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
import { render } from '@testing-library/react';
|
|
2
2
|
import { GlobalChartsProvider } from '../../../providers';
|
|
3
3
|
import BarChart from '../bar-chart';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
jest.mock( '@visx/xychart', () => {
|
|
12
|
-
const actual = jest.requireActual( '@visx/xychart' );
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
__esModule: true,
|
|
16
|
-
...actual,
|
|
17
|
-
XYChart: ( props: { children?: ReactNode } ) => {
|
|
18
|
-
mockXYChart( props );
|
|
19
|
-
return <svg />;
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
} );
|
|
4
|
+
const mockBandTooltip = jest.fn();
|
|
5
|
+
jest.mock( '../private/band-tooltip', () => ( {
|
|
6
|
+
BandTooltip: props => {
|
|
7
|
+
mockBandTooltip( props );
|
|
8
|
+
return null;
|
|
9
|
+
},
|
|
10
|
+
} ) );
|
|
23
11
|
|
|
24
12
|
const mockRefCallback = jest.fn();
|
|
25
13
|
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
@@ -38,7 +26,7 @@ const DATA = [
|
|
|
38
26
|
];
|
|
39
27
|
|
|
40
28
|
/**
|
|
41
|
-
* Render the chart with the recorder standing in for
|
|
29
|
+
* Render the chart with the recorder standing in for band pointer handling.
|
|
42
30
|
*
|
|
43
31
|
* @param props - Props layered onto the defaults.
|
|
44
32
|
* @return The render result.
|
|
@@ -52,15 +40,15 @@ function renderBarChart( props = {} ) {
|
|
|
52
40
|
}
|
|
53
41
|
|
|
54
42
|
describe( 'BarChart pointer events', () => {
|
|
55
|
-
beforeEach( () =>
|
|
43
|
+
beforeEach( () => mockBandTooltip.mockClear() );
|
|
56
44
|
|
|
57
|
-
test( '
|
|
45
|
+
test( 'routes pointer handlers through band correction without requiring tooltips', () => {
|
|
58
46
|
const onPointerDown = jest.fn();
|
|
59
47
|
const onPointerUp = jest.fn();
|
|
60
48
|
|
|
61
49
|
renderBarChart( { onPointerDown, onPointerUp } );
|
|
62
50
|
|
|
63
|
-
const props =
|
|
51
|
+
const props = mockBandTooltip.mock.calls.at( -1 )[ 0 ];
|
|
64
52
|
expect( props.onPointerDown ).toBe( onPointerDown );
|
|
65
53
|
expect( props.onPointerUp ).toBe( onPointerUp );
|
|
66
54
|
} );
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
render,
|
|
3
|
+
renderHook,
|
|
4
|
+
screen,
|
|
5
|
+
within,
|
|
6
|
+
act,
|
|
7
|
+
waitFor,
|
|
8
|
+
fireEvent,
|
|
9
|
+
} from '@testing-library/react';
|
|
2
10
|
import userEvent from '@testing-library/user-event';
|
|
3
11
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
12
|
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
@@ -30,6 +38,12 @@ describe( 'BarChart', () => {
|
|
|
30
38
|
],
|
|
31
39
|
};
|
|
32
40
|
|
|
41
|
+
// visx renders bars and grid lines without accessible roles or configurable test IDs.
|
|
42
|
+
const getBarRects = () => {
|
|
43
|
+
// eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
|
|
44
|
+
return screen.getByRole( 'grid' ).querySelectorAll( '.visx-bar-group rect' );
|
|
45
|
+
};
|
|
46
|
+
|
|
33
47
|
const renderWithTheme = ( props = {}, children = undefined ) => {
|
|
34
48
|
return render(
|
|
35
49
|
<GlobalChartsProvider>
|
|
@@ -40,6 +54,247 @@ describe( 'BarChart', () => {
|
|
|
40
54
|
);
|
|
41
55
|
};
|
|
42
56
|
|
|
57
|
+
describe( 'pointer selection and focus return', () => {
|
|
58
|
+
const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
|
|
59
|
+
beforeAll( () => {
|
|
60
|
+
// jsdom lacks the SVG transform used by visx to convert pointer coordinates.
|
|
61
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value: () => null,
|
|
64
|
+
} );
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
afterAll( () => {
|
|
68
|
+
if ( screenTransform ) {
|
|
69
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
|
|
70
|
+
} else {
|
|
71
|
+
Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
|
|
72
|
+
}
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
const barGeometry = () => {
|
|
76
|
+
const bars = getBarRects();
|
|
77
|
+
return Array.from( bars, bar =>
|
|
78
|
+
[ 'x', 'y', 'width', 'height' ].map( name => Number( bar.getAttribute( name ) ) )
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const pointer = ( type: string, clientX: number, clientY: number ) => {
|
|
83
|
+
// visx owns the pointer capture rect and does not expose an attribute prop for it.
|
|
84
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
85
|
+
const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' );
|
|
86
|
+
fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) );
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const hover = ( clientX: number, clientY: number ) =>
|
|
90
|
+
pointer( 'pointermove', clientX, clientY );
|
|
91
|
+
|
|
92
|
+
test( 'Escape returns focus to the grid without reopening either tooltip', async () => {
|
|
93
|
+
jest.useFakeTimers();
|
|
94
|
+
try {
|
|
95
|
+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
|
|
96
|
+
renderWithTheme( { withTooltips: true } );
|
|
97
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
98
|
+
await user.tab();
|
|
99
|
+
await user.keyboard( '{ArrowRight}' );
|
|
100
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
101
|
+
await user.keyboard( '{Escape}' );
|
|
102
|
+
await act( async () => {
|
|
103
|
+
jest.advanceTimersByTime( 5000 );
|
|
104
|
+
} );
|
|
105
|
+
expect( chart ).toHaveFocus();
|
|
106
|
+
expect( screen.queryByTestId( /^chart-tooltip-\d+$/ ) ).not.toBeInTheDocument();
|
|
107
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
108
|
+
} finally {
|
|
109
|
+
jest.useRealTimers();
|
|
110
|
+
}
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
// The pointer never moves, so visx never fires the leave that would normally close this.
|
|
114
|
+
test( 'closes a hover tooltip when the series it describes is hidden', async () => {
|
|
115
|
+
let context: GlobalChartsContextValue;
|
|
116
|
+
const Grab = () => {
|
|
117
|
+
context = useGlobalChartsContext();
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
render(
|
|
122
|
+
<GlobalChartsProvider>
|
|
123
|
+
<Grab />
|
|
124
|
+
<BarChart { ...defaultProps } withTooltips chartId="test-hover-then-hide" />
|
|
125
|
+
</GlobalChartsProvider>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const [ [ x0, , width ] ] = barGeometry();
|
|
129
|
+
hover( x0 + width / 2, 150 );
|
|
130
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
|
|
131
|
+
|
|
132
|
+
act( () => context.toggleSeriesVisibility( 'test-hover-then-hide', 'Series A' ) );
|
|
133
|
+
|
|
134
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
test( 'selects the nearer padded band before and after an unrelated rerender', async () => {
|
|
138
|
+
const chart = ( className: string ) => (
|
|
139
|
+
<GlobalChartsProvider>
|
|
140
|
+
<BarChart { ...defaultProps } withTooltips className={ className } />
|
|
141
|
+
</GlobalChartsProvider>
|
|
142
|
+
);
|
|
143
|
+
const { rerender } = render( chart( 'before' ) );
|
|
144
|
+
const [ [ x0, , width ], [ x1 ] ] = barGeometry();
|
|
145
|
+
const clientX = ( x0 + width / 2 + x1 + width / 2 ) / 2 - 2;
|
|
146
|
+
hover( clientX, 150 );
|
|
147
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
|
|
148
|
+
rerender( chart( 'after' ) );
|
|
149
|
+
hover( x1 + width / 2, 150 );
|
|
150
|
+
await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 2' ) );
|
|
151
|
+
hover( clientX, 150 );
|
|
152
|
+
await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 1' ) );
|
|
153
|
+
} );
|
|
154
|
+
|
|
155
|
+
test( 'reports the tooltip datum when pressing and releasing in a padded gap', async () => {
|
|
156
|
+
const onPointerDown = jest.fn();
|
|
157
|
+
const onPointerUp = jest.fn();
|
|
158
|
+
renderWithTheme( { withTooltips: true, onPointerDown, onPointerUp } );
|
|
159
|
+
const [ [ x0, , width ], [ x1 ] ] = barGeometry();
|
|
160
|
+
const clientX = ( x0 + width / 2 + x1 + width / 2 ) / 2 - 2;
|
|
161
|
+
hover( clientX, 150 );
|
|
162
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
|
|
163
|
+
pointer( 'pointerdown', clientX, 150 );
|
|
164
|
+
pointer( 'pointerup', clientX, 150 );
|
|
165
|
+
for ( const callback of [ onPointerDown, onPointerUp ] ) {
|
|
166
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
167
|
+
expect( callback ).toHaveBeenLastCalledWith(
|
|
168
|
+
expect.objectContaining( {
|
|
169
|
+
key: 'Series A',
|
|
170
|
+
index: 0,
|
|
171
|
+
datum: defaultProps.data[ 0 ].data[ 0 ],
|
|
172
|
+
} )
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
} );
|
|
176
|
+
|
|
177
|
+
test( 'selects the painted grouped bar at the outer edge of a padded band', async () => {
|
|
178
|
+
renderWithTheme( {
|
|
179
|
+
withTooltips: true,
|
|
180
|
+
data: [ 'A', 'B', 'C' ].map( label => ( {
|
|
181
|
+
label,
|
|
182
|
+
data: [
|
|
183
|
+
{ label: 'Jan 1', value: 50 },
|
|
184
|
+
{ label: 'Jan 2', value: 50 },
|
|
185
|
+
{ label: 'Jan 3', value: 50 },
|
|
186
|
+
],
|
|
187
|
+
} ) ),
|
|
188
|
+
} );
|
|
189
|
+
const [ x, y, width, height ] = barGeometry()[ 6 ];
|
|
190
|
+
hover( x + width - 1, y + height / 2 );
|
|
191
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
|
|
192
|
+
} );
|
|
193
|
+
|
|
194
|
+
test.each( [ 'vertical', 'horizontal' ] )(
|
|
195
|
+
'selects the long grouped bar near its base in a %s chart',
|
|
196
|
+
async orientation => {
|
|
197
|
+
renderWithTheme( {
|
|
198
|
+
withTooltips: true,
|
|
199
|
+
orientation,
|
|
200
|
+
data: [
|
|
201
|
+
{
|
|
202
|
+
label: 'Series A',
|
|
203
|
+
data: [
|
|
204
|
+
{ label: 'Jan 1', value: 100 },
|
|
205
|
+
{ label: 'Jan 2', value: 100 },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
label: 'Series B',
|
|
210
|
+
data: [
|
|
211
|
+
{ label: 'Jan 1', value: 10 },
|
|
212
|
+
{ label: 'Jan 2', value: 10 },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
} );
|
|
217
|
+
const [ [ x, y, width, height ] ] = barGeometry();
|
|
218
|
+
hover(
|
|
219
|
+
orientation === 'horizontal' ? x + 2 : x + width / 2,
|
|
220
|
+
orientation === 'horizontal' ? y + height / 2 : y + height - 2
|
|
221
|
+
);
|
|
222
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Series A' );
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
} );
|
|
226
|
+
|
|
227
|
+
test( 'reports band bounds without drawing an overlay and clears them on Escape', async () => {
|
|
228
|
+
const user = userEvent.setup();
|
|
229
|
+
const onBandHighlightChange = jest.fn();
|
|
230
|
+
renderWithTheme( { withTooltips: true, onBandHighlightChange } );
|
|
231
|
+
await user.tab();
|
|
232
|
+
await user.keyboard( '{ArrowRight}' );
|
|
233
|
+
expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
|
|
234
|
+
expect.objectContaining( {
|
|
235
|
+
datum: expect.objectContaining( { value: 10 } ),
|
|
236
|
+
x: expect.any( Number ),
|
|
237
|
+
y: expect.any( Number ),
|
|
238
|
+
width: expect.any( Number ),
|
|
239
|
+
height: expect.any( Number ),
|
|
240
|
+
} )
|
|
241
|
+
);
|
|
242
|
+
expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
|
|
243
|
+
await user.keyboard( '{Escape}' );
|
|
244
|
+
await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
|
|
245
|
+
} );
|
|
246
|
+
|
|
247
|
+
test.each( [ 'vertical', 'horizontal' ] )(
|
|
248
|
+
'draws the active band across a %s plot',
|
|
249
|
+
async orientation => {
|
|
250
|
+
const user = userEvent.setup();
|
|
251
|
+
const onBandHighlightChange = jest.fn();
|
|
252
|
+
renderWithTheme( {
|
|
253
|
+
withTooltips: true,
|
|
254
|
+
withBandHighlight: true,
|
|
255
|
+
orientation,
|
|
256
|
+
onBandHighlightChange,
|
|
257
|
+
} );
|
|
258
|
+
await user.tab();
|
|
259
|
+
await user.keyboard( '{ArrowRight}' );
|
|
260
|
+
const highlight = screen.getByTestId( 'bar-chart-band-highlight' );
|
|
261
|
+
expect( highlight.matches( 'svg > :first-child' ) ).toBe( true );
|
|
262
|
+
const bounds = onBandHighlightChange.mock.calls.at( -1 )[ 0 ];
|
|
263
|
+
expect( Number( highlight.getAttribute( 'width' ) ) ).toBe( bounds.width );
|
|
264
|
+
expect( Number( highlight.getAttribute( 'height' ) ) ).toBe( bounds.height );
|
|
265
|
+
expect( bounds.width ).toBeGreaterThan( 0 );
|
|
266
|
+
expect( bounds.height ).toBeGreaterThan( 0 );
|
|
267
|
+
const bar = getBarRects()[ 0 ];
|
|
268
|
+
const bandAxis = orientation === 'vertical' ? 'x' : 'y';
|
|
269
|
+
const bandSize = orientation === 'vertical' ? 'width' : 'height';
|
|
270
|
+
const plotSize = orientation === 'vertical' ? 'height' : 'width';
|
|
271
|
+
const barStart = Number( bar.getAttribute( bandAxis ) );
|
|
272
|
+
const barSize = Number( bar.getAttribute( bandSize ) );
|
|
273
|
+
expect( Number( highlight.getAttribute( bandAxis ) ) ).toBeLessThanOrEqual( barStart );
|
|
274
|
+
expect(
|
|
275
|
+
Number( highlight.getAttribute( bandAxis ) ) + Number( highlight.getAttribute( bandSize ) )
|
|
276
|
+
).toBeGreaterThanOrEqual( barStart + barSize );
|
|
277
|
+
expect( Number( highlight.getAttribute( bandSize ) ) ).toBeLessThan( barSize * 2 );
|
|
278
|
+
expect( Number( highlight.getAttribute( plotSize ) ) ).toBeGreaterThan(
|
|
279
|
+
Number( bar.getAttribute( plotSize ) )
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
test( 'aligns horizontal grid lines with explicit value ticks', () => {
|
|
285
|
+
const { container } = renderWithTheme( {
|
|
286
|
+
gridVisibility: 'x',
|
|
287
|
+
options: {
|
|
288
|
+
axis: { y: { tickValues: [ 0, 50, 100 ] } },
|
|
289
|
+
scale: { y: { domain: [ 0, 100 ] } },
|
|
290
|
+
},
|
|
291
|
+
} );
|
|
292
|
+
// See the visx node constraint at getBarRects.
|
|
293
|
+
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
|
294
|
+
const lines = container.querySelectorAll( '.visx-rows line' );
|
|
295
|
+
expect( lines ).toHaveLength( 3 );
|
|
296
|
+
} );
|
|
297
|
+
|
|
43
298
|
describe( 'Data Validation', () => {
|
|
44
299
|
test( 'handles empty data array', () => {
|
|
45
300
|
renderWithTheme( { data: [] } );
|
|
@@ -927,6 +1182,54 @@ describe( 'BarChart', () => {
|
|
|
927
1182
|
} );
|
|
928
1183
|
|
|
929
1184
|
describe( 'Pattern', () => {
|
|
1185
|
+
test( 'uses point colors with a series fallback and preserves pattern fills', () => {
|
|
1186
|
+
const data: SeriesData[] = [
|
|
1187
|
+
{
|
|
1188
|
+
label: 'Scores',
|
|
1189
|
+
data: [
|
|
1190
|
+
{
|
|
1191
|
+
label: 'First day',
|
|
1192
|
+
value: 80,
|
|
1193
|
+
color: 'var(--wpds-color-foreground-content-success-weak)',
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
label: 'Second day',
|
|
1197
|
+
value: 40,
|
|
1198
|
+
color: 'var(--wpds-color-foreground-content-error-weak)',
|
|
1199
|
+
},
|
|
1200
|
+
{ label: 'Third day', value: 60 },
|
|
1201
|
+
],
|
|
1202
|
+
},
|
|
1203
|
+
];
|
|
1204
|
+
const { rerender } = render( <BarChart { ...defaultProps } data={ data } /> );
|
|
1205
|
+
const getBars = getBarRects;
|
|
1206
|
+
const bars = getBars();
|
|
1207
|
+
expect( bars ).toHaveLength( 3 );
|
|
1208
|
+
expect( bars[ 0 ] ).toHaveAttribute( 'fill', data[ 0 ].data[ 0 ].color );
|
|
1209
|
+
expect( bars[ 1 ] ).toHaveAttribute( 'fill', data[ 0 ].data[ 1 ].color );
|
|
1210
|
+
const fallbackFill = bars[ 2 ].getAttribute( 'fill' );
|
|
1211
|
+
expect( fallbackFill ).toBeTruthy();
|
|
1212
|
+
rerender(
|
|
1213
|
+
<BarChart
|
|
1214
|
+
{ ...defaultProps }
|
|
1215
|
+
data={ [
|
|
1216
|
+
{
|
|
1217
|
+
...data[ 0 ],
|
|
1218
|
+
data: data[ 0 ].data.map( point => ( { ...point, color: undefined } ) ),
|
|
1219
|
+
},
|
|
1220
|
+
] }
|
|
1221
|
+
/>
|
|
1222
|
+
);
|
|
1223
|
+
expect( getBars()[ 0 ] ).toHaveAttribute( 'fill', fallbackFill );
|
|
1224
|
+
rerender( <BarChart { ...defaultProps } data={ data } withPatterns /> );
|
|
1225
|
+
for ( const bar of getBars() ) {
|
|
1226
|
+
expect( bar ).toHaveAttribute(
|
|
1227
|
+
'fill',
|
|
1228
|
+
expect.stringMatching( /^url\(#bar-pattern-.+-0\)$/ )
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
} );
|
|
1232
|
+
|
|
930
1233
|
test( 'renders with patterns', () => {
|
|
931
1234
|
renderWithTheme( { withPatterns: true } );
|
|
932
1235
|
expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
|
|
@@ -1225,6 +1528,57 @@ describe( 'BarChart', () => {
|
|
|
1225
1528
|
} );
|
|
1226
1529
|
} );
|
|
1227
1530
|
|
|
1531
|
+
describe( 'ARIA grid boundaries', () => {
|
|
1532
|
+
// Three points, so a clamp at a boundary lands on a different index than a wrap would.
|
|
1533
|
+
const threePointData = [
|
|
1534
|
+
{
|
|
1535
|
+
label: 'Series A',
|
|
1536
|
+
data: [
|
|
1537
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1538
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1539
|
+
{ date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
|
|
1540
|
+
],
|
|
1541
|
+
options: {},
|
|
1542
|
+
},
|
|
1543
|
+
];
|
|
1544
|
+
|
|
1545
|
+
test( 'right arrow at the last point stays put and keeps the highlight (no escape, no wrap)', async () => {
|
|
1546
|
+
const user = userEvent.setup();
|
|
1547
|
+
renderWithTheme( { withTooltips: true, data: threePointData } );
|
|
1548
|
+
|
|
1549
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
1550
|
+
chart.focus();
|
|
1551
|
+
|
|
1552
|
+
// Move to the first, then on to the last (third) point.
|
|
1553
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1554
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
1555
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
1556
|
+
expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
|
|
1557
|
+
|
|
1558
|
+
// Right arrow at the last point must not move focus: the highlighted
|
|
1559
|
+
// bar/tooltip stays visible and focused (ARIA grid: focus does not move).
|
|
1560
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1561
|
+
expect( screen.getByTestId( 'chart-tooltip-2' ) ).toBeInTheDocument();
|
|
1562
|
+
expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
|
|
1563
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
1564
|
+
// Focus did not escape back to the chart container.
|
|
1565
|
+
expect( chart ).not.toHaveFocus();
|
|
1566
|
+
} );
|
|
1567
|
+
|
|
1568
|
+
// Left arrow as the first key is the hard case: it clamps from the unselected -1, and a wrap
|
|
1569
|
+
// would land on the last point instead.
|
|
1570
|
+
test( 'left arrow as the first key selects the first point', async () => {
|
|
1571
|
+
const user = userEvent.setup();
|
|
1572
|
+
renderWithTheme( { withTooltips: true, data: threePointData } );
|
|
1573
|
+
|
|
1574
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
1575
|
+
await user.keyboard( '{ArrowLeft}' );
|
|
1576
|
+
|
|
1577
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
1578
|
+
expect( screen.queryByTestId( 'chart-tooltip-2' ) ).not.toBeInTheDocument();
|
|
1579
|
+
} );
|
|
1580
|
+
} );
|
|
1581
|
+
|
|
1228
1582
|
describe( 'Comparison tooltip', () => {
|
|
1229
1583
|
test( 'tooltip shows both the primary and comparison values', async () => {
|
|
1230
1584
|
const user = userEvent.setup();
|
|
@@ -2350,4 +2704,214 @@ describe( 'BarChart', () => {
|
|
|
2350
2704
|
expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
2351
2705
|
} );
|
|
2352
2706
|
} );
|
|
2707
|
+
|
|
2708
|
+
describe( 'Keyboard navigation with a hidden primary series', () => {
|
|
2709
|
+
const mountWithVisibilityToggle = () => {
|
|
2710
|
+
let context: GlobalChartsContextValue;
|
|
2711
|
+
const Grab = () => {
|
|
2712
|
+
context = useGlobalChartsContext();
|
|
2713
|
+
return null;
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
render(
|
|
2717
|
+
<GlobalChartsProvider>
|
|
2718
|
+
<Grab />
|
|
2719
|
+
<BarChartUnresponsive
|
|
2720
|
+
width={ 500 }
|
|
2721
|
+
height={ 300 }
|
|
2722
|
+
withTooltips
|
|
2723
|
+
chartId="test-hide-while-navigating"
|
|
2724
|
+
data={ [
|
|
2725
|
+
{
|
|
2726
|
+
label: 'Series A',
|
|
2727
|
+
data: [
|
|
2728
|
+
{ label: 'Jan', value: 10 },
|
|
2729
|
+
{ label: 'Feb', value: 20 },
|
|
2730
|
+
{ label: 'Mar', value: 30 },
|
|
2731
|
+
],
|
|
2732
|
+
options: {},
|
|
2733
|
+
},
|
|
2734
|
+
{
|
|
2735
|
+
label: 'Series B',
|
|
2736
|
+
data: [
|
|
2737
|
+
{ label: 'Jan', value: 15 },
|
|
2738
|
+
{ label: 'Feb', value: 25 },
|
|
2739
|
+
{ label: 'Mar', value: 35 },
|
|
2740
|
+
],
|
|
2741
|
+
options: {},
|
|
2742
|
+
},
|
|
2743
|
+
] }
|
|
2744
|
+
/>
|
|
2745
|
+
</GlobalChartsProvider>
|
|
2746
|
+
);
|
|
2747
|
+
|
|
2748
|
+
return ( label: string ) =>
|
|
2749
|
+
act( () => context.toggleSeriesVisibility( 'test-hide-while-navigating', label ) );
|
|
2750
|
+
};
|
|
2751
|
+
|
|
2752
|
+
it( 'moves the selection onto a visible bar when the selected series is hidden', async () => {
|
|
2753
|
+
const user = userEvent.setup();
|
|
2754
|
+
const toggle = mountWithVisibilityToggle();
|
|
2755
|
+
|
|
2756
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2757
|
+
for ( let i = 0; i < 6; i++ ) {
|
|
2758
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2759
|
+
}
|
|
2760
|
+
expect( screen.getByTestId( 'chart-tooltip-5' ) ).toHaveTextContent( 'Series B' );
|
|
2761
|
+
|
|
2762
|
+
toggle( 'Series B' );
|
|
2763
|
+
|
|
2764
|
+
expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveTextContent( 'Series A' );
|
|
2765
|
+
expect( screen.queryByTestId( 'chart-tooltip-5' ) ).not.toBeInTheDocument();
|
|
2766
|
+
|
|
2767
|
+
await user.keyboard( '{ArrowLeft}' );
|
|
2768
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series A' );
|
|
2769
|
+
} );
|
|
2770
|
+
|
|
2771
|
+
it( 'leaves focus on the legend item that hid the selected series', async () => {
|
|
2772
|
+
const user = userEvent.setup();
|
|
2773
|
+
|
|
2774
|
+
renderWithTheme( {
|
|
2775
|
+
withTooltips: true,
|
|
2776
|
+
showLegend: true,
|
|
2777
|
+
legend: { interactive: true },
|
|
2778
|
+
chartId: 'test-hide-from-legend-while-navigating',
|
|
2779
|
+
data: [
|
|
2780
|
+
{
|
|
2781
|
+
label: 'Series A',
|
|
2782
|
+
data: [
|
|
2783
|
+
{ label: 'Jan', value: 10 },
|
|
2784
|
+
{ label: 'Feb', value: 20 },
|
|
2785
|
+
{ label: 'Mar', value: 30 },
|
|
2786
|
+
],
|
|
2787
|
+
options: {},
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
label: 'Series B',
|
|
2791
|
+
data: [
|
|
2792
|
+
{ label: 'Jan', value: 15 },
|
|
2793
|
+
{ label: 'Feb', value: 25 },
|
|
2794
|
+
{ label: 'Mar', value: 35 },
|
|
2795
|
+
],
|
|
2796
|
+
options: {},
|
|
2797
|
+
},
|
|
2798
|
+
],
|
|
2799
|
+
} );
|
|
2800
|
+
|
|
2801
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2802
|
+
for ( let i = 0; i < 6; i++ ) {
|
|
2803
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
const seriesBToggle = screen.getByRole( 'button', { name: /Series B: visible/i } );
|
|
2807
|
+
await user.click( seriesBToggle );
|
|
2808
|
+
|
|
2809
|
+
expect( seriesBToggle ).toHaveFocus();
|
|
2810
|
+
expect( screen.queryAllByTestId( /^chart-tooltip-/ ) ).toHaveLength( 0 );
|
|
2811
|
+
// Clearing the selection drops the keyboard test id, so assert on the role too:
|
|
2812
|
+
// visx's own hide is debounced, and the stale datum repaints as a different series.
|
|
2813
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
2814
|
+
} );
|
|
2815
|
+
|
|
2816
|
+
// The point count is unchanged by a one-for-one swap, so only the visible set reveals it.
|
|
2817
|
+
it( 'clears the selection when a swap keeps the count but changes which series are visible', async () => {
|
|
2818
|
+
const user = userEvent.setup();
|
|
2819
|
+
let context: GlobalChartsContextValue;
|
|
2820
|
+
const Grab = () => {
|
|
2821
|
+
context = useGlobalChartsContext();
|
|
2822
|
+
return null;
|
|
2823
|
+
};
|
|
2824
|
+
|
|
2825
|
+
render(
|
|
2826
|
+
<GlobalChartsProvider>
|
|
2827
|
+
<Grab />
|
|
2828
|
+
<BarChartUnresponsive
|
|
2829
|
+
width={ 500 }
|
|
2830
|
+
height={ 300 }
|
|
2831
|
+
withTooltips
|
|
2832
|
+
chartId="test-equal-count-swap"
|
|
2833
|
+
defaultHiddenSeries={ [ 'Series B' ] }
|
|
2834
|
+
data={ [
|
|
2835
|
+
{
|
|
2836
|
+
label: 'Series A',
|
|
2837
|
+
data: [
|
|
2838
|
+
{ label: 'Jan', value: 10 },
|
|
2839
|
+
{ label: 'Feb', value: 20 },
|
|
2840
|
+
{ label: 'Mar', value: 30 },
|
|
2841
|
+
],
|
|
2842
|
+
options: {},
|
|
2843
|
+
},
|
|
2844
|
+
{
|
|
2845
|
+
label: 'Series B',
|
|
2846
|
+
data: [
|
|
2847
|
+
{ label: 'Jan', value: 15 },
|
|
2848
|
+
{ label: 'Feb', value: 25 },
|
|
2849
|
+
{ label: 'Mar', value: 35 },
|
|
2850
|
+
],
|
|
2851
|
+
options: {},
|
|
2852
|
+
},
|
|
2853
|
+
] }
|
|
2854
|
+
/>
|
|
2855
|
+
</GlobalChartsProvider>
|
|
2856
|
+
);
|
|
2857
|
+
|
|
2858
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
|
|
2859
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
2860
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series A' );
|
|
2861
|
+
|
|
2862
|
+
// Move focus out of the chart, then swap which series is hidden without changing the count.
|
|
2863
|
+
screen.getByRole( 'grid', { name: /bar chart/i } ).blur();
|
|
2864
|
+
act( () => context.setChartHiddenSeries( 'test-equal-count-swap', [ 'Series A' ] ) );
|
|
2865
|
+
|
|
2866
|
+
expect( screen.queryAllByTestId( /^chart-tooltip-/ ) ).toHaveLength( 0 );
|
|
2867
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
2868
|
+
} );
|
|
2869
|
+
|
|
2870
|
+
it( 'stops at the last visible slot on a standard chart', async () => {
|
|
2871
|
+
const user = userEvent.setup();
|
|
2872
|
+
|
|
2873
|
+
renderWithTheme( {
|
|
2874
|
+
withTooltips: true,
|
|
2875
|
+
showLegend: true,
|
|
2876
|
+
legend: { interactive: true },
|
|
2877
|
+
chartId: 'test-hide-primary-standard',
|
|
2878
|
+
data: [
|
|
2879
|
+
{
|
|
2880
|
+
label: 'Series A',
|
|
2881
|
+
data: [
|
|
2882
|
+
{ label: 'Jan', value: 10 },
|
|
2883
|
+
{ label: 'Feb', value: 20 },
|
|
2884
|
+
{ label: 'Mar', value: 30 },
|
|
2885
|
+
],
|
|
2886
|
+
options: {},
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
label: 'Series B',
|
|
2890
|
+
data: [
|
|
2891
|
+
{ label: 'Jan', value: 15 },
|
|
2892
|
+
{ label: 'Feb', value: 25 },
|
|
2893
|
+
{ label: 'Mar', value: 35 },
|
|
2894
|
+
],
|
|
2895
|
+
options: {},
|
|
2896
|
+
},
|
|
2897
|
+
],
|
|
2898
|
+
} );
|
|
2899
|
+
|
|
2900
|
+
await user.click( screen.getByRole( 'button', { name: /Series B: visible/i } ) );
|
|
2901
|
+
|
|
2902
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
2903
|
+
chart.focus();
|
|
2904
|
+
|
|
2905
|
+
for ( let i = 0; i < 5; i++ ) {
|
|
2906
|
+
await user.keyboard( '{ArrowRight}' );
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
const tooltips = screen.queryAllByTestId( /^chart-tooltip-/ );
|
|
2910
|
+
expect( tooltips ).toHaveLength( 1 );
|
|
2911
|
+
expect( screen.getByTestId( 'chart-tooltip-2' ) ).toBeInTheDocument();
|
|
2912
|
+
expect( tooltips[ 0 ] ).toHaveTextContent( 'Series A' );
|
|
2913
|
+
expect( tooltips[ 0 ] ).not.toHaveTextContent( 'Series B' );
|
|
2914
|
+
expect( screen.queryByTestId( 'chart-tooltip-3' ) ).not.toBeInTheDocument();
|
|
2915
|
+
} );
|
|
2916
|
+
} );
|
|
2353
2917
|
} );
|
|
@@ -15,8 +15,10 @@ import type { AxisRendererProps, AxisScale } from '@visx/axis';
|
|
|
15
15
|
import type { AnyD3Scale } from '@visx/scale';
|
|
16
16
|
import type { ComponentType, FC } from 'react';
|
|
17
17
|
|
|
18
|
-
export interface BarListChartProps
|
|
19
|
-
|
|
18
|
+
export interface BarListChartProps extends Exclude<
|
|
19
|
+
BarChartProps,
|
|
20
|
+
'orientation' | 'size' | 'gridVisibility'
|
|
21
|
+
> {
|
|
20
22
|
options?: {
|
|
21
23
|
/**
|
|
22
24
|
* Scale for the y axis. Exclude the type property.
|
|
@@ -89,7 +91,7 @@ export interface RenderValueProps {
|
|
|
89
91
|
const getScaleBandwidth = < Scale extends AxisScale >( scale?: Scale ) => {
|
|
90
92
|
// Broaden type before using 'xxx' in s as typeguard.
|
|
91
93
|
const s = scale as AxisScale;
|
|
92
|
-
return s && 'bandwidth' in s ? s?.bandwidth() ?? 0 : 0;
|
|
94
|
+
return s && 'bandwidth' in s ? ( s?.bandwidth() ?? 0 ) : 0;
|
|
93
95
|
};
|
|
94
96
|
|
|
95
97
|
const DefaultLabelComponent = ( { textProps, x, y, label, formatter } ) => {
|