@automattic/charts 4.7.0 → 4.8.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 +16 -0
- package/TOKENS.md +6 -3
- package/dist/index.cjs +212 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +10 -9
- package/dist/index.d.cts +60 -20
- package/dist/index.d.ts +60 -20
- package/dist/index.js +212 -97
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- package/src/charts/bar-chart/bar-chart.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +20 -14
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +6 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +39 -29
- package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +2 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +99 -7
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
- package/src/charts/heatmap-chart/types.ts +4 -3
- package/src/charts/line-chart/line-chart.tsx +2 -2
- package/src/charts/pie-chart/pie-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +6 -8
- package/src/components/tooltip/base-tooltip.tsx +31 -32
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +282 -365
- package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
- package/src/components/tooltip/tooltip-box.tsx +43 -0
- package/src/components/tooltip/types.ts +7 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/index.ts +8 -3
- package/src/providers/chart-context/global-charts-provider.tsx +5 -14
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope.scss +1 -0
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
import { act, render, screen } from '@testing-library/react';
|
|
2
|
-
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useCallback, useRef, useState } from 'react';
|
|
4
|
-
import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
|
|
5
|
-
import { GlobalChartsProvider } from '../../../providers';
|
|
6
|
-
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
7
|
-
import { useKeyboardNavigation } from '../accessible-tooltip';
|
|
8
|
-
import type { ReactNode } from 'react';
|
|
9
|
-
|
|
10
|
-
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
11
|
-
const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-axis' ) => {
|
|
12
|
-
const chart = (
|
|
13
|
-
<LineChartUnresponsive
|
|
14
|
-
width={ 500 }
|
|
15
|
-
height={ 300 }
|
|
16
|
-
data={ [
|
|
17
|
-
{
|
|
18
|
-
label: 'Series A',
|
|
19
|
-
data: [
|
|
20
|
-
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
21
|
-
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
22
|
-
],
|
|
23
|
-
options: {},
|
|
24
|
-
},
|
|
25
|
-
] }
|
|
26
|
-
withTooltips
|
|
27
|
-
tooltipPlacement={ tooltipPlacement }
|
|
28
|
-
withTooltipCrosshairs={ { showVertical: true } }
|
|
29
|
-
withGradientFill={ false }
|
|
30
|
-
/>
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
const scoped: ReactNode = scope ? (
|
|
34
|
-
<ChartScopeContext.Provider value={ scope }>{ chart }</ChartScopeContext.Provider>
|
|
35
|
-
) : (
|
|
36
|
-
chart
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
return render( <GlobalChartsProvider>{ scoped }</GlobalChartsProvider> );
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const openTooltip = async () => {
|
|
43
|
-
const user = userEvent.setup();
|
|
44
|
-
|
|
45
|
-
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
46
|
-
await user.keyboard( '{ArrowRight}' );
|
|
47
|
-
|
|
48
|
-
return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
describe( 'AccessibleTooltip', () => {
|
|
52
|
-
// The stroke is read at the scope element, not inherited through the DOM; see TOKENS.md#the-svg-bridge.
|
|
53
|
-
it( 'reads the grid role from the scope element', async () => {
|
|
54
|
-
const scope = document.createElement( 'div' );
|
|
55
|
-
scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
|
|
56
|
-
document.body.appendChild( scope );
|
|
57
|
-
|
|
58
|
-
renderChart( scope );
|
|
59
|
-
|
|
60
|
-
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
|
|
61
|
-
|
|
62
|
-
document.body.removeChild( scope );
|
|
63
|
-
} );
|
|
64
|
-
|
|
65
|
-
it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
|
|
66
|
-
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
67
|
-
try {
|
|
68
|
-
renderChart();
|
|
69
|
-
await openTooltip();
|
|
70
|
-
|
|
71
|
-
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
72
|
-
expect( focus ).toHaveBeenLastCalledWith();
|
|
73
|
-
} finally {
|
|
74
|
-
focus.mockRestore();
|
|
75
|
-
}
|
|
76
|
-
} );
|
|
77
|
-
|
|
78
|
-
it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
|
|
79
|
-
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
80
|
-
try {
|
|
81
|
-
renderChart( undefined, 'below-axis' );
|
|
82
|
-
await openTooltip();
|
|
83
|
-
|
|
84
|
-
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
85
|
-
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
86
|
-
} finally {
|
|
87
|
-
focus.mockRestore();
|
|
88
|
-
}
|
|
89
|
-
} );
|
|
90
|
-
|
|
91
|
-
it.each( [
|
|
92
|
-
[ 'Tab', '{Tab}' ],
|
|
93
|
-
[ 'Escape', '{Escape}' ],
|
|
94
|
-
] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
|
|
95
|
-
const user = userEvent.setup();
|
|
96
|
-
renderChart( undefined, 'below-axis' );
|
|
97
|
-
await openTooltip();
|
|
98
|
-
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
99
|
-
try {
|
|
100
|
-
await user.keyboard( keys );
|
|
101
|
-
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
102
|
-
} finally {
|
|
103
|
-
focus.mockRestore();
|
|
104
|
-
}
|
|
105
|
-
} );
|
|
106
|
-
|
|
107
|
-
it.each( [
|
|
108
|
-
[ 'Tab', '{Tab}' ],
|
|
109
|
-
[ 'Escape', '{Escape}' ],
|
|
110
|
-
] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
|
|
111
|
-
const user = userEvent.setup();
|
|
112
|
-
renderChart();
|
|
113
|
-
await openTooltip();
|
|
114
|
-
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
115
|
-
try {
|
|
116
|
-
await user.keyboard( keys );
|
|
117
|
-
expect( focus ).toHaveBeenCalledWith();
|
|
118
|
-
} finally {
|
|
119
|
-
focus.mockRestore();
|
|
120
|
-
}
|
|
121
|
-
} );
|
|
122
|
-
|
|
123
|
-
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
124
|
-
renderChart();
|
|
125
|
-
|
|
126
|
-
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
|
|
127
|
-
} );
|
|
128
|
-
} );
|
|
129
|
-
|
|
130
|
-
// Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
|
|
131
|
-
const NavigationHarness = ( {
|
|
132
|
-
totalPoints,
|
|
133
|
-
trackBlur = false,
|
|
134
|
-
}: {
|
|
135
|
-
totalPoints: number;
|
|
136
|
-
trackBlur?: boolean;
|
|
137
|
-
} ) => {
|
|
138
|
-
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
139
|
-
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
140
|
-
const chartRef = useRef< HTMLDivElement >( null );
|
|
141
|
-
const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
|
|
142
|
-
selectedIndex,
|
|
143
|
-
setSelectedIndex,
|
|
144
|
-
isNavigating,
|
|
145
|
-
setIsNavigating,
|
|
146
|
-
chartRef,
|
|
147
|
-
totalPoints,
|
|
148
|
-
} );
|
|
149
|
-
const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
|
|
150
|
-
const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
|
|
151
|
-
|
|
152
|
-
return (
|
|
153
|
-
<>
|
|
154
|
-
<div
|
|
155
|
-
role="grid"
|
|
156
|
-
aria-label="Harness"
|
|
157
|
-
tabIndex={ 0 }
|
|
158
|
-
onKeyDown={ onChartKeyDown }
|
|
159
|
-
onBlur={ trackBlur ? onChartBlur : undefined }
|
|
160
|
-
>
|
|
161
|
-
<div ref={ chartRef } data-testid="selected-index">
|
|
162
|
-
{ selectedIndex ?? 'none' }
|
|
163
|
-
<button type="button">Inside</button>
|
|
164
|
-
</div>
|
|
165
|
-
<div data-testid="point-4" onPointerMove={ moveToFour } />
|
|
166
|
-
<div data-testid="point-1" onPointerMove={ moveToOne } />
|
|
167
|
-
</div>
|
|
168
|
-
<button type="button">Outside</button>
|
|
169
|
-
</>
|
|
170
|
-
);
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
describe( 'useKeyboardNavigation', () => {
|
|
174
|
-
const navigate = async ( presses: number ) => {
|
|
175
|
-
const user = userEvent.setup();
|
|
176
|
-
const view = render( <NavigationHarness totalPoints={ 6 } /> );
|
|
177
|
-
|
|
178
|
-
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
179
|
-
for ( let i = 0; i < presses; i++ ) {
|
|
180
|
-
await user.keyboard( '{ArrowRight}' );
|
|
181
|
-
}
|
|
182
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
|
|
183
|
-
|
|
184
|
-
return { user, view };
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
|
|
188
|
-
const { view } = await navigate( 6 );
|
|
189
|
-
|
|
190
|
-
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
191
|
-
|
|
192
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
193
|
-
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
194
|
-
} );
|
|
195
|
-
|
|
196
|
-
// In range rather than past the end: the effect reconciles on any count change, not only an overflow.
|
|
197
|
-
it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
|
|
198
|
-
const { view } = await navigate( 2 );
|
|
199
|
-
|
|
200
|
-
screen.getByRole( 'button', { name: 'Outside' } ).focus();
|
|
201
|
-
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
202
|
-
|
|
203
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
204
|
-
} );
|
|
205
|
-
|
|
206
|
-
it( 'returns focus to the grid on Escape when there are no points', async () => {
|
|
207
|
-
const user = userEvent.setup();
|
|
208
|
-
render( <NavigationHarness totalPoints={ 0 } /> );
|
|
209
|
-
|
|
210
|
-
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
211
|
-
await user.keyboard( '{Escape}' );
|
|
212
|
-
|
|
213
|
-
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
214
|
-
} );
|
|
215
|
-
|
|
216
|
-
it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
|
|
217
|
-
const { view } = await navigate( 2 );
|
|
218
|
-
|
|
219
|
-
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
220
|
-
view.rerender( <NavigationHarness totalPoints={ 0 } /> );
|
|
221
|
-
|
|
222
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
223
|
-
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
224
|
-
} );
|
|
225
|
-
|
|
226
|
-
it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
|
|
227
|
-
const { user } = await navigate( 2 );
|
|
228
|
-
|
|
229
|
-
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
230
|
-
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
231
|
-
|
|
232
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
233
|
-
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
234
|
-
|
|
235
|
-
await user.keyboard( '{ArrowRight}' );
|
|
236
|
-
|
|
237
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
|
|
238
|
-
} );
|
|
239
|
-
|
|
240
|
-
it( 'continues from the last point the pointer reached after taking over', async () => {
|
|
241
|
-
const { user } = await navigate( 2 );
|
|
242
|
-
|
|
243
|
-
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
244
|
-
await user.hover( screen.getByTestId( 'point-1' ) );
|
|
245
|
-
await user.keyboard( '{ArrowRight}' );
|
|
246
|
-
|
|
247
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
248
|
-
} );
|
|
249
|
-
|
|
250
|
-
it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
|
|
251
|
-
const user = userEvent.setup();
|
|
252
|
-
render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
|
|
253
|
-
const grid = screen.getByRole( 'grid', { name: 'Harness' } );
|
|
254
|
-
|
|
255
|
-
act( () => grid.focus() );
|
|
256
|
-
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
257
|
-
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
258
|
-
act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
|
|
259
|
-
act( () => grid.focus() );
|
|
260
|
-
await user.keyboard( '{ArrowRight}' );
|
|
261
|
-
|
|
262
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
263
|
-
} );
|
|
264
|
-
|
|
265
|
-
it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
|
|
266
|
-
const { user } = await navigate( 2 );
|
|
267
|
-
const outside = screen.getByRole( 'button', { name: 'Outside' } );
|
|
268
|
-
|
|
269
|
-
outside.focus();
|
|
270
|
-
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
271
|
-
|
|
272
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
273
|
-
expect( outside ).toHaveFocus();
|
|
274
|
-
} );
|
|
275
|
-
|
|
276
|
-
it( 'ignores the pointer when there is no keyboard selection', async () => {
|
|
277
|
-
const user = userEvent.setup();
|
|
278
|
-
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
279
|
-
|
|
280
|
-
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
281
|
-
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
282
|
-
await user.keyboard( '{ArrowRight}' );
|
|
283
|
-
|
|
284
|
-
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
285
|
-
} );
|
|
286
|
-
|
|
287
|
-
// The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
|
|
288
|
-
const recordPageKeyDown = () => {
|
|
289
|
-
const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
|
|
290
|
-
document.addEventListener( 'keydown', onKeyDown );
|
|
291
|
-
return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
it( 'leaves keys it does not handle available to the page', async () => {
|
|
295
|
-
const user = userEvent.setup();
|
|
296
|
-
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
297
|
-
const { onKeyDown, stop } = recordPageKeyDown();
|
|
298
|
-
|
|
299
|
-
try {
|
|
300
|
-
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
301
|
-
await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
|
|
302
|
-
|
|
303
|
-
expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
|
|
304
|
-
for ( const [ event ] of onKeyDown.mock.calls ) {
|
|
305
|
-
expect( event.defaultPrevented ).toBe( false );
|
|
306
|
-
}
|
|
307
|
-
} finally {
|
|
308
|
-
stop();
|
|
309
|
-
}
|
|
310
|
-
} );
|
|
311
|
-
|
|
312
|
-
// `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
|
|
313
|
-
it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
|
|
314
|
-
const user = userEvent.setup();
|
|
315
|
-
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
316
|
-
const { onKeyDown, stop } = recordPageKeyDown();
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
320
|
-
await user.keyboard( '{Escape}' );
|
|
321
|
-
|
|
322
|
-
expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
|
|
323
|
-
|
|
324
|
-
await user.keyboard( '{ArrowRight}{Escape}' );
|
|
325
|
-
|
|
326
|
-
expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
|
|
327
|
-
} finally {
|
|
328
|
-
stop();
|
|
329
|
-
}
|
|
330
|
-
} );
|
|
331
|
-
} );
|