@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,418 +1,335 @@
|
|
|
1
|
-
import { render, screen
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const BAND_PARAMS = [
|
|
33
|
-
{ key: 'bars', index: 0, datum: BAND_DATA[ 0 ], svgPoint: { x: 20, y: 90 } },
|
|
34
|
-
] as unknown as EventHandlerParams< Datum >[];
|
|
35
|
-
|
|
36
|
-
// The pointer sits on A's datum, 25px above B's, so A is the nearest datum.
|
|
37
|
-
const BOTH_SERIES: EventHandlerParams< Datum >[] = [
|
|
38
|
-
{
|
|
39
|
-
key: 'A',
|
|
40
|
-
index: 1,
|
|
41
|
-
datum: SERIES_A[ 1 ],
|
|
42
|
-
svgPoint: { x: 100, y: 50 },
|
|
43
|
-
distanceX: 0,
|
|
44
|
-
distanceY: 0,
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
key: 'B',
|
|
48
|
-
index: 1,
|
|
49
|
-
datum: SERIES_B[ 1 ],
|
|
50
|
-
svgPoint: { x: 100, y: 50 },
|
|
51
|
-
distanceX: 0,
|
|
52
|
-
distanceY: 25,
|
|
53
|
-
},
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
// Opens the tooltip the way a pointer event would, once the series have registered.
|
|
57
|
-
const OpenTooltip = ( { params }: { params: EventHandlerParams< Datum >[] } ) => {
|
|
58
|
-
const tooltipContext = useContext( TooltipContext );
|
|
59
|
-
useEffect( () => {
|
|
60
|
-
params.forEach( p => tooltipContext?.showTooltip( p ) );
|
|
61
|
-
// The context object changes identity on every tooltip update; re-running would loop.
|
|
62
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
-
}, [] );
|
|
64
|
-
return null;
|
|
65
|
-
};
|
|
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 { AccessibleTooltip, XYChartTooltip, useKeyboardNavigation } from '../xy-chart-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
|
+
);
|
|
66
32
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
const NO_MARGIN = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
72
|
-
|
|
73
|
-
// 200x100 chart; with no margin x maps 0..10 to 0..200px and y maps 0..100 to 100..0px.
|
|
74
|
-
const renderChart = (
|
|
75
|
-
tooltipProps: Partial< XyChartTooltipProps< Datum > > = {},
|
|
76
|
-
params: EventHandlerParams< Datum >[] = [
|
|
77
|
-
{ key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
|
|
78
|
-
],
|
|
79
|
-
margin = NO_MARGIN,
|
|
80
|
-
theme?: XYChartTheme
|
|
81
|
-
) =>
|
|
82
|
-
render(
|
|
83
|
-
<div data-testid="wrapper" style={ { position: 'relative' } }>
|
|
84
|
-
<XYChart
|
|
85
|
-
width={ 200 }
|
|
86
|
-
height={ 100 }
|
|
87
|
-
margin={ margin }
|
|
88
|
-
theme={ theme }
|
|
89
|
-
xScale={ { type: 'linear', domain: [ 0, 10 ] } }
|
|
90
|
-
yScale={ { type: 'linear', domain: [ 0, 100 ] } }
|
|
91
|
-
>
|
|
92
|
-
<LineSeries dataKey="A" data={ SERIES_A } xAccessor={ xAccessor } yAccessor={ yAccessor } />
|
|
93
|
-
<LineSeries dataKey="B" data={ SERIES_B } xAccessor={ xAccessor } yAccessor={ yAccessor } />
|
|
94
|
-
<XyChartTooltip
|
|
95
|
-
renderTooltip={ renderTooltip }
|
|
96
|
-
data-testid="tooltip-box"
|
|
97
|
-
{ ...tooltipProps }
|
|
98
|
-
/>
|
|
99
|
-
<OpenTooltip params={ params } />
|
|
100
|
-
</XYChart>
|
|
101
|
-
</div>
|
|
33
|
+
const scoped: ReactNode = scope ? (
|
|
34
|
+
<ChartScopeContext.Provider value={ scope }>{ chart }</ChartScopeContext.Provider>
|
|
35
|
+
) : (
|
|
36
|
+
chart
|
|
102
37
|
);
|
|
103
38
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
[ 'bounded', {} ],
|
|
107
|
-
[ 'unbounded', { detectBounds: false } ],
|
|
108
|
-
[ 'below-axis', { tooltipPlacement: 'below-axis' as const } ],
|
|
109
|
-
] )( 'draws the %s box on the package surface, with no visx box styles', async ( _, props ) => {
|
|
110
|
-
renderChart( props );
|
|
111
|
-
const box = await screen.findByTestId( 'tooltip-box' );
|
|
112
|
-
expect( box ).toHaveClass( 'surface' );
|
|
113
|
-
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'box-shadow' ) );
|
|
114
|
-
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background-color' ) );
|
|
115
|
-
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'padding' ) );
|
|
116
|
-
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'border-radius' ) );
|
|
117
|
-
} );
|
|
39
|
+
return render( <GlobalChartsProvider>{ scoped }</GlobalChartsProvider> );
|
|
40
|
+
};
|
|
118
41
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const box = await screen.findByTestId( 'tooltip-box' );
|
|
122
|
-
expect( box ).not.toHaveClass( 'surface' );
|
|
123
|
-
} );
|
|
42
|
+
const openTooltip = async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
124
44
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const box = await screen.findByTestId( 'tooltip-box' );
|
|
128
|
-
expect( box ).not.toHaveClass( 'surface' );
|
|
129
|
-
} );
|
|
45
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
46
|
+
await user.keyboard( '{ArrowRight}' );
|
|
130
47
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const box = await screen.findByTestId( 'tooltip-box' );
|
|
134
|
-
expect( box ).toHaveClass( 'surface', 'extra' );
|
|
135
|
-
} );
|
|
48
|
+
return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
49
|
+
};
|
|
136
50
|
|
|
137
|
-
|
|
138
|
-
|
|
51
|
+
describe( 'XYChartTooltip', () => {
|
|
52
|
+
it( 'keeps AccessibleTooltip as an alias', () => {
|
|
53
|
+
expect( AccessibleTooltip ).toBe( XYChartTooltip );
|
|
54
|
+
} );
|
|
139
55
|
|
|
140
|
-
|
|
56
|
+
// The stroke is read at the scope element, not inherited through the DOM; see TOKENS.md#the-svg-bridge.
|
|
57
|
+
it( 'reads the grid role from the scope element', async () => {
|
|
58
|
+
const scope = document.createElement( 'div' );
|
|
59
|
+
scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
|
|
60
|
+
document.body.appendChild( scope );
|
|
141
61
|
|
|
142
|
-
|
|
143
|
-
expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
|
|
144
|
-
// A body-level portal would sit outside the render root.
|
|
145
|
-
expect( container ).toContainElement( box );
|
|
146
|
-
} );
|
|
62
|
+
renderChart( scope );
|
|
147
63
|
|
|
148
|
-
|
|
149
|
-
renderChart();
|
|
64
|
+
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
|
|
150
65
|
|
|
151
|
-
|
|
152
|
-
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
|
|
153
|
-
transform: 'translate(133px, 55px)',
|
|
154
|
-
} );
|
|
66
|
+
document.body.removeChild( scope );
|
|
155
67
|
} );
|
|
156
68
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
69
|
+
it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
|
|
70
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
71
|
+
try {
|
|
72
|
+
renderChart();
|
|
73
|
+
await openTooltip();
|
|
74
|
+
|
|
75
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
76
|
+
expect( focus ).toHaveBeenLastCalledWith();
|
|
77
|
+
} finally {
|
|
78
|
+
focus.mockRestore();
|
|
79
|
+
}
|
|
162
80
|
} );
|
|
163
81
|
|
|
164
|
-
|
|
165
|
-
|
|
82
|
+
it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
|
|
83
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
84
|
+
try {
|
|
85
|
+
renderChart( undefined, 'below-axis' );
|
|
86
|
+
await openTooltip();
|
|
166
87
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
);
|
|
88
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
89
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
90
|
+
} finally {
|
|
91
|
+
focus.mockRestore();
|
|
92
|
+
}
|
|
173
93
|
} );
|
|
174
94
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
: rect;
|
|
184
|
-
} );
|
|
185
|
-
|
|
95
|
+
it.each( [
|
|
96
|
+
[ 'Tab', '{Tab}' ],
|
|
97
|
+
[ 'Escape', '{Escape}' ],
|
|
98
|
+
] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
|
|
99
|
+
const user = userEvent.setup();
|
|
100
|
+
renderChart( undefined, 'below-axis' );
|
|
101
|
+
await openTooltip();
|
|
102
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
186
103
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
tooltipPlacement: 'below-axis',
|
|
190
|
-
snapTooltipToDatumY: true,
|
|
191
|
-
showDatumGlyph: true,
|
|
192
|
-
showVerticalCrosshair: true,
|
|
193
|
-
showHorizontalCrosshair: true,
|
|
194
|
-
},
|
|
195
|
-
undefined,
|
|
196
|
-
{ top: 10, right: 20, bottom: 30, left: 40 }
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
const box = await screen.findByTestId( 'tooltip-box' );
|
|
200
|
-
expect( box ).toHaveStyle( { transform: 'translate(70px, 106px)' } );
|
|
201
|
-
expect( screen.getByTestId( 'wrapper' ) ).toContainElement( box );
|
|
202
|
-
expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveStyle( {
|
|
203
|
-
left: '34px',
|
|
204
|
-
top: '-6px',
|
|
205
|
-
} );
|
|
206
|
-
expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
|
|
207
|
-
'transform',
|
|
208
|
-
'translate(110, 40)'
|
|
209
|
-
);
|
|
210
|
-
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).toHaveAttribute(
|
|
211
|
-
'y2',
|
|
212
|
-
'70'
|
|
213
|
-
);
|
|
214
|
-
expect( screen.getByTestId( 'xy-chart-tooltip-crosshair-horizontal' ) ).toHaveAttribute(
|
|
215
|
-
'y1',
|
|
216
|
-
'40'
|
|
217
|
-
);
|
|
104
|
+
await user.keyboard( keys );
|
|
105
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
218
106
|
} finally {
|
|
219
|
-
|
|
107
|
+
focus.mockRestore();
|
|
220
108
|
}
|
|
221
109
|
} );
|
|
222
110
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
);
|
|
238
|
-
expect( glyphA ).toHaveAttribute( 'cx', '0' );
|
|
239
|
-
expect( glyphA ).toHaveAttribute( 'cy', '0' );
|
|
240
|
-
expect( glyphB ).toHaveAttribute( 'cx', '0' );
|
|
241
|
-
expect( glyphB ).toHaveAttribute( 'cy', '0' );
|
|
242
|
-
// Drawn into the chart SVG, not portaled out as HTML.
|
|
243
|
-
expect( glyphA ).toBeInstanceOf( SVGElement );
|
|
111
|
+
it.each( [
|
|
112
|
+
[ 'Tab', '{Tab}' ],
|
|
113
|
+
[ 'Escape', '{Escape}' ],
|
|
114
|
+
] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
|
|
115
|
+
const user = userEvent.setup();
|
|
116
|
+
renderChart();
|
|
117
|
+
await openTooltip();
|
|
118
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
119
|
+
try {
|
|
120
|
+
await user.keyboard( keys );
|
|
121
|
+
expect( focus ).toHaveBeenCalledWith();
|
|
122
|
+
} finally {
|
|
123
|
+
focus.mockRestore();
|
|
124
|
+
}
|
|
244
125
|
} );
|
|
245
126
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
await waitFor( () => expect( renderGlyph ).toHaveBeenCalledTimes( 2 ) );
|
|
251
|
-
|
|
252
|
-
const calls = renderGlyph.mock.calls as unknown as Array<
|
|
253
|
-
[ { key: string; x: number; y: number; isNearestDatum: boolean; datum: Datum } ]
|
|
254
|
-
>;
|
|
255
|
-
const byKey = Object.fromEntries( calls.map( ( [ p ] ) => [ p.key, p ] ) );
|
|
256
|
-
// A renderer written against visx's Tooltip ignores x and y, so both stay 0.
|
|
257
|
-
expect( byKey.A ).toMatchObject( { x: 0, y: 0, datum: SERIES_A[ 1 ] } );
|
|
258
|
-
expect( byKey.B ).toMatchObject( { x: 0, y: 0, datum: SERIES_B[ 1 ] } );
|
|
259
|
-
expect( byKey.A.isNearestDatum ).toBe( true );
|
|
260
|
-
expect( byKey.B.isNearestDatum ).toBe( false );
|
|
127
|
+
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
128
|
+
renderChart();
|
|
129
|
+
|
|
130
|
+
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
|
|
261
131
|
} );
|
|
132
|
+
} );
|
|
262
133
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
134
|
+
// Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
|
|
135
|
+
const NavigationHarness = ( {
|
|
136
|
+
totalPoints,
|
|
137
|
+
trackBlur = false,
|
|
138
|
+
}: {
|
|
139
|
+
totalPoints: number;
|
|
140
|
+
trackBlur?: boolean;
|
|
141
|
+
} ) => {
|
|
142
|
+
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
143
|
+
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
144
|
+
const chartRef = useRef< HTMLDivElement >( null );
|
|
145
|
+
const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
|
|
146
|
+
selectedIndex,
|
|
147
|
+
setSelectedIndex,
|
|
148
|
+
isNavigating,
|
|
149
|
+
setIsNavigating,
|
|
150
|
+
chartRef,
|
|
151
|
+
totalPoints,
|
|
152
|
+
} );
|
|
153
|
+
const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
|
|
154
|
+
const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<div
|
|
159
|
+
role="grid"
|
|
160
|
+
aria-label="Harness"
|
|
161
|
+
tabIndex={ 0 }
|
|
162
|
+
onKeyDown={ onChartKeyDown }
|
|
163
|
+
onBlur={ trackBlur ? onChartBlur : undefined }
|
|
164
|
+
>
|
|
165
|
+
<div ref={ chartRef } data-testid="selected-index">
|
|
166
|
+
{ selectedIndex ?? 'none' }
|
|
167
|
+
<button type="button">Inside</button>
|
|
168
|
+
</div>
|
|
169
|
+
<div data-testid="point-4" onPointerMove={ moveToFour } />
|
|
170
|
+
<div data-testid="point-1" onPointerMove={ moveToOne } />
|
|
288
171
|
</div>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
172
|
+
<button type="button">Outside</button>
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
describe( 'useKeyboardNavigation', () => {
|
|
178
|
+
const navigate = async ( presses: number ) => {
|
|
179
|
+
const user = userEvent.setup();
|
|
180
|
+
const view = render( <NavigationHarness totalPoints={ 6 } /> );
|
|
181
|
+
|
|
182
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
183
|
+
for ( let i = 0; i < presses; i++ ) {
|
|
184
|
+
await user.keyboard( '{ArrowRight}' );
|
|
185
|
+
}
|
|
186
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
|
|
187
|
+
|
|
188
|
+
return { user, view };
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
|
|
192
|
+
const { view } = await navigate( 6 );
|
|
193
|
+
|
|
194
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
195
|
+
|
|
196
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
197
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
301
198
|
} );
|
|
302
199
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
undefined,
|
|
312
|
-
{ top: 10, right: 20, bottom: 30, left: 40 }
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
const vertical = await screen.findByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
316
|
-
const horizontal = await screen.findByTestId( 'xy-chart-tooltip-crosshair-horizontal' );
|
|
317
|
-
|
|
318
|
-
// Plot area is 140x60 at (40, 10); datum (5, 50) sits at its centre, (110, 40).
|
|
319
|
-
expect( vertical ).toHaveAttribute( 'x1', '110' );
|
|
320
|
-
expect( vertical ).toHaveAttribute( 'y1', '10' );
|
|
321
|
-
expect( vertical ).toHaveAttribute( 'y2', '70' );
|
|
322
|
-
expect( horizontal ).toHaveAttribute( 'y1', '40' );
|
|
323
|
-
expect( horizontal ).toHaveAttribute( 'x1', '40' );
|
|
324
|
-
expect( horizontal ).toHaveAttribute( 'x2', '180' );
|
|
200
|
+
// In range rather than past the end: the effect reconciles on any count change, not only an overflow.
|
|
201
|
+
it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
|
|
202
|
+
const { view } = await navigate( 2 );
|
|
203
|
+
|
|
204
|
+
screen.getByRole( 'button', { name: 'Outside' } ).focus();
|
|
205
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
206
|
+
|
|
207
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
325
208
|
} );
|
|
326
209
|
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
};
|
|
336
|
-
renderChart( {
|
|
337
|
-
snapTooltipToDatumX: true,
|
|
338
|
-
snapTooltipToDatumY: true,
|
|
339
|
-
showVerticalCrosshair: true,
|
|
340
|
-
showHorizontalCrosshair: true,
|
|
341
|
-
verticalCrosshairStyle: consumerStyle,
|
|
342
|
-
horizontalCrosshairStyle: consumerStyle,
|
|
343
|
-
} );
|
|
344
|
-
|
|
345
|
-
for ( const direction of [ 'vertical', 'horizontal' ] ) {
|
|
346
|
-
const guide = await screen.findByTestId( `xy-chart-tooltip-crosshair-${ direction }` );
|
|
347
|
-
expect( guide ).toHaveAttribute( 'stroke', 'purple' );
|
|
348
|
-
expect( guide ).toHaveAttribute( 'stroke-width', '8' );
|
|
349
|
-
expect( guide ).toHaveClass( 'custom-crosshair' );
|
|
350
|
-
expect( guide ).toHaveStyle( { 'stroke-opacity': '0.25', transform: 'none' } );
|
|
351
|
-
expect( guide ).not.toHaveAttribute( 'transform' );
|
|
352
|
-
expect( guide ).toHaveAttribute( 'x1', direction === 'vertical' ? '100' : '0' );
|
|
353
|
-
expect( guide ).toHaveAttribute( 'x2', direction === 'vertical' ? '100' : '200' );
|
|
354
|
-
expect( guide ).toHaveAttribute( 'y1', direction === 'vertical' ? '0' : '50' );
|
|
355
|
-
expect( guide ).toHaveAttribute( 'y2', direction === 'vertical' ? '100' : '50' );
|
|
356
|
-
}
|
|
210
|
+
it( 'returns focus to the grid on Escape when there are no points', async () => {
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
render( <NavigationHarness totalPoints={ 0 } /> );
|
|
213
|
+
|
|
214
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
215
|
+
await user.keyboard( '{Escape}' );
|
|
216
|
+
|
|
217
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
357
218
|
} );
|
|
358
219
|
|
|
359
|
-
|
|
360
|
-
const {
|
|
361
|
-
|
|
362
|
-
|
|
220
|
+
it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
|
|
221
|
+
const { view } = await navigate( 2 );
|
|
222
|
+
|
|
223
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
224
|
+
view.rerender( <NavigationHarness totalPoints={ 0 } /> );
|
|
363
225
|
|
|
364
|
-
|
|
365
|
-
|
|
226
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
227
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
366
228
|
} );
|
|
367
229
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
|
|
230
|
+
it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
|
|
231
|
+
const { user } = await navigate( 2 );
|
|
232
|
+
|
|
233
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
234
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
235
|
+
|
|
236
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
237
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
238
|
+
|
|
239
|
+
await user.keyboard( '{ArrowRight}' );
|
|
240
|
+
|
|
241
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
|
|
242
|
+
} );
|
|
377
243
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
} );
|
|
244
|
+
it( 'continues from the last point the pointer reached after taking over', async () => {
|
|
245
|
+
const { user } = await navigate( 2 );
|
|
246
|
+
|
|
247
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
248
|
+
await user.hover( screen.getByTestId( 'point-1' ) );
|
|
249
|
+
await user.keyboard( '{ArrowRight}' );
|
|
250
|
+
|
|
251
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
384
252
|
} );
|
|
385
253
|
|
|
386
|
-
|
|
387
|
-
|
|
254
|
+
it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
|
|
255
|
+
const user = userEvent.setup();
|
|
256
|
+
render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
|
|
257
|
+
const grid = screen.getByRole( 'grid', { name: 'Harness' } );
|
|
388
258
|
|
|
389
|
-
|
|
259
|
+
act( () => grid.focus() );
|
|
260
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
261
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
262
|
+
act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
|
|
263
|
+
act( () => grid.focus() );
|
|
264
|
+
await user.keyboard( '{ArrowRight}' );
|
|
390
265
|
|
|
391
|
-
expect(
|
|
392
|
-
expect( box ).not.toHaveAttribute( 'debounce' );
|
|
266
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
393
267
|
} );
|
|
394
268
|
|
|
395
|
-
|
|
396
|
-
|
|
269
|
+
it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
|
|
270
|
+
const { user } = await navigate( 2 );
|
|
271
|
+
const outside = screen.getByRole( 'button', { name: 'Outside' } );
|
|
272
|
+
|
|
273
|
+
outside.focus();
|
|
274
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
397
275
|
|
|
398
|
-
|
|
399
|
-
expect(
|
|
276
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
277
|
+
expect( outside ).toHaveFocus();
|
|
400
278
|
} );
|
|
401
279
|
|
|
402
|
-
|
|
403
|
-
|
|
280
|
+
it( 'ignores the pointer when there is no keyboard selection', async () => {
|
|
281
|
+
const user = userEvent.setup();
|
|
282
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
283
|
+
|
|
284
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
285
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
286
|
+
await user.keyboard( '{ArrowRight}' );
|
|
287
|
+
|
|
288
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
// The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
|
|
292
|
+
const recordPageKeyDown = () => {
|
|
293
|
+
const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
|
|
294
|
+
document.addEventListener( 'keydown', onKeyDown );
|
|
295
|
+
return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
it( 'leaves keys it does not handle available to the page', async () => {
|
|
299
|
+
const user = userEvent.setup();
|
|
300
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
301
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
305
|
+
await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
|
|
404
306
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
307
|
+
expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
|
|
308
|
+
for ( const [ event ] of onKeyDown.mock.calls ) {
|
|
309
|
+
expect( event.defaultPrevented ).toBe( false );
|
|
310
|
+
}
|
|
311
|
+
} finally {
|
|
312
|
+
stop();
|
|
313
|
+
}
|
|
409
314
|
} );
|
|
410
315
|
|
|
411
|
-
|
|
412
|
-
|
|
316
|
+
// `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
|
|
317
|
+
it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
|
|
318
|
+
const user = userEvent.setup();
|
|
319
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
320
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
324
|
+
await user.keyboard( '{Escape}' );
|
|
413
325
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
326
|
+
expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
|
|
327
|
+
|
|
328
|
+
await user.keyboard( '{ArrowRight}{Escape}' );
|
|
329
|
+
|
|
330
|
+
expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
|
|
331
|
+
} finally {
|
|
332
|
+
stop();
|
|
333
|
+
}
|
|
417
334
|
} );
|
|
418
335
|
} );
|