@automattic/charts 4.1.1 → 4.3.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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -9,6 +9,7 @@ import {
9
9
  useChartRegistration,
10
10
  useGlobalChartsContext,
11
11
  } from '../../../providers';
12
+ import { useChartLegendItems } from '../hooks/use-chart-legend-items';
12
13
  import { Legend } from '../legend';
13
14
  import { BaseLegend } from '../private/base-legend';
14
15
  import type { ChartType } from '../../../types';
@@ -49,6 +50,26 @@ describe( 'BaseLegend', () => {
49
50
  expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
50
51
  } );
51
52
 
53
+ test( 'preserves each duplicate label, color, and following item', () => {
54
+ render(
55
+ <BaseLegend
56
+ items={ [
57
+ { label: 'Views', color: '#ff0000' },
58
+ { label: 'Views', color: '#00ff00', interactive: false },
59
+ { label: 'Visitors', color: '#0000ff' },
60
+ ] }
61
+ shape={ TestShape }
62
+ labelFormat={ label => `Metric: ${ label }` }
63
+ />
64
+ );
65
+ expect( screen.getAllByText( 'Metric: Views' ) ).toHaveLength( 2 );
66
+ expect( screen.getByText( 'Metric: Visitors' ) ).toBeInTheDocument();
67
+ const markers = screen.getAllByTestId( 'legend-marker' );
68
+ expect( markers[ 0 ] ).toHaveAttribute( 'fill', '#ff0000' );
69
+ expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
70
+ expect( markers[ 2 ] ).toHaveAttribute( 'fill', '#0000ff' );
71
+ } );
72
+
52
73
  test( 'handles empty items array', () => {
53
74
  render( <BaseLegend items={ [] } orientation="horizontal" /> );
54
75
  const legendItems = screen.queryAllByRole( 'listitem' );
@@ -754,6 +775,69 @@ describe( 'BaseLegend', () => {
754
775
  expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
755
776
  } );
756
777
 
778
+ it( 'keeps an item with interactive: false static inside an interactive legend', async () => {
779
+ const user = userEvent.setup();
780
+ const items = [
781
+ { label: 'Views', color: '#0000ff' },
782
+ { label: 'Comparison period', color: '#0000ff', interactive: false },
783
+ ];
784
+
785
+ render(
786
+ <GlobalChartsProvider>
787
+ <BaseLegend items={ items } interactive={ true } chartId="test-chart" />
788
+ </GlobalChartsProvider>
789
+ );
790
+
791
+ expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
792
+ const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
793
+ expect( staticItem ).toHaveTextContent( 'Comparison period' );
794
+ expect( staticItem ).not.toHaveAttribute( 'role' );
795
+ expect( staticItem ).not.toHaveAttribute( 'tabindex' );
796
+ expect( staticItem ).not.toHaveAttribute( 'aria-pressed' );
797
+
798
+ await user.click( staticItem );
799
+
800
+ expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'true' );
801
+ expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
802
+ } );
803
+
804
+ it( 'keeps a comparison item distinct when its label collides', async () => {
805
+ const user = userEvent.setup();
806
+ const Example = () => {
807
+ const items = useChartLegendItems(
808
+ [
809
+ {
810
+ label: 'Comparison period',
811
+ group: 'metric',
812
+ data: [ { label: 'Mon', value: 10 } ],
813
+ },
814
+ {
815
+ label: 'Previous metric',
816
+ group: 'metric',
817
+ options: { type: 'comparison' },
818
+ data: [ { label: 'Mon', value: 5 } ],
819
+ },
820
+ ],
821
+ { collapseGroups: true, comparisonItem: true },
822
+ 'line'
823
+ );
824
+ return <BaseLegend items={ items } interactive chartId="test-chart" />;
825
+ };
826
+ render(
827
+ <GlobalChartsProvider>
828
+ <Example />
829
+ </GlobalChartsProvider>
830
+ );
831
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
832
+ const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
833
+ expect( staticItem ).not.toHaveAttribute( 'role' );
834
+ await user.click( screen.getByRole( 'button' ) );
835
+ expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
836
+ expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
837
+ await user.click( staticItem );
838
+ expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
839
+ } );
840
+
757
841
  it( 'falls back to the item label when seriesLabels is an empty array', async () => {
758
842
  const user = userEvent.setup();
759
843
 
@@ -61,4 +61,6 @@ export type BaseLegendItem = {
61
61
  * the interactive legend to toggle a whole group's visibility from one item.
62
62
  */
63
63
  seriesLabels?: string[];
64
+ /** Set to false for a static item that cannot be toggled or dimmed. */
65
+ interactive?: boolean;
64
66
  };
@@ -1,3 +1,2 @@
1
1
  export { valueOrIdentity, valueOrIdentityString } from './value-or-identity';
2
- export { labelTransformFactory } from './label-transform-factory';
3
2
  export type { ValueOrIdentity } from './value-or-identity';
@@ -1,6 +1,8 @@
1
1
  import { TooltipContext } from '@visx/xychart';
2
2
  import clsx from 'clsx';
3
3
  import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
+ import { ChartInstanceContext } from '../../charts/private/chart-instance-context';
5
+ import { useGlobalChartsContext } from '../../providers/chart-context/hooks/use-global-charts-context';
4
6
  import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
5
7
  import { useChartScopeElement, useStandaloneScopeClass } from '../../providers/chart-scope';
6
8
  import { resolveCssVariable } from '../../utils';
@@ -18,8 +20,10 @@ export type FlattenedTooltipData = {
18
20
  };
19
21
 
20
22
  // Enhanced tooltip with keyboard navigation and accessibility
21
- interface AccessibleTooltipProps
22
- extends Omit< XyChartTooltipProps< DataPointDate >, 'renderTooltip' > {
23
+ interface AccessibleTooltipProps extends Omit<
24
+ XyChartTooltipProps< DataPointDate >,
25
+ 'renderTooltip'
26
+ > {
23
27
  renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
24
28
  selectedIndex?: number | undefined;
25
29
  tooltipRef?: ( element: HTMLDivElement | null ) => void;
@@ -98,6 +102,40 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
98
102
  // Tracks whether this effect opened a tooltip, so it only closes its own.
99
103
  const hasKeyboardSelection = useRef( false );
100
104
 
105
+ // visx's own `hideTooltip` is debounced by 400ms, long enough for a stale datum to be
106
+ // repainted against a changed series list. This is the undebounced close it wraps.
107
+ const closeTooltipNow = useCallback( () => {
108
+ tooltipContext?.updateTooltip( {
109
+ tooltipOpen: false,
110
+ tooltipLeft: undefined,
111
+ tooltipTop: undefined,
112
+ tooltipData: undefined,
113
+ } );
114
+ // Don't include tooltipContext in the dependency array to avoid loop.
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, [] );
117
+
118
+ const chartId = useContext( ChartInstanceContext )?.chartId;
119
+ const { getHiddenSeries } = useGlobalChartsContext();
120
+ const hiddenSeriesKey = chartId
121
+ ? JSON.stringify( [ ...getHiddenSeries( chartId ) ].sort() )
122
+ : undefined;
123
+ const lastHiddenSeriesKey = useRef( hiddenSeriesKey );
124
+
125
+ // Hiding a series re-points every tooltip index at a different series, so a tooltip opened by
126
+ // the pointer would keep showing a datum that is no longer on the chart. Nothing else closes
127
+ // it: the pointer has not moved, so visx never fires the leave that would.
128
+ useEffect( () => {
129
+ const changed = lastHiddenSeriesKey.current !== hiddenSeriesKey;
130
+ lastHiddenSeriesKey.current = hiddenSeriesKey;
131
+
132
+ // A keyboard selection is reconciled by `useKeyboardNavigation` instead, which either
133
+ // re-shows the tooltip at a valid index or clears the selection for the branch below.
134
+ if ( changed && selectedIndex === undefined ) {
135
+ closeTooltipNow();
136
+ }
137
+ }, [ hiddenSeriesKey, selectedIndex, closeTooltipNow ] );
138
+
101
139
  // Handle tooltip highlighting for keyboard navigation
102
140
  useEffect( () => {
103
141
  if ( selectedIndex === undefined ) {
@@ -106,7 +144,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
106
144
  // mid-navigation and closes the tooltip the user is reading.
107
145
  if ( hasKeyboardSelection.current ) {
108
146
  hasKeyboardSelection.current = false;
109
- tooltipContext?.hideTooltip();
147
+ closeTooltipNow();
110
148
  }
111
149
  return;
112
150
  }
@@ -134,7 +172,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
134
172
  tooltipContext?.showTooltip( {
135
173
  datum: tooltipItem.datum,
136
174
  key: tooltipItem.seriesLabel,
137
- index: tooltipItem.seriesIndex,
175
+ index: tooltipItem.dataPointIndex,
138
176
  } );
139
177
  }
140
178
  }
@@ -201,6 +239,12 @@ interface UseKeyboardNavigationProps {
201
239
  */
202
240
  onActivate?: ( index: number ) => void;
203
241
  preventTooltipScroll?: boolean;
242
+ /**
243
+ * Identity of the series the navigation indexes run over, for charts whose index space depends
244
+ * on which series are visible. Swapping one hidden series for another of the same length leaves
245
+ * `totalPoints` unchanged, so the count alone cannot detect it.
246
+ */
247
+ visibleSeriesKey?: string;
204
248
  }
205
249
 
206
250
  export const useKeyboardNavigation = ( {
@@ -212,21 +256,78 @@ export const useKeyboardNavigation = ( {
212
256
  totalPoints,
213
257
  onActivate,
214
258
  preventTooltipScroll = false,
259
+ visibleSeriesKey,
215
260
  }: UseKeyboardNavigationProps ) => {
261
+ // `chartRef` sits inside the focusable grid, so the grid is what focus returns to and is measured against.
262
+ const getChartRoot = useCallback(
263
+ (): HTMLElement | null =>
264
+ chartRef.current?.closest< HTMLElement >( '[role="grid"]' ) ?? chartRef.current,
265
+ [ chartRef ]
266
+ );
267
+
268
+ const focusWithoutScrollIfNeeded = useCallback(
269
+ ( element: HTMLElement | null | undefined ) => {
270
+ if ( preventTooltipScroll ) {
271
+ element?.focus( { preventScroll: true } );
272
+ } else {
273
+ element?.focus();
274
+ }
275
+ },
276
+ [ preventTooltipScroll ]
277
+ );
278
+
216
279
  // Focus the tooltip as soon as it is rendered
217
280
  const tooltipRef = useCallback(
218
281
  ( element: HTMLDivElement | null ) => {
219
282
  if ( element && selectedIndex !== undefined ) {
220
- if ( preventTooltipScroll ) {
221
- element.focus( { preventScroll: true } );
222
- } else {
223
- element.focus();
224
- }
283
+ focusWithoutScrollIfNeeded( element );
225
284
  }
226
285
  },
227
- [ preventTooltipScroll, selectedIndex ]
286
+ [ focusWithoutScrollIfNeeded, selectedIndex ]
228
287
  );
229
288
 
289
+ const previousTotalPoints = useRef( totalPoints );
290
+ const previousVisibleSeriesKey = useRef( visibleSeriesKey );
291
+
292
+ // Hiding or showing a series moves every index onto a different bar. The count alone misses a
293
+ // swap that keeps it the same, such as hiding one series while revealing another of equal length.
294
+ // Clear rather than clamp once focus has left the chart: a new tooltip would pull focus back.
295
+ useEffect( () => {
296
+ const countChanged = previousTotalPoints.current !== totalPoints;
297
+ const seriesChanged = previousVisibleSeriesKey.current !== visibleSeriesKey;
298
+ previousTotalPoints.current = totalPoints;
299
+ previousVisibleSeriesKey.current = visibleSeriesKey;
300
+
301
+ if ( ( ! countChanged && ! seriesChanged ) || selectedIndex === undefined ) {
302
+ return;
303
+ }
304
+
305
+ const { activeElement } = document;
306
+ const focusIsInChart = activeElement !== null && !! getChartRoot()?.contains( activeElement );
307
+
308
+ // A swap that leaves the count alone gives the same index a different series, so unlike a
309
+ // count change there is no shorter range to clamp into.
310
+ if ( totalPoints === 0 || ! focusIsInChart || ! countChanged ) {
311
+ setSelectedIndex( undefined );
312
+ setIsNavigating( false );
313
+ // Clearing unmounts the focused tooltip, which would drop focus to the body.
314
+ if ( focusIsInChart ) {
315
+ focusWithoutScrollIfNeeded( getChartRoot() );
316
+ }
317
+ return;
318
+ }
319
+
320
+ setSelectedIndex( Math.min( selectedIndex, totalPoints - 1 ) );
321
+ }, [
322
+ selectedIndex,
323
+ totalPoints,
324
+ visibleSeriesKey,
325
+ setSelectedIndex,
326
+ setIsNavigating,
327
+ getChartRoot,
328
+ focusWithoutScrollIfNeeded,
329
+ ] );
330
+
230
331
  // Returning focus from the tooltip must not restore the selection Escape just cleared.
231
332
  const onChartFocus = useCallback(
232
333
  ( event: React.FocusEvent< HTMLDivElement > ) => {
@@ -247,46 +348,32 @@ export const useKeyboardNavigation = ( {
247
348
 
248
349
  const onChartKeyDown = useCallback(
249
350
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
250
- if ( totalPoints === 0 ) return;
251
-
252
- const focusChart = () => {
253
- if ( preventTooltipScroll ) {
254
- chartRef.current?.focus( { preventScroll: true } );
255
- } else {
256
- chartRef.current?.focus();
351
+ if ( event.key === 'Tab' || event.key === 'Escape' ) {
352
+ // Consuming Escape with nothing open would swallow the first Escape of a surrounding Modal.
353
+ if ( event.key === 'Escape' && selectedIndex !== undefined ) {
354
+ event.preventDefault();
257
355
  }
258
- };
259
-
260
- // Keep focus on the chart if tab is pressed
261
- if ( event.key === 'Tab' ) {
262
- focusChart();
356
+ focusWithoutScrollIfNeeded( getChartRoot() );
263
357
  setSelectedIndex( undefined );
264
358
  setIsNavigating( false );
265
359
  return;
266
360
  }
267
361
 
268
- const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
269
-
270
- if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
271
- focusChart();
272
- setSelectedIndex( undefined );
273
- setIsNavigating( false );
274
- return;
275
- }
362
+ if ( totalPoints === 0 ) return;
276
363
 
277
- event.preventDefault();
364
+ const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
278
365
 
279
- if ( [ 'ArrowRight' ].includes( event.key ) ) {
366
+ // WAI-ARIA grid: arrows stop at the first and last cell.
367
+ if ( event.key === 'ArrowRight' ) {
368
+ event.preventDefault();
280
369
  setIsNavigating( true );
281
- setSelectedIndex( ( currentSelectedIndex + 1 ) % totalPoints );
282
- } else if ( [ 'ArrowLeft' ].includes( event.key ) ) {
370
+ setSelectedIndex( Math.min( currentSelectedIndex + 1, totalPoints - 1 ) );
371
+ } else if ( event.key === 'ArrowLeft' ) {
372
+ event.preventDefault();
283
373
  setIsNavigating( true );
284
- setSelectedIndex( ( currentSelectedIndex - 1 + totalPoints ) % totalPoints );
285
- } else if ( event.key === 'Escape' ) {
286
- setSelectedIndex( undefined );
287
- setIsNavigating( false );
288
- focusChart();
374
+ setSelectedIndex( Math.max( currentSelectedIndex - 1, 0 ) );
289
375
  } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
376
+ event.preventDefault();
290
377
  onActivate?.( selectedIndex );
291
378
  }
292
379
  },
@@ -295,9 +382,9 @@ export const useKeyboardNavigation = ( {
295
382
  selectedIndex,
296
383
  setSelectedIndex,
297
384
  setIsNavigating,
298
- chartRef,
385
+ getChartRoot,
386
+ focusWithoutScrollIfNeeded,
299
387
  onActivate,
300
- preventTooltipScroll,
301
388
  ]
302
389
  );
303
390
 
@@ -1,3 +1,5 @@
1
+ // `.surface` is the look alone, for a chart that positions the box itself.
2
+ .surface,
1
3
  .tooltip {
2
4
  padding: var(--wpds-dimension-padding-sm);
3
5
  // No WPDS token fits a translucent dark tooltip surface:
@@ -9,7 +11,10 @@
9
11
  border-radius: var(--wpds-border-radius-md);
10
12
  font-size: var(--wpds-typography-font-size-md);
11
13
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
12
- position: absolute;
13
14
  pointer-events: none;
15
+ }
16
+
17
+ .tooltip {
18
+ position: absolute;
14
19
  transform: translate(-50%, -100%);
15
20
  }
@@ -14,6 +14,9 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
14
14
  };
15
15
 
16
16
  const DEFAULT_OFFSET = 10;
17
+ // Above anything else layered inside a chart, such as the zoom reset button (2).
18
+ // Chart wrappers isolate their stacking context, so it never reaches page chrome.
19
+ export const TOOLTIP_Z_INDEX = 3;
17
20
  const POINTER_HEIGHT = 6;
18
21
 
19
22
  const clamp = ( position: number, min: number, max: number, size: number ) =>
@@ -41,7 +44,8 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
41
44
  /**
42
45
  * Where the box goes, in wrapper coordinates. Automatic placement flips to
43
46
  * the side that clips less, then clamps inside `bounds`; below-axis placement
44
- * is centered on the anchor and clamped horizontally.
47
+ * is centered on the anchor and clamped horizontally; beside placement preserves
48
+ * the anchor's y and only flips horizontally before bounds clamping.
45
49
  *
46
50
  * The flip measures against the visible part of the wrapper, its intersection
47
51
  * with `bounds`. Only the clamp is bound by the page: a box that fits inside the
@@ -55,7 +59,7 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
55
59
  * @param params.offsetTop - Gap between the anchor and the box, vertically.
56
60
  * @param params.box - Rendered size of the box.
57
61
  * @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
58
- * @param params.placement - Fixed below-axis placement or automatic flipping.
62
+ * @param params.placement - Below-axis, beside without vertical flipping, or automatic flipping.
59
63
  * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
60
64
  * @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
61
65
  */
@@ -103,7 +107,10 @@ export const getBoundedPosition = ( {
103
107
  const upY = top - offsetTop - box.height;
104
108
  const downOverflow = downY + box.height - fit.bottom;
105
109
  const upOverflow = fit.top - upY;
106
- let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
110
+ let y = top;
111
+ if ( placement !== 'beside' ) {
112
+ y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
113
+ }
107
114
 
108
115
  x = clamp( x, bounds.left, bounds.right, box.width );
109
116
  y = clamp( y, bounds.top, bounds.bottom, box.height );
@@ -122,8 +129,7 @@ export const getBoundedPosition = ( {
122
129
  * alone would let the box spill into an `overflow: hidden` card and be cut off.
123
130
  *
124
131
  * Re-measures on every render, so a box whose content changes width between
125
- * two hovers is placed for its current size. See `getBoundedPosition` for the
126
- * below-axis exception.
132
+ * two hovers is placed for its current size.
127
133
  *
128
134
  * @param props - visx `Tooltip` props.
129
135
  * @param props.left - Anchor x, in wrapper coordinates.
@@ -133,7 +139,7 @@ export const getBoundedPosition = ( {
133
139
  * @param props.style - Box styles; visx's defaults unless `unstyled`.
134
140
  * @param props.unstyled - Skip `style` and leave the box bare.
135
141
  * @param props.children - Box content.
136
- * @param props.placement - Fixed below-axis placement or automatic flipping.
142
+ * @param props.placement - Below-axis, beside without vertical flipping, or automatic flipping.
137
143
  * @return The tooltip box.
138
144
  */
139
145
  export const BoundedTooltip = ( {
@@ -1,8 +1,10 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { useRef, useState } from 'react';
3
4
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
4
5
  import { GlobalChartsProvider } from '../../../providers';
5
6
  import { ChartScopeContext } from '../../../providers/chart-scope';
7
+ import { useKeyboardNavigation } from '../accessible-tooltip';
6
8
  import type { ReactNode } from 'react';
7
9
 
8
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.
@@ -124,3 +126,129 @@ describe( 'AccessibleTooltip', () => {
124
126
  await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
125
127
  } );
126
128
  } );
129
+
130
+ // Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
131
+ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
132
+ const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
133
+ const [ isNavigating, setIsNavigating ] = useState( false );
134
+ const chartRef = useRef< HTMLDivElement >( null );
135
+ const { onChartKeyDown } = useKeyboardNavigation( {
136
+ selectedIndex,
137
+ setSelectedIndex,
138
+ isNavigating,
139
+ setIsNavigating,
140
+ chartRef,
141
+ totalPoints,
142
+ } );
143
+
144
+ return (
145
+ <>
146
+ <div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
147
+ <div ref={ chartRef } data-testid="selected-index">
148
+ { selectedIndex ?? 'none' }
149
+ <button type="button">Inside</button>
150
+ </div>
151
+ </div>
152
+ <button type="button">Outside</button>
153
+ </>
154
+ );
155
+ };
156
+
157
+ describe( 'useKeyboardNavigation', () => {
158
+ const navigate = async ( presses: number ) => {
159
+ const user = userEvent.setup();
160
+ const view = render( <NavigationHarness totalPoints={ 6 } /> );
161
+
162
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
163
+ for ( let i = 0; i < presses; i++ ) {
164
+ await user.keyboard( '{ArrowRight}' );
165
+ }
166
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
167
+
168
+ return { user, view };
169
+ };
170
+
171
+ it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
172
+ const { view } = await navigate( 6 );
173
+
174
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
175
+
176
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
177
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
178
+ } );
179
+
180
+ // In range rather than past the end: the effect reconciles on any count change, not only an overflow.
181
+ it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
182
+ const { view } = await navigate( 2 );
183
+
184
+ screen.getByRole( 'button', { name: 'Outside' } ).focus();
185
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
186
+
187
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
188
+ } );
189
+
190
+ it( 'returns focus to the grid on Escape when there are no points', async () => {
191
+ const user = userEvent.setup();
192
+ render( <NavigationHarness totalPoints={ 0 } /> );
193
+
194
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
195
+ await user.keyboard( '{Escape}' );
196
+
197
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
198
+ } );
199
+
200
+ it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
201
+ const { view } = await navigate( 2 );
202
+
203
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
204
+ view.rerender( <NavigationHarness totalPoints={ 0 } /> );
205
+
206
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
207
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
208
+ } );
209
+
210
+ // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
211
+ const recordPageKeyDown = () => {
212
+ const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
213
+ document.addEventListener( 'keydown', onKeyDown );
214
+ return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
215
+ };
216
+
217
+ it( 'leaves keys it does not handle available to the page', async () => {
218
+ const user = userEvent.setup();
219
+ render( <NavigationHarness totalPoints={ 6 } /> );
220
+ const { onKeyDown, stop } = recordPageKeyDown();
221
+
222
+ try {
223
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
224
+ await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
225
+
226
+ expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
227
+ for ( const [ event ] of onKeyDown.mock.calls ) {
228
+ expect( event.defaultPrevented ).toBe( false );
229
+ }
230
+ } finally {
231
+ stop();
232
+ }
233
+ } );
234
+
235
+ // `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
236
+ it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
237
+ const user = userEvent.setup();
238
+ render( <NavigationHarness totalPoints={ 6 } /> );
239
+ const { onKeyDown, stop } = recordPageKeyDown();
240
+
241
+ try {
242
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
243
+ await user.keyboard( '{Escape}' );
244
+
245
+ expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
246
+
247
+ await user.keyboard( '{ArrowRight}{Escape}' );
248
+
249
+ expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
250
+ } finally {
251
+ stop();
252
+ }
253
+ } );
254
+ } );
@@ -5,6 +5,23 @@ const BOX = { width: 208, height: 36 };
5
5
  const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
6
6
 
7
7
  describe( 'getBoundedPosition', () => {
8
+ test.each( [
9
+ [ 40, 50 ],
10
+ [ 700, 425 ],
11
+ ] )( 'keeps a beside tooltip at its anchor and flips horizontally at %s', ( left, x ) => {
12
+ expect(
13
+ getBoundedPosition( {
14
+ left,
15
+ top: -20,
16
+ ...OFFSETS,
17
+ box: { width: 265, height: 382 },
18
+ wrapper: { left: 0, top: 0, right: 800, bottom: 300 },
19
+ bounds: { left: 0, top: -100, right: 800, bottom: 500 },
20
+ placement: 'beside',
21
+ } )
22
+ ).toEqual( { x, y: -20 } );
23
+ } );
24
+
8
25
  test( 'sits below and to the right of the anchor when that fits', () => {
9
26
  expect(
10
27
  getBoundedPosition( {
@@ -139,6 +139,13 @@ describe( 'XyChartTooltip', () => {
139
139
  } );
140
140
  } );
141
141
 
142
+ test( 'pins beside placement to the explicit SVG top anchor', async () => {
143
+ renderChart( { tooltipPlacement: 'beside', tooltipAnchorTop: 20, snapTooltipToDatumY: true } );
144
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
145
+ transform: 'translate(133px, 20px)',
146
+ } );
147
+ } );
148
+
142
149
  test( 'snaps the box to the nearest datum', async () => {
143
150
  renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
144
151