@automattic/charts 4.3.0 → 4.5.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 (41) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +584 -209
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +14 -3
  7. package/dist/index.d.ts +14 -3
  8. package/dist/index.js +585 -210
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +57 -23
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
  15. package/src/charts/bar-chart/private/index.ts +1 -0
  16. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
  18. package/src/charts/bar-chart/types.ts +5 -1
  19. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  20. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  21. package/src/charts/geo-chart/types.ts +5 -0
  22. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  23. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  24. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  25. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  26. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  27. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  28. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  29. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  30. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  31. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  32. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  33. package/src/providers/chart-context/private/series-palette.ts +1 -1
  34. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  35. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  36. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  37. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  38. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  39. package/src/utils/color-utils.ts +5 -3
  40. package/src/utils/test/date-formatting.test.ts +1 -1
  41. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
@@ -1,8 +1,9 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { useTooltip } from '@visx/tooltip';
4
+ import { color as d3Color } from '@visx/vendor/d3-color';
4
5
  import clsx from 'clsx';
5
- import { useCallback, useContext, useMemo, useRef } from 'react';
6
+ import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
7
  import { Legend, useChartLegendItems } from '../../components/legend';
7
8
  import { BaseTooltip } from '../../components/tooltip';
8
9
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
@@ -19,8 +20,16 @@ import {
19
20
  useGlobalChartsTheme,
20
21
  GlobalChartsContext,
21
22
  } from '../../providers';
23
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
24
+ import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
22
25
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
23
- import { attachSubComponents, resolveFontSize } from '../../utils';
26
+ import {
27
+ attachSubComponents,
28
+ createCssVariableResolver,
29
+ isValidHexColor,
30
+ normalizeColorToHex,
31
+ resolveFontSize,
32
+ } from '../../utils';
24
33
  import { getStringWidth } from '../../visx/text';
25
34
  import { Center } from '../private/center';
26
35
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
@@ -161,6 +170,50 @@ const validateData = ( data: DataPointPercentage[] ) => {
161
170
  return { isValid: true, message: '' };
162
171
  };
163
172
 
173
+ /**
174
+ * The label pointers, resolved at the pie chart's own root element so JS and CSS agree on what paints.
175
+ */
176
+ interface ResolvedLabelPointers {
177
+ /** True while a label plate is set; the label text then always uses the inverse role. */
178
+ hasPlate: boolean;
179
+ labelHex: string;
180
+ labelInverseHex: string;
181
+ /** True when the inverse role is see-through, so only the `label` role can be seen on a fill. */
182
+ isInverseSeeThrough: boolean;
183
+ }
184
+
185
+ /**
186
+ * Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
187
+ *
188
+ * A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
189
+ * Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
190
+ * yet resolved, or labels off) keep the default inverse role.
191
+ *
192
+ * @param fill - The slice's resolved fill color.
193
+ * @param pointers - The label pointers, resolved at the chart's own root element.
194
+ * @return Whether the label needs dark text.
195
+ */
196
+ const labelNeedsDarkText = ( fill: string, pointers: ResolvedLabelPointers | null ): boolean => {
197
+ if ( ! pointers || pointers.hasPlate ) {
198
+ return false;
199
+ }
200
+
201
+ const fillHex = normalizeColorToHex( fill );
202
+ if ( ! isValidHexColor( fillHex ) || ! isValidHexColor( pointers.labelHex ) ) {
203
+ return false;
204
+ }
205
+ if ( pointers.isInverseSeeThrough ) {
206
+ return true;
207
+ }
208
+ if ( ! isValidHexColor( pointers.labelInverseHex ) ) {
209
+ return false;
210
+ }
211
+
212
+ return (
213
+ contrastRatio( fillHex, pointers.labelHex ) > contrastRatio( fillHex, pointers.labelInverseHex )
214
+ );
215
+ };
216
+
164
217
  /**
165
218
  * Renders a pie or donut chart using the provided data.
166
219
  *
@@ -202,6 +255,11 @@ const PieChartInternal = ( {
202
255
  // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
203
256
  const containerRef = useRef< HTMLDivElement >( null );
204
257
 
258
+ // The element the chart's own `className` lands on, so an override set there reaches this
259
+ // decision the same way it reaches CSS.
260
+ const rootRef = useRef< HTMLDivElement >( null );
261
+ const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
262
+
205
263
  const onMouseLeave = useCallback( () => {
206
264
  if ( ! withTooltips ) {
207
265
  return;
@@ -209,7 +267,41 @@ const PieChartInternal = ( {
209
267
  hideTooltip();
210
268
  }, [ withTooltips, hideTooltip ] );
211
269
 
212
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
270
+ const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
271
+ const { isValid, message } = validateData( data );
272
+
273
+ // Skipped when labels are off, or before the chart's own root element mounts (the invalid-data
274
+ // branch renders a plain div, so `rootRef` is not yet attached).
275
+ useLayoutEffect( () => {
276
+ if ( ! showLabels || ! rootRef.current ) {
277
+ return;
278
+ }
279
+
280
+ const resolve = createCssVariableResolver( rootRef.current );
281
+ const rawLabelBackground = resolve( CATALOG_POINTERS.labelBackground );
282
+ // Hex drops alpha, so a see-through label role would win the comparison and paint nothing.
283
+ const rawLabel = resolve( CATALOG_POINTERS.label );
284
+ const isLabelOpaque = rawLabel ? d3Color( rawLabel )?.opacity === 1 : false;
285
+ const rawLabelInverse = resolve( CATALOG_POINTERS.labelInverse );
286
+ const labelInverseColor = rawLabelInverse ? d3Color( rawLabelInverse ) : null;
287
+ // A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
288
+ const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
289
+ const next: ResolvedLabelPointers = {
290
+ hasPlate: rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false,
291
+ labelHex: isLabelOpaque ? normalizeColorToHex( CATALOG_POINTERS.label, null, resolve ) : '',
292
+ labelInverseHex: normalizeColorToHex( CATALOG_POINTERS.labelInverse, null, resolve ),
293
+ isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false,
294
+ };
295
+ setLabelPointers( previous =>
296
+ previous &&
297
+ previous.hasPlate === next.hasPlate &&
298
+ previous.labelHex === next.labelHex &&
299
+ previous.labelInverseHex === next.labelInverseHex &&
300
+ previous.isInverseSeeThrough === next.isInverseSeeThrough
301
+ ? previous
302
+ : next
303
+ );
304
+ }, [ showLabels, className, isColorPaletteResolved, isValid ] );
213
305
 
214
306
  // Calculate percentages from values (single source of truth)
215
307
  const dataWithPercentages = useDataWithPercentages( data );
@@ -230,8 +322,6 @@ const PieChartInternal = ( {
230
322
  // Create legend items using legendData (has recalculated percentages for visible items)
231
323
  const legendItems = useChartLegendItems( legendData, legendOptions );
232
324
 
233
- const { isValid, message } = validateData( data );
234
-
235
325
  // Process children to extract compound components
236
326
  const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
237
327
  children,
@@ -309,6 +399,7 @@ const PieChartInternal = ( {
309
399
  legendElement={ legendElement }
310
400
  legendChildren={ legendChildren }
311
401
  gap={ gap }
402
+ rootRef={ rootRef }
312
403
  className={ clsx(
313
404
  'pie-chart',
314
405
  styles[ 'pie-chart' ],
@@ -405,11 +496,12 @@ const PieChartInternal = ( {
405
496
  } );
406
497
  };
407
498
 
499
+ const fill = accessors.fill( arc.data );
408
500
  const pathProps: SVGProps< SVGPathElement > & {
409
501
  'data-testid'?: string;
410
502
  } = {
411
503
  d: pie.path( arc ) || '',
412
- fill: accessors.fill( arc.data ),
504
+ fill,
413
505
  'data-testid': 'pie-segment',
414
506
  };
415
507
 
@@ -446,7 +538,11 @@ const PieChartInternal = ( {
446
538
  pointerEvents="none"
447
539
  />
448
540
  <text
449
- className={ styles[ 'pie-chart__label-text' ] }
541
+ className={ clsx( styles[ 'pie-chart__label-text' ], {
542
+ [ styles[ 'pie-chart__label-text--on-light' ] ]:
543
+ labelNeedsDarkText( fill, labelPointers ),
544
+ } ) }
545
+ data-testid="pie-label"
450
546
  x={ centroidX }
451
547
  y={ centroidY }
452
548
  dy=".33em"
@@ -149,6 +149,155 @@ describe( 'PieChart', () => {
149
149
  } );
150
150
  } );
151
151
 
152
+ describe( 'Label Text Contrast', () => {
153
+ const contrastData = [
154
+ { label: 'Light', value: 60, color: '#f5d76e' },
155
+ { label: 'Dark', value: 40, color: '#1e3a8a' },
156
+ ];
157
+
158
+ let injectedStyle: HTMLStyleElement | null = null;
159
+
160
+ afterEach( () => {
161
+ if ( injectedStyle ) {
162
+ document.head.removeChild( injectedStyle );
163
+ injectedStyle = null;
164
+ }
165
+ } );
166
+
167
+ test( 'uses dark label text on a light slice', () => {
168
+ renderWithTheme( { data: contrastData } );
169
+
170
+ const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
171
+ expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
172
+ expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
173
+ } );
174
+
175
+ test( "keeps the inverse label color when a label plate is set on the chart's own class", () => {
176
+ injectedStyle = document.createElement( 'style' );
177
+ injectedStyle.textContent =
178
+ '.plated-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
179
+ document.head.appendChild( injectedStyle );
180
+
181
+ renderWithTheme( { data: contrastData, className: 'plated-pie' } );
182
+
183
+ screen
184
+ .getAllByTestId( 'pie-label' )
185
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
186
+ } );
187
+
188
+ test( "compares against a label-inverse override set on the chart's own class", () => {
189
+ injectedStyle = document.createElement( 'style' );
190
+ // Matches the slice fill exactly, so the inverse role loses to the default dark label role.
191
+ injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #1e3a8a; }';
192
+ document.head.appendChild( injectedStyle );
193
+
194
+ renderWithTheme( {
195
+ data: [ { label: 'Dark', value: 100, color: '#1e3a8a' } ],
196
+ className: 'overridden-pie',
197
+ } );
198
+
199
+ const [ label ] = screen.getAllByTestId( 'pie-label' );
200
+ expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
201
+ } );
202
+
203
+ test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
204
+ injectedStyle = document.createElement( 'style' );
205
+ injectedStyle.textContent =
206
+ '.modern-plate-pie { --a8c-charts-color-label-background: rgb(0 0 0 / 50%); }';
207
+ document.head.appendChild( injectedStyle );
208
+
209
+ renderWithTheme( { data: contrastData, className: 'modern-plate-pie' } );
210
+
211
+ screen
212
+ .getAllByTestId( 'pie-label' )
213
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
214
+ } );
215
+
216
+ test( 'keeps the inverse label color when the label role is see-through', () => {
217
+ injectedStyle = document.createElement( 'style' );
218
+ injectedStyle.textContent =
219
+ '.clear-label-pie { --a8c-charts-color-label: rgba(0, 0, 0, 0); }';
220
+ document.head.appendChild( injectedStyle );
221
+
222
+ renderWithTheme( { data: contrastData, className: 'clear-label-pie' } );
223
+
224
+ screen
225
+ .getAllByTestId( 'pie-label' )
226
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
227
+ } );
228
+
229
+ test( 'keeps one label color on every slice when both label roles are the same', () => {
230
+ injectedStyle = document.createElement( 'style' );
231
+ injectedStyle.textContent =
232
+ '.single-label-pie { --a8c-charts-color-label: #ffffff; --a8c-charts-color-label-inverse: #ffffff; }';
233
+ document.head.appendChild( injectedStyle );
234
+
235
+ renderWithTheme( { data: contrastData, className: 'single-label-pie' } );
236
+
237
+ screen
238
+ .getAllByTestId( 'pie-label' )
239
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
240
+ } );
241
+
242
+ test( 'uses the label role on every slice when the inverse role is see-through', () => {
243
+ injectedStyle = document.createElement( 'style' );
244
+ injectedStyle.textContent =
245
+ '.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
246
+ document.head.appendChild( injectedStyle );
247
+
248
+ renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
249
+
250
+ screen
251
+ .getAllByTestId( 'pie-label' )
252
+ .forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
253
+ } );
254
+
255
+ test( 'keeps the inverse label color when the fill cannot be resolved', () => {
256
+ renderWithTheme( {
257
+ data: [ { label: 'Unresolved', value: 100, color: 'var(--not-set)' } ],
258
+ } );
259
+
260
+ screen
261
+ .getAllByTestId( 'pie-label' )
262
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
263
+ } );
264
+
265
+ describe( 'after an invalid-to-valid data transition', () => {
266
+ test( "keeps the inverse label color when a plate is set on the chart's own class", () => {
267
+ injectedStyle = document.createElement( 'style' );
268
+ injectedStyle.textContent =
269
+ '.plated-late-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
270
+ document.head.appendChild( injectedStyle );
271
+
272
+ const { rerender } = renderWithTheme( { data: [], className: 'plated-late-pie' } );
273
+
274
+ rerender(
275
+ <GlobalChartsProvider>
276
+ <PieChart { ...defaultProps } data={ contrastData } className="plated-late-pie" />
277
+ </GlobalChartsProvider>
278
+ );
279
+
280
+ screen
281
+ .getAllByTestId( 'pie-label' )
282
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
283
+ } );
284
+
285
+ test( 'resolves the label pointers once the chart mounts its own root element', () => {
286
+ const { rerender } = renderWithTheme( { data: [] } );
287
+
288
+ rerender(
289
+ <GlobalChartsProvider>
290
+ <PieChart { ...defaultProps } data={ contrastData } />
291
+ </GlobalChartsProvider>
292
+ );
293
+
294
+ const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
295
+ expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
296
+ expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
297
+ } );
298
+ } );
299
+ } );
300
+
152
301
  describe( 'Legend Value Display', () => {
153
302
  // Values that give clean percentages: 60/100=60%, 23/100=23%, 17/100=17%
154
303
  const testData = [
@@ -5,7 +5,7 @@ import { renderLegendSlot } from '../chart-composition';
5
5
  import styles from './chart-layout.module.scss';
6
6
  import type { GapSize, LegendPosition } from '../../../types';
7
7
  import type { LegendChild } from '../chart-composition/use-chart-children';
8
- import type { CSSProperties, ReactNode } from 'react';
8
+ import type { CSSProperties, ReactNode, Ref } from 'react';
9
9
 
10
10
  /**
11
11
  * Measurements provided to the render prop when ChartLayout handles resize listening.
@@ -36,6 +36,8 @@ export interface ChartLayoutProps {
36
36
  gap?: GapSize;
37
37
  /** Additional class names */
38
38
  className?: string;
39
+ /** Ref to the root element, the one `className` lands on */
40
+ rootRef?: Ref< HTMLDivElement >;
39
41
  /** Inline styles (width, height, etc.) */
40
42
  style?: CSSProperties;
41
43
  /** Test ID for the container */
@@ -53,6 +55,7 @@ export const ChartLayout = ( {
53
55
  onContentHeightChange,
54
56
  gap,
55
57
  className,
58
+ rootRef,
56
59
  style,
57
60
  'data-testid': dataTestId,
58
61
  'data-chart-id': dataChartId,
@@ -78,6 +81,7 @@ export const ChartLayout = ( {
78
81
 
79
82
  return (
80
83
  <Stack
84
+ ref={ rootRef }
81
85
  direction="column"
82
86
  gap={ gap }
83
87
  className={ className }
@@ -341,10 +341,41 @@ export const useKeyboardNavigation = ( {
341
341
  [ isNavigating, selectedIndex, setSelectedIndex ]
342
342
  );
343
343
 
344
+ // The point the pointer took over a keyboard selection at, where the next arrow key continues from.
345
+ const pointerIndex = useRef< number | undefined >( undefined );
346
+
347
+ // The pointer is the latest input, so it ends a keyboard selection instead of competing with it.
348
+ const onChartPointerMove = useCallback(
349
+ ( index: number ) => {
350
+ if ( selectedIndex === undefined ) {
351
+ if ( pointerIndex.current !== undefined ) {
352
+ pointerIndex.current = index;
353
+ }
354
+ return;
355
+ }
356
+ const root = getChartRoot();
357
+ const activeElement = root?.ownerDocument.activeElement;
358
+ // Focus elsewhere on the page stays there; a hover must not pull it back into the chart.
359
+ if ( activeElement && root.contains( activeElement ) ) {
360
+ pointerIndex.current = index;
361
+ focusWithoutScrollIfNeeded( root );
362
+ }
363
+ setSelectedIndex( undefined );
364
+ setIsNavigating( false );
365
+ },
366
+ [ selectedIndex, setSelectedIndex, setIsNavigating, getChartRoot, focusWithoutScrollIfNeeded ]
367
+ );
368
+
344
369
  // On each blur of chart, keyboard navigation should restart from first tooltip
345
- const onChartBlur = useCallback( () => {
346
- setIsNavigating( false );
347
- }, [ setIsNavigating ] );
370
+ const onChartBlur = useCallback(
371
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
372
+ setIsNavigating( false );
373
+ if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
374
+ pointerIndex.current = undefined;
375
+ }
376
+ },
377
+ [ setIsNavigating ]
378
+ );
348
379
 
349
380
  const onChartKeyDown = useCallback(
350
381
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
@@ -356,12 +387,16 @@ export const useKeyboardNavigation = ( {
356
387
  focusWithoutScrollIfNeeded( getChartRoot() );
357
388
  setSelectedIndex( undefined );
358
389
  setIsNavigating( false );
390
+ pointerIndex.current = undefined;
359
391
  return;
360
392
  }
361
393
 
362
394
  if ( totalPoints === 0 ) return;
363
395
 
364
- const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
396
+ const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
397
+ if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) {
398
+ pointerIndex.current = undefined;
399
+ }
365
400
 
366
401
  // WAI-ARIA grid: arrows stop at the first and last cell.
367
402
  if ( event.key === 'ArrowRight' ) {
@@ -393,5 +428,6 @@ export const useKeyboardNavigation = ( {
393
428
  onChartFocus,
394
429
  onChartBlur,
395
430
  onChartKeyDown,
431
+ onChartPointerMove,
396
432
  };
397
433
  };
@@ -1,6 +1,6 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { useRef, useState } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
4
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
5
5
  import { GlobalChartsProvider } from '../../../providers';
6
6
  import { ChartScopeContext } from '../../../providers/chart-scope';
@@ -128,11 +128,17 @@ describe( 'AccessibleTooltip', () => {
128
128
  } );
129
129
 
130
130
  // Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
131
- const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
131
+ const NavigationHarness = ( {
132
+ totalPoints,
133
+ trackBlur = false,
134
+ }: {
135
+ totalPoints: number;
136
+ trackBlur?: boolean;
137
+ } ) => {
132
138
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
133
139
  const [ isNavigating, setIsNavigating ] = useState( false );
134
140
  const chartRef = useRef< HTMLDivElement >( null );
135
- const { onChartKeyDown } = useKeyboardNavigation( {
141
+ const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
136
142
  selectedIndex,
137
143
  setSelectedIndex,
138
144
  isNavigating,
@@ -140,14 +146,24 @@ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
140
146
  chartRef,
141
147
  totalPoints,
142
148
  } );
149
+ const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
150
+ const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
143
151
 
144
152
  return (
145
153
  <>
146
- <div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
154
+ <div
155
+ role="grid"
156
+ aria-label="Harness"
157
+ tabIndex={ 0 }
158
+ onKeyDown={ onChartKeyDown }
159
+ onBlur={ trackBlur ? onChartBlur : undefined }
160
+ >
147
161
  <div ref={ chartRef } data-testid="selected-index">
148
162
  { selectedIndex ?? 'none' }
149
163
  <button type="button">Inside</button>
150
164
  </div>
165
+ <div data-testid="point-4" onPointerMove={ moveToFour } />
166
+ <div data-testid="point-1" onPointerMove={ moveToOne } />
151
167
  </div>
152
168
  <button type="button">Outside</button>
153
169
  </>
@@ -207,6 +223,67 @@ describe( 'useKeyboardNavigation', () => {
207
223
  expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
208
224
  } );
209
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
+
210
287
  // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
211
288
  const recordPageKeyDown = () => {
212
289
  const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();