@automattic/charts 4.1.0 → 4.2.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 (80) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +7 -5
  3. package/dist/index.cjs +1115 -471
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +39 -1
  6. package/dist/index.d.cts +116 -16
  7. package/dist/index.d.ts +116 -16
  8. package/dist/index.js +1115 -473
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -11
  11. package/src/charts/area-chart/area-chart.tsx +2 -1
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +67 -17
  15. package/src/charts/bar-chart/index.ts +1 -1
  16. package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
  17. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  18. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  19. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  20. package/src/charts/bar-chart/private/index.ts +2 -0
  21. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
  22. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  23. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  24. package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
  25. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  26. package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
  27. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  28. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  29. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  30. package/src/charts/geo-chart/types.ts +4 -2
  31. package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +15 -45
  32. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  33. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  34. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  35. package/src/charts/heatmap-chart/index.ts +15 -3
  36. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  37. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  38. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  39. package/src/charts/heatmap-chart/private/index.ts +2 -2
  40. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  41. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +37 -1
  42. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  43. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  44. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  45. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  46. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
  47. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  48. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
  49. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  50. package/src/charts/heatmap-chart/types.ts +88 -3
  51. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
  52. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  53. package/src/charts/leaderboard-chart/types.ts +13 -14
  54. package/src/charts/line-chart/line-chart.tsx +3 -2
  55. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  56. package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
  57. package/src/charts/line-chart/types.ts +2 -2
  58. package/src/charts/private/time-axis-options.ts +1 -1
  59. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  60. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  61. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  62. package/src/components/legend/private/base-legend.tsx +1 -1
  63. package/src/components/tooltip/accessible-tooltip.tsx +146 -37
  64. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  65. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  66. package/src/components/tooltip/test/accessible-tooltip.test.tsx +160 -0
  67. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +29 -3
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +11 -11
  70. package/src/hooks/test/use-element-size.test.tsx +13 -13
  71. package/src/hooks/use-xychart-theme.ts +1 -1
  72. package/src/index.ts +7 -1
  73. package/src/providers/chart-context/themes.ts +1 -0
  74. package/src/styles/chart-scope.scss +11 -3
  75. package/src/styles/test/chart-scope.test.ts +36 -2
  76. package/src/types.ts +6 -13
  77. package/src/utils/format-metric-value.ts +4 -4
  78. package/src/utils/get-edge-tick-widths.ts +1 -1
  79. package/src/utils/get-styles.ts +1 -1
  80. package/src/visx/types.ts +7 -10
@@ -1,5 +1,5 @@
1
1
  import type { BaseChartProps } from '../../types';
2
- import type { ReactNode } from 'react';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
3
 
4
4
  /** A single heatmap cell. `value: null` marks an empty cell. */
5
5
  export type HeatmapCell = {
@@ -36,19 +36,46 @@ export type HeatmapColumn = {
36
36
  summary?: boolean;
37
37
  };
38
38
 
39
+ /** A run of consecutive columns sharing one label beneath the grid. */
40
+ export type HeatmapColumnGroup = {
41
+ label: string;
42
+ /** Columns covered; a positive integer. */
43
+ span: number;
44
+ };
45
+
39
46
  export type HeatmapTooltipData = {
40
47
  value: number | null;
41
48
  rowLabel?: string;
42
49
  columnLabel?: string;
50
+ /** Label of the column group the cell falls in, if any. */
51
+ groupLabel?: string;
43
52
  cellLabel?: string;
44
53
  row: number;
45
54
  column: number;
46
55
  };
47
56
 
48
- export interface HeatmapChartProps
49
- extends Omit< BaseChartProps< HeatmapColumn[] >, 'showLegend' | 'legend' | 'gridVisibility' > {
57
+ export interface HeatmapChartProps extends Omit<
58
+ BaseChartProps< HeatmapColumn[] >,
59
+ 'showLegend' | 'legend' | 'gridVisibility'
60
+ > {
50
61
  /** y-axis labels by row index. Empty entries render blank. */
51
62
  rowLabels?: string[];
63
+ /**
64
+ * Consecutive runs of columns sharing one label beneath the grid, set one
65
+ * group gap apart. Runs from the first column; columns past the last group
66
+ * stay ungrouped. Ignored, with a warning, when a span is not a positive
67
+ * integer or the spans reach past the last column.
68
+ */
69
+ columnGroups?: HeatmapColumnGroup[];
70
+ /**
71
+ * How the arrow keys move the selection. `grid` steps across the whole grid.
72
+ * `calendar` reads each column group as a page: Left/Right by one cell (wrapping
73
+ * to the next row, then the next group), Up/Down by one row into the neighboring
74
+ * group past the edge, Page Up/Down by one group. Default `grid`.
75
+ */
76
+ keyboardNavigation?: 'grid' | 'calendar';
77
+ /** Accessible name of the grid. Defaults to a localized "Heatmap chart". */
78
+ ariaLabel?: string;
52
79
  /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
53
80
  compact?: boolean;
54
81
  /** Render the numeric value inside each cell. Default `! compact`. */
@@ -80,5 +107,63 @@ export interface HeatmapChartProps
80
107
  */
81
108
  primaryColor?: string;
82
109
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
110
+ /**
111
+ * The tooltip box: `light` is the plain white box, `dark` the package's
112
+ * tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
113
+ * Default `light`.
114
+ */
115
+ tooltipVariant?: 'light' | 'dark';
116
+ /** Inline styles merged onto the tooltip box, over the variant's own. */
117
+ tooltipStyle?: CSSProperties;
83
118
  children?: ReactNode;
84
119
  }
120
+
121
+ export type CalendarHeatmapResult = {
122
+ data: HeatmapColumn[];
123
+ rowLabels: string[];
124
+ };
125
+
126
+ export type CalendarHeatmapOptions = {
127
+ weekStartsOn?: 0 | 1;
128
+ /**
129
+ * Mark the days completing the first and last week outside the series' span as
130
+ * hidden cells rather than blank ones, giving the calendar ragged edges. Days
131
+ * inside the span stay blank even where the series has no entry.
132
+ */
133
+ hideOutOfRangeDays?: boolean;
134
+ /**
135
+ * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
136
+ * Days inside the grid but outside the series become placeholder cells: painted,
137
+ * but reporting nothing, since they were never measured. A start bound opens from
138
+ * the beginning of its week, and bounds narrower than the series are ignored.
139
+ */
140
+ gridSpan?: { start?: string; end?: string };
141
+ /** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
142
+ locale?: string;
143
+ /**
144
+ * IANA zone the series' instants are bucketed into days in. Defaults to the
145
+ * runtime's zone. A `dateString` carrying no offset is taken as written.
146
+ */
147
+ timeZone?: string;
148
+ };
149
+
150
+ export type MonthCalendarHeatmapRange = {
151
+ /** First measured day, `yyyy-MM-dd`. */
152
+ start: string;
153
+ /** Last measured day, `yyyy-MM-dd`, inclusive. */
154
+ end: string;
155
+ };
156
+
157
+ export type MonthCalendarHeatmapOptions = {
158
+ /** 0 = Sunday, 1 = Monday. Default 1. */
159
+ weekStartsOn?: 0 | 1;
160
+ /** BCP-47 tag the month and day labels are written in. Defaults to the runtime's locale. */
161
+ locale?: string;
162
+ };
163
+
164
+ export type MonthCalendarHeatmapResult = {
165
+ /** For `HeatmapChart`'s `data`: 7 columns per month, 6 cells each. */
166
+ data: HeatmapColumn[];
167
+ /** For `HeatmapChart`'s `columnGroups`: one per month, in order. */
168
+ columnGroups: HeatmapColumnGroup[];
169
+ };
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useChartFormatting } from '../../providers';
3
- import { buildCalendarHeatmapData } from './private';
4
- import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
3
+ import { buildCalendarHeatmapData } from './build-calendar-data';
4
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './types';
5
5
  import type { DataPointDate } from '../../types';
6
6
 
7
7
  /**
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+ import { useChartFormatting } from '../../providers';
3
+ import { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
4
+ import type {
5
+ MonthCalendarHeatmapOptions,
6
+ MonthCalendarHeatmapRange,
7
+ MonthCalendarHeatmapResult,
8
+ } from './types';
9
+
10
+ /**
11
+ * `buildMonthCalendarHeatmapData` with the locale taken from `GlobalChartsProvider`
12
+ * where the caller names none.
13
+ *
14
+ * @param valueByDay - Value per `yyyy-MM-dd`. Held by reference, so a caller that rebuilds it each render defeats the memo.
15
+ * @param range - As for `buildMonthCalendarHeatmapData`.
16
+ * @param options - As for `buildMonthCalendarHeatmapData`; `locale` wins over the provider.
17
+ * @return Columns and groups for `HeatmapChart`.
18
+ */
19
+ export const useMonthCalendarHeatmapData = (
20
+ valueByDay: Record< string, number | null >,
21
+ range: MonthCalendarHeatmapRange,
22
+ options: MonthCalendarHeatmapOptions = {}
23
+ ): MonthCalendarHeatmapResult => {
24
+ const formatting = useChartFormatting();
25
+ const locale = options.locale ?? formatting.locale;
26
+ const { weekStartsOn } = options;
27
+ const { start, end } = range;
28
+
29
+ // Spread into primitives: the option and range objects are rebuilt on each render.
30
+ return useMemo(
31
+ () => buildMonthCalendarHeatmapData( valueByDay, { start, end }, { locale, weekStartsOn } ),
32
+ [ valueByDay, start, end, locale, weekStartsOn ]
33
+ );
34
+ };
@@ -1,20 +1,19 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { BaseChartProps, LeaderboardEntry } from '../../types';
3
3
 
4
- export interface LeaderboardChartProps
5
- extends Pick<
6
- BaseChartProps< LeaderboardEntry >,
7
- | 'className'
8
- | 'data'
9
- | 'showLegend'
10
- | 'legend'
11
- | 'chartId'
12
- | 'width'
13
- | 'height'
14
- | 'size'
15
- | 'gap'
16
- | 'animation'
17
- > {
4
+ export interface LeaderboardChartProps extends Pick<
5
+ BaseChartProps< LeaderboardEntry >,
6
+ | 'className'
7
+ | 'data'
8
+ | 'showLegend'
9
+ | 'legend'
10
+ | 'chartId'
11
+ | 'width'
12
+ | 'height'
13
+ | 'size'
14
+ | 'gap'
15
+ | 'animation'
16
+ > {
18
17
  /**
19
18
  * Whether to show comparison data
20
19
  */
@@ -505,7 +505,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
505
505
  background: tooltipStyle?.background,
506
506
  backgroundColor: tooltipStyle?.backgroundColor,
507
507
  }
508
- )
508
+ )
509
509
  : renderTooltip( { ...params, bucketInfo } ),
510
510
  [ renderTooltip, bucketInfo, tooltipStyle ]
511
511
  );
@@ -562,6 +562,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
562
562
 
563
563
  return (
564
564
  <div
565
+ ref={ chartRef }
565
566
  role="grid"
566
567
  aria-label={ __( 'Line chart', 'jetpack-charts' ) }
567
568
  tabIndex={ 0 }
@@ -570,7 +571,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
570
571
  onBlur={ onChartBlur }
571
572
  >
572
573
  { chartHeight > 0 && (
573
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
574
+ <div className={ plotStyles[ 'xy-plot' ] }>
574
575
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
575
576
  <XYChart
576
577
  theme={ theme }
@@ -269,7 +269,7 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
269
269
  }px)`,
270
270
  width: labelWidth,
271
271
  height: labelHeight,
272
- }
272
+ }
273
273
  : undefined;
274
274
  };
275
275
 
@@ -204,6 +204,37 @@ describe( 'LineChart', () => {
204
204
  );
205
205
  } );
206
206
 
207
+ test.each( [ [ 'Escape', '{Escape}' ] ] )(
208
+ 'returns focus to the grid after %s',
209
+ async ( _name, keys ) => {
210
+ jest.useFakeTimers();
211
+ try {
212
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
213
+ renderWithTheme();
214
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
215
+
216
+ await user.tab();
217
+ expect( chart ).toHaveFocus();
218
+ await user.keyboard( '{ArrowRight}' );
219
+ expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
220
+
221
+ await user.keyboard( keys );
222
+ await act( async () => {
223
+ jest.advanceTimersByTime( 5000 );
224
+ } );
225
+ expect( chart ).toHaveFocus();
226
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
227
+
228
+ await user.keyboard( '{ArrowRight}' );
229
+ const tooltip = screen.getByRole( 'tooltip' );
230
+ expect( tooltip ).toHaveFocus();
231
+ expect( tooltip ).toHaveTextContent( 'Series A:10' );
232
+ } finally {
233
+ jest.useRealTimers();
234
+ }
235
+ }
236
+ );
237
+
207
238
  describe( 'Data Validation', () => {
208
239
  test( 'handles empty data array', () => {
209
240
  renderWithTheme( { data: [] } );
@@ -8,7 +8,7 @@ import type {
8
8
  AnnotationStyles,
9
9
  DataPoint,
10
10
  } from '../../types';
11
- import type { CrosshairStyle, RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
11
+ import type { CrosshairStyle, RenderTooltipParams } from '../../visx/types';
12
12
  import type { GlyphProps } from '@visx/xychart';
13
13
  import type { ReactNode, SVGProps, FC, CSSProperties } from 'react';
14
14
 
@@ -45,7 +45,7 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
45
45
  * Place the panel below the x-axis tick labels with a pointer at the datum x; horizontal bounds still apply.
46
46
  * @default 'auto'
47
47
  */
48
- tooltipPlacement?: XyChartTooltipProps< DataPointDate >[ 'tooltipPlacement' ];
48
+ tooltipPlacement?: 'auto' | 'below-axis';
49
49
  /**
50
50
  * Inline container styles; see Below-Axis Tooltips in stories/index.docs.mdx for content color overrides.
51
51
  */
@@ -83,7 +83,7 @@ export const buildTimeAxisOptions = ( {
83
83
  effectiveDomain,
84
84
  ownFormatter,
85
85
  callerNumTicks ?? getMaxTicksForWidth( width )
86
- );
86
+ );
87
87
 
88
88
  // An empty selection means the domain holds no point to tick, not that the
89
89
  // axis wants no ticks: visx reads `[]` as the latter and labels nothing.
@@ -94,7 +94,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
94
94
  // Cap the available width at maxWidth unless an explicit width pins it. Before
95
95
  // measurement resolves, fall back to the explicit width.
96
96
  const cap = width === undefined ? maxWidth : Infinity;
97
- const availableWidth = parentWidth > 0 ? Math.min( parentWidth, cap ) : width ?? 0;
97
+ const availableWidth = parentWidth > 0 ? Math.min( parentWidth, cap ) : ( width ?? 0 );
98
98
 
99
99
  let boxWidth = availableWidth;
100
100
  let boxHeight: number;
@@ -107,7 +107,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
107
107
  boxHeight = derivedHeight;
108
108
  }
109
109
  } else {
110
- boxHeight = parentHeight > 0 ? parentHeight : height ?? 0;
110
+ boxHeight = parentHeight > 0 ? parentHeight : ( height ?? 0 );
111
111
  }
112
112
 
113
113
  // Decide containment from the wrapper's real height, measured after layout.
@@ -130,9 +130,7 @@ describe( 'useXZoom', () => {
130
130
  } );
131
131
 
132
132
  test( 'leaves domain null when the X scale has no invert function', () => {
133
- const chartRef = makeChartRef( {
134
- /* no invert */
135
- } );
133
+ const chartRef = makeChartRef( { /* no invert */ } );
136
134
  const { result } = renderHook( () => useXZoom< number >( { enabled: true, chartRef } ) );
137
135
 
138
136
  act( () => result.current.handlers.onPointerDown( makeParams( 100 ) ) );
@@ -79,8 +79,7 @@ export function useXZoom< T extends Date | number = Date >( {
79
79
  const hi = Math.max( finalDrag.a, finalDrag.b );
80
80
  if ( hi - lo < MIN_DRAG_PIXELS ) return;
81
81
  const xScale = chartRef.current?.getScales()?.xScale as
82
- | ( AxisScale & { invert?: ( v: number ) => T } )
83
- | undefined;
82
+ ( AxisScale & { invert?: ( v: number ) => T } ) | undefined;
84
83
  if ( ! xScale || typeof xScale.invert !== 'function' ) return;
85
84
  setDomain( [ xScale.invert( lo ), xScale.invert( hi ) ] );
86
85
  },
@@ -75,7 +75,7 @@ const getLegendItemAriaLabel = (
75
75
  _x( '%1$s, %2$s', 'legend item label and value', 'jetpack-charts' ),
76
76
  text,
77
77
  String( value )
78
- )
78
+ )
79
79
  : text;
80
80
 
81
81
  if ( interactive ) {
@@ -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
  }
@@ -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,27 +256,90 @@ 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
 
230
- // On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
231
- const onChartFocus = useCallback( () => {
232
- if ( ! isNavigating && selectedIndex !== undefined ) {
233
- setSelectedIndex( 0 );
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;
234
318
  }
235
- }, [ isNavigating, selectedIndex, setSelectedIndex ] );
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
+
331
+ // Returning focus from the tooltip must not restore the selection Escape just cleared.
332
+ const onChartFocus = useCallback(
333
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
334
+ if ( event.currentTarget.contains( event.relatedTarget ) ) {
335
+ return;
336
+ }
337
+ if ( ! isNavigating && selectedIndex !== undefined ) {
338
+ setSelectedIndex( 0 );
339
+ }
340
+ },
341
+ [ isNavigating, selectedIndex, setSelectedIndex ]
342
+ );
236
343
 
237
344
  // On each blur of chart, keyboard navigation should restart from first tooltip
238
345
  const onChartBlur = useCallback( () => {
@@ -241,42 +348,44 @@ export const useKeyboardNavigation = ( {
241
348
 
242
349
  const onChartKeyDown = useCallback(
243
350
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
244
- if ( totalPoints === 0 ) return;
245
-
246
- // Keep focus on the chart if tab is pressed
247
- if ( event.key === 'Tab' ) {
248
- 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();
355
+ }
356
+ focusWithoutScrollIfNeeded( getChartRoot() );
249
357
  setSelectedIndex( undefined );
250
358
  setIsNavigating( false );
251
359
  return;
252
360
  }
253
361
 
254
- const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
255
-
256
- if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
257
- chartRef.current?.focus();
258
- setSelectedIndex( undefined );
259
- setIsNavigating( false );
260
- return;
261
- }
362
+ if ( totalPoints === 0 ) return;
262
363
 
263
- event.preventDefault();
364
+ const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
264
365
 
265
- 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();
266
369
  setIsNavigating( true );
267
- setSelectedIndex( ( currentSelectedIndex + 1 ) % totalPoints );
268
- } else if ( [ 'ArrowLeft' ].includes( event.key ) ) {
370
+ setSelectedIndex( Math.min( currentSelectedIndex + 1, totalPoints - 1 ) );
371
+ } else if ( event.key === 'ArrowLeft' ) {
372
+ event.preventDefault();
269
373
  setIsNavigating( true );
270
- setSelectedIndex( ( currentSelectedIndex - 1 + totalPoints ) % totalPoints );
271
- } else if ( event.key === 'Escape' ) {
272
- setSelectedIndex( undefined );
273
- setIsNavigating( false );
274
- chartRef.current?.focus();
374
+ setSelectedIndex( Math.max( currentSelectedIndex - 1, 0 ) );
275
375
  } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
376
+ event.preventDefault();
276
377
  onActivate?.( selectedIndex );
277
378
  }
278
379
  },
279
- [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
380
+ [
381
+ totalPoints,
382
+ selectedIndex,
383
+ setSelectedIndex,
384
+ setIsNavigating,
385
+ getChartRoot,
386
+ focusWithoutScrollIfNeeded,
387
+ onActivate,
388
+ ]
280
389
  );
281
390
 
282
391
  return {
@@ -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
  }