@automattic/charts 4.1.1 → 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 (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +875 -237
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +106 -6
  7. package/dist/index.d.ts +106 -6
  8. package/dist/index.js +875 -239
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/private/overlays.tsx +1 -2
  12. package/src/charts/bar-chart/bar-chart.tsx +67 -17
  13. package/src/charts/bar-chart/index.ts +1 -1
  14. package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
  15. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  16. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  17. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  18. package/src/charts/bar-chart/private/index.ts +2 -0
  19. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
  20. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  21. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  22. package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
  23. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  24. package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
  25. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  26. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  27. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  28. package/src/charts/geo-chart/types.ts +4 -2
  29. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  30. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  31. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  32. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  33. package/src/charts/heatmap-chart/index.ts +6 -0
  34. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  35. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  36. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  37. package/src/charts/heatmap-chart/private/index.ts +2 -0
  38. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  39. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  40. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  41. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  42. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  43. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  44. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  45. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  46. package/src/charts/heatmap-chart/types.ts +59 -3
  47. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  48. package/src/charts/leaderboard-chart/types.ts +13 -14
  49. package/src/charts/line-chart/line-chart.tsx +1 -1
  50. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  51. package/src/charts/line-chart/types.ts +2 -2
  52. package/src/charts/private/time-axis-options.ts +1 -1
  53. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  54. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  55. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  56. package/src/components/legend/private/base-legend.tsx +1 -1
  57. package/src/components/tooltip/accessible-tooltip.tsx +127 -40
  58. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  59. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  60. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  61. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  62. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  63. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  64. package/src/hooks/test/use-element-size.test.tsx +13 -13
  65. package/src/hooks/use-xychart-theme.ts +1 -1
  66. package/src/index.ts +7 -1
  67. package/src/providers/chart-context/themes.ts +1 -0
  68. package/src/styles/test/chart-scope.test.ts +1 -1
  69. package/src/types.ts +6 -13
  70. package/src/utils/format-metric-value.ts +4 -4
  71. package/src/utils/get-edge-tick-widths.ts +1 -1
  72. package/src/utils/get-styles.ts +1 -1
  73. 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,6 +107,14 @@ 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
  }
85
120
 
@@ -111,3 +146,24 @@ export type CalendarHeatmapOptions = {
111
146
  */
112
147
  timeZone?: string;
113
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
+ };
@@ -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
  );
@@ -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
 
@@ -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,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 = ( {