@automattic/charts 4.7.0 → 4.9.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +834 -321
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +68 -17
  6. package/dist/index.d.cts +102 -28
  7. package/dist/index.d.ts +102 -28
  8. package/dist/index.js +838 -327
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +7 -4
  12. package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  15. package/src/charts/area-chart/types.ts +2 -0
  16. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  17. package/src/charts/bar-chart/bar-chart.tsx +25 -16
  18. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  19. package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
  20. package/src/charts/bar-chart/types.ts +2 -0
  21. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
  26. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  27. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  28. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  29. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
  30. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  31. package/src/charts/heatmap-chart/private/index.ts +6 -3
  32. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  33. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  34. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  35. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +231 -8
  36. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  37. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  38. package/src/charts/heatmap-chart/types.ts +6 -3
  39. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  40. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  41. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  42. package/src/charts/leaderboard-chart/types.ts +6 -1
  43. package/src/charts/line-chart/line-chart.tsx +7 -4
  44. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  46. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  47. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  48. package/src/charts/line-chart/types.ts +5 -3
  49. package/src/charts/pie-chart/pie-chart.tsx +153 -143
  50. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
  53. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  55. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  57. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  58. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  59. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  60. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  61. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  62. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  63. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  64. package/src/charts/private/test/label-text-color.test.ts +6 -8
  65. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  66. package/src/components/tooltip/base-tooltip.tsx +31 -32
  67. package/src/components/tooltip/index.ts +3 -1
  68. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  69. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  70. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  71. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  72. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  74. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
  75. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  76. package/src/components/tooltip/tooltip-box.tsx +43 -0
  77. package/src/components/tooltip/types.ts +7 -1
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  79. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  80. package/src/hooks/use-chart-margin.tsx +21 -18
  81. package/src/index.ts +8 -3
  82. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  83. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  84. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  85. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  86. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  87. package/src/styles/chart-scope.scss +1 -0
  88. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  89. package/src/types.ts +18 -2
  90. package/src/utils/test/date-formatting.test.ts +0 -10
  91. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  92. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -1,418 +1,336 @@
1
- import { render, screen, waitFor } from '@testing-library/react';
2
- import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
3
- import { useContext, useEffect } from 'react';
4
- import { XyChartTooltip } from '../xy-chart-tooltip';
5
- import type { XyChartTooltipProps } from '../../../visx/types';
6
- import type { EventHandlerParams, XYChartTheme } from '@visx/xychart';
7
-
8
- type Datum = { x: number; y: number };
9
-
10
- const SERIES_A: Datum[] = [
11
- { x: 0, y: 0 },
12
- { x: 5, y: 50 },
13
- { x: 10, y: 100 },
14
- ];
15
- const SERIES_B: Datum[] = [
16
- { x: 0, y: 100 },
17
- { x: 5, y: 25 },
18
- { x: 10, y: 0 },
19
- ];
20
-
21
- const xAccessor = ( d: Datum ) => d.x;
22
- const yAccessor = ( d: Datum ) => d.y;
23
-
24
- type BandDatum = { x: string; y: number };
25
- const BAND_DATA: BandDatum[] = [
26
- { x: 'a', y: 50 },
27
- { x: 'b', y: 80 },
28
- ];
29
- const bandXAccessor = ( d: BandDatum ) => d.x;
30
- const bandYAccessor = ( d: BandDatum ) => d.y;
31
- const renderBarTooltip = () => <span>bar</span>;
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 renderTooltip: XyChartTooltipProps< Datum >[ 'renderTooltip' ] = ( { tooltipData } ) => (
68
- <span>{ `nearest:${ tooltipData?.nearestDatum?.key ?? '' }` }</span>
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
- describe( 'XyChartTooltip', () => {
105
- test.each( [
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
- test( 'strips the surface from an unbounded box when unstyled', async () => {
120
- renderChart( { detectBounds: false, unstyled: true } );
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
- test( 'strips the surface from a bounded box when unstyled', async () => {
126
- renderChart( { unstyled: true } );
127
- const box = await screen.findByTestId( 'tooltip-box' );
128
- expect( box ).not.toHaveClass( 'surface' );
129
- } );
45
+ screen.getByRole( 'application', { name: /line chart/i } ).focus();
46
+ await user.keyboard( '{ArrowRight}' );
130
47
 
131
- test( 'keeps a caller class beside the surface on an unbounded box', async () => {
132
- renderChart( { detectBounds: false, className: 'extra' } );
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
- test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
138
- const { container } = renderChart();
51
+ describe( 'XYChartTooltip', () => {
52
+ it( 'keeps AccessibleTooltip as an alias', () => {
53
+ expect( AccessibleTooltip ).toBe( XYChartTooltip );
54
+ } );
139
55
 
140
- const box = await screen.findByTestId( 'tooltip-box' );
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
- expect( box ).toHaveTextContent( 'nearest:A' );
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
- test( 'places the box at the pointer position when snapping is off', async () => {
149
- renderChart();
64
+ await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
150
65
 
151
- // TooltipWithBounds moves the box through a transform from (0, 0), after its 10px offsets.
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
- test( 'pins beside placement to the explicit SVG top anchor', async () => {
158
- renderChart( { tooltipPlacement: 'beside', tooltipAnchorTop: 20, snapTooltipToDatumY: true } );
159
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
160
- transform: 'translate(133px, 20px)',
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
- test( 'snaps the box to the nearest datum', async () => {
165
- renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
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
- // Datum (5, 50) sits at (100px, 50px); the default 10px offsets apply after snapping.
168
- await waitFor( () =>
169
- expect( screen.getByTestId( 'tooltip-box' ) ).toHaveStyle( {
170
- transform: 'translate(110px, 60px)',
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
- test( 'anchors below the x-axis label band without moving datum glyphs or crosshairs', async () => {
176
- const originalRect = Element.prototype.getBoundingClientRect;
177
- const rectSpy = jest
178
- .spyOn( Element.prototype, 'getBoundingClientRect' )
179
- .mockImplementation( function ( this: Element ) {
180
- const rect = originalRect.call( this );
181
- return this.classList.contains( 'visx-tooltip' )
182
- ? { ...rect, width: 80, height: 30, right: rect.left + 80, bottom: rect.top + 30 }
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( 'application' ), 'focus' );
186
103
  try {
187
- renderChart(
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
- rectSpy.mockRestore();
107
+ focus.mockRestore();
220
108
  }
221
109
  } );
222
110
 
223
- test( 'draws one glyph per series in the SVG at the datum position', async () => {
224
- renderChart( { showSeriesGlyphs: true }, BOTH_SERIES );
225
-
226
- const glyphA = await screen.findByTestId( 'xy-chart-tooltip-glyph-A' );
227
- const glyphB = await screen.findByTestId( 'xy-chart-tooltip-glyph-B' );
228
-
229
- // The group is placed at the datum; the glyph itself draws at the origin.
230
- expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-A' ) ).toHaveAttribute(
231
- 'transform',
232
- 'translate(100, 50)'
233
- );
234
- expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-B' ) ).toHaveAttribute(
235
- 'transform',
236
- 'translate(100, 75)'
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( 'application' ), 'focus' );
119
+ try {
120
+ await user.keyboard( keys );
121
+ expect( focus ).toHaveBeenCalledWith();
122
+ } finally {
123
+ focus.mockRestore();
124
+ }
244
125
  } );
245
126
 
246
- test( 'hands the glyph renderer the origin, like visx, and marks the nearest datum', async () => {
247
- const renderGlyph = jest.fn( () => null );
248
- renderChart( { showSeriesGlyphs: true, renderGlyph }, BOTH_SERIES );
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
- test( 'centres a band-scale datum in its band', async () => {
264
- render(
265
- <div style={ { position: 'relative' } }>
266
- <XYChart
267
- width={ 200 }
268
- height={ 100 }
269
- margin={ NO_MARGIN }
270
- xScale={ { type: 'band', domain: [ 'a', 'b' ], paddingInner: 0, paddingOuter: 0 } }
271
- yScale={ { type: 'linear', domain: [ 0, 100 ] } }
272
- >
273
- <BarSeries
274
- dataKey="bars"
275
- data={ BAND_DATA }
276
- xAccessor={ bandXAccessor }
277
- yAccessor={ bandYAccessor }
278
- />
279
- <XyChartTooltip
280
- renderTooltip={ renderBarTooltip }
281
- data-testid="tooltip-box"
282
- snapTooltipToDatumX
283
- snapTooltipToDatumY
284
- showDatumGlyph
285
- />
286
- <OpenTooltip params={ BAND_PARAMS } />
287
- </XYChart>
134
+ // Mirrors the charts' markup: the focusable chart container 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
+ { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- mirrors the charts' container. */ }
159
+ <div
160
+ role="application"
161
+ aria-label="Harness"
162
+ tabIndex={ 0 }
163
+ onKeyDown={ onChartKeyDown }
164
+ onBlur={ trackBlur ? onChartBlur : undefined }
165
+ >
166
+ <div ref={ chartRef } data-testid="selected-index">
167
+ { selectedIndex ?? 'none' }
168
+ <button type="button">Inside</button>
169
+ </div>
170
+ <div data-testid="point-4" onPointerMove={ moveToFour } />
171
+ <div data-testid="point-1" onPointerMove={ moveToOne } />
288
172
  </div>
289
- );
290
-
291
- // Band 'a' spans 0..100px; the datum sits at its centre, 50px, not its start.
292
- await waitFor( () =>
293
- expect( screen.getByTestId( 'xy-chart-tooltip-glyph-group-bars' ) ).toHaveAttribute(
294
- 'transform',
295
- 'translate(50, 50)'
296
- )
297
- );
298
- expect( screen.getByTestId( 'tooltip-box' ) ).toHaveStyle( {
299
- transform: 'translate(60px, 60px)',
300
- } );
173
+ <button type="button">Outside</button>
174
+ </>
175
+ );
176
+ };
177
+
178
+ describe( 'useKeyboardNavigation', () => {
179
+ const navigate = async ( presses: number ) => {
180
+ const user = userEvent.setup();
181
+ const view = render( <NavigationHarness totalPoints={ 6 } /> );
182
+
183
+ screen.getByRole( 'application', { name: 'Harness' } ).focus();
184
+ for ( let i = 0; i < presses; i++ ) {
185
+ await user.keyboard( '{ArrowRight}' );
186
+ }
187
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
188
+
189
+ return { user, view };
190
+ };
191
+
192
+ it( 'moves the selection to the last point when the count shrinks while the chart has focus', async () => {
193
+ const { view } = await navigate( 6 );
194
+
195
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
196
+
197
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
198
+ expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
301
199
  } );
302
200
 
303
- test( 'draws the crosshairs across the plot area, inside the margins, at the snapped position', async () => {
304
- renderChart(
305
- {
306
- snapTooltipToDatumX: true,
307
- snapTooltipToDatumY: true,
308
- showVerticalCrosshair: true,
309
- showHorizontalCrosshair: true,
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' );
201
+ // In range rather than past the end: the effect reconciles on any count change, not only an overflow.
202
+ it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
203
+ const { view } = await navigate( 2 );
204
+
205
+ screen.getByRole( 'button', { name: 'Outside' } ).focus();
206
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
207
+
208
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
325
209
  } );
326
210
 
327
- test( 'applies crosshair paint without allowing geometry overrides', async () => {
328
- const consumerStyle = {
329
- stroke: 'purple',
330
- strokeWidth: 8,
331
- className: 'custom-crosshair',
332
- x1: 999,
333
- transform: 'translate(100 0)',
334
- style: { strokeOpacity: 0.25, transform: 'translateX(100px)' },
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
- }
211
+ it( 'returns focus to the chart on Escape when there are no points', async () => {
212
+ const user = userEvent.setup();
213
+ render( <NavigationHarness totalPoints={ 0 } /> );
214
+
215
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
216
+ await user.keyboard( '{Escape}' );
217
+
218
+ expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
357
219
  } );
358
220
 
359
- test( 'stacks the box above the chart overlays, with zIndex as the override', async () => {
360
- const { unmount } = renderChart();
361
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '3' } );
362
- unmount();
221
+ it( 'returns focus to the chart when the count drops to zero while focus is in the chart', async () => {
222
+ const { view } = await navigate( 2 );
223
+
224
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
225
+ view.rerender( <NavigationHarness totalPoints={ 0 } /> );
363
226
 
364
- renderChart( { zIndex: 9 } );
365
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { zIndex: '9' } );
227
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
228
+ expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
366
229
  } );
367
230
 
368
- test.each( [ 'auto', 'below-axis' ] as const )(
369
- 'keeps the %s surface and stacking under a partial style override',
370
- async tooltipPlacement => {
371
- renderChart( { tooltipPlacement, style: { color: 'red' } } );
372
- const box = await screen.findByTestId( 'tooltip-box' );
373
- expect( box ).toHaveClass( 'surface' );
374
- expect( box ).toHaveStyle( { zIndex: '3', color: 'rgb(255, 0, 0)' } );
375
- }
376
- );
231
+ it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
232
+ const { user } = await navigate( 2 );
233
+
234
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
235
+ await user.hover( screen.getByTestId( 'point-4' ) );
236
+
237
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
238
+ expect( screen.getByRole( 'application', { name: 'Harness' } ) ).toHaveFocus();
239
+
240
+ await user.keyboard( '{ArrowRight}' );
241
+
242
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
243
+ } );
377
244
 
378
- test( 'lets a style override paint the box', async () => {
379
- renderChart( { style: { background: 'rgb(0, 0, 0)', color: 'rgb(255, 255, 255)' } } );
380
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
381
- background: 'rgb(0, 0, 0)',
382
- color: 'rgb(255, 255, 255)',
383
- } );
245
+ it( 'continues from the last point the pointer reached after taking over', async () => {
246
+ const { user } = await navigate( 2 );
247
+
248
+ await user.hover( screen.getByTestId( 'point-4' ) );
249
+ await user.hover( screen.getByTestId( 'point-1' ) );
250
+ await user.keyboard( '{ArrowRight}' );
251
+
252
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
384
253
  } );
385
254
 
386
- test( 'accepts the portal-era options without passing them to the box', async () => {
387
- renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
255
+ it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
256
+ const user = userEvent.setup();
257
+ render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
258
+ const chart = screen.getByRole( 'application', { name: 'Harness' } );
388
259
 
389
- const box = await screen.findByTestId( 'tooltip-box' );
260
+ act( () => chart.focus() );
261
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
262
+ await user.hover( screen.getByTestId( 'point-4' ) );
263
+ act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
264
+ act( () => chart.focus() );
265
+ await user.keyboard( '{ArrowRight}' );
390
266
 
391
- expect( box ).not.toHaveAttribute( 'scroll' );
392
- expect( box ).not.toHaveAttribute( 'debounce' );
267
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
393
268
  } );
394
269
 
395
- test( 'renders no box when there is no tooltip renderer', async () => {
396
- renderChart( { renderTooltip: undefined } );
270
+ it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
271
+ const { user } = await navigate( 2 );
272
+ const outside = screen.getByRole( 'button', { name: 'Outside' } );
273
+
274
+ outside.focus();
275
+ await user.hover( screen.getByTestId( 'point-4' ) );
397
276
 
398
- await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
399
- expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
277
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
278
+ expect( outside ).toHaveFocus();
400
279
  } );
401
280
 
402
- test( 'renders nothing when the tooltip content is empty', async () => {
403
- renderChart( { renderTooltip: () => null, showSeriesGlyphs: true }, BOTH_SERIES );
281
+ it( 'ignores the pointer when there is no keyboard selection', async () => {
282
+ const user = userEvent.setup();
283
+ render( <NavigationHarness totalPoints={ 6 } /> );
284
+
285
+ screen.getByRole( 'application', { name: 'Harness' } ).focus();
286
+ await user.hover( screen.getByTestId( 'point-4' ) );
287
+ await user.keyboard( '{ArrowRight}' );
288
+
289
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
290
+ } );
291
+
292
+ // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
293
+ const recordPageKeyDown = () => {
294
+ const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
295
+ document.addEventListener( 'keydown', onKeyDown );
296
+ return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
297
+ };
298
+
299
+ it( 'leaves keys it does not handle available to the page', async () => {
300
+ const user = userEvent.setup();
301
+ render( <NavigationHarness totalPoints={ 6 } /> );
302
+ const { onKeyDown, stop } = recordPageKeyDown();
303
+
304
+ try {
305
+ screen.getByRole( 'application', { name: 'Harness' } ).focus();
306
+ await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
404
307
 
405
- // The anchor is the only thing rendered while there is nothing to show.
406
- await expect( screen.findByTestId( 'xy-chart-tooltip-anchor' ) ).resolves.toBeInTheDocument();
407
- expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
408
- expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
308
+ expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
309
+ for ( const [ event ] of onKeyDown.mock.calls ) {
310
+ expect( event.defaultPrevented ).toBe( false );
311
+ }
312
+ } finally {
313
+ stop();
314
+ }
409
315
  } );
410
316
 
411
- test( 'renders nothing while the tooltip is closed', () => {
412
- renderChart( { showSeriesGlyphs: true, showVerticalCrosshair: true }, [] );
317
+ // `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
318
+ it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
319
+ const user = userEvent.setup();
320
+ render( <NavigationHarness totalPoints={ 6 } /> );
321
+ const { onKeyDown, stop } = recordPageKeyDown();
322
+
323
+ try {
324
+ screen.getByRole( 'application', { name: 'Harness' } ).focus();
325
+ await user.keyboard( '{Escape}' );
413
326
 
414
- expect( screen.queryByTestId( 'tooltip-box' ) ).not.toBeInTheDocument();
415
- expect( screen.queryByTestId( 'xy-chart-tooltip-glyph-A' ) ).not.toBeInTheDocument();
416
- expect( screen.queryByTestId( 'xy-chart-tooltip-crosshair-vertical' ) ).not.toBeInTheDocument();
327
+ expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
328
+
329
+ await user.keyboard( '{ArrowRight}{Escape}' );
330
+
331
+ expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
332
+ } finally {
333
+ stop();
334
+ }
417
335
  } );
418
336
  } );