@automattic/charts 4.6.0 → 4.8.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/TOKENS.md +15 -10
  3. package/dist/index.cjs +427 -211
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +40 -26
  6. package/dist/index.d.cts +69 -29
  7. package/dist/index.d.ts +69 -29
  8. package/dist/index.js +427 -213
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
  20. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
  21. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  22. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
  23. package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
  24. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  25. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  26. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  27. package/src/charts/heatmap-chart/private/index.ts +2 -1
  28. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  29. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
  30. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  31. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  32. package/src/charts/heatmap-chart/types.ts +6 -9
  33. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  34. package/src/charts/line-chart/line-chart.module.scss +0 -5
  35. package/src/charts/line-chart/line-chart.tsx +10 -42
  36. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  37. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  38. package/src/charts/pie-chart/pie-chart.tsx +26 -80
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  42. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  43. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  44. package/src/charts/private/label-text-color.ts +116 -0
  45. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  46. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  47. package/src/charts/private/test/label-text-color.test.ts +189 -0
  48. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  49. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  50. package/src/components/tooltip/base-tooltip.tsx +31 -30
  51. package/src/components/tooltip/index.ts +3 -1
  52. package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
  53. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  54. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  55. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  56. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  57. package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
  58. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  59. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  60. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  61. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
  62. package/src/components/tooltip/tooltip-box.module.scss +21 -0
  63. package/src/components/tooltip/tooltip-box.tsx +43 -0
  64. package/src/components/tooltip/types.ts +7 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
  66. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  69. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  70. package/src/hooks/use-chart-margin.tsx +21 -18
  71. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  72. package/src/hooks/use-xychart-theme.ts +4 -12
  73. package/src/index.ts +8 -3
  74. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  75. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
  76. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  77. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  78. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  79. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  81. package/src/styles/chart-scope-class.ts +5 -0
  82. package/src/styles/chart-scope.scss +10 -6
  83. package/src/styles/test/chart-scope.test.ts +3 -3
  84. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  85. package/src/utils/color-utils.ts +2 -1
  86. package/src/utils/test/date-formatting.test.ts +0 -10
  87. package/src/visx/types.ts +1 -1
  88. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
  89. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  90. package/src/components/tooltip/base-tooltip.module.scss +0 -20
  91. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -1,326 +1,436 @@
1
- import { Tooltip, defaultStyles } from '@visx/tooltip';
2
- import { DataContext, TooltipContext } from '@visx/xychart';
3
- import { useCallback, useContext, useState } from 'react';
4
- import { createPortal } from 'react-dom';
5
- import { isValidHexColor } from '../../utils';
6
- import { BoundedTooltip, TOOLTIP_Z_INDEX } from './private/bounded-tooltip';
7
- import type {
8
- CrosshairStyle,
9
- RenderTooltipGlyphProps,
10
- XyChartTooltipProps,
11
- } from '../../visx/types';
12
- import type { TooltipContextType } from '@visx/xychart';
13
- import type { CSSProperties, ReactNode } from 'react';
14
-
15
- const CROSSHAIR_STROKE_WIDTH = 1.5;
16
- const CROSSHAIR_PAINT_PROPERTIES = new Set( [
17
- 'stroke',
18
- 'strokeWidth',
19
- 'strokeOpacity',
20
- 'strokeDasharray',
21
- 'strokeLinecap',
22
- 'opacity',
23
- ] );
24
-
25
- const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
26
- const pickPaint = ( value: object ) =>
27
- Object.fromEntries(
28
- Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
29
- );
1
+ import { TooltipContext } from '@visx/xychart';
2
+ import clsx from 'clsx';
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';
6
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
7
+ import { useChartScopeElement, useStandaloneScopeClass } from '../../providers/chart-scope';
8
+ import { resolveCssVariable } from '../../utils';
9
+ import { XyChartTooltipOverlay } from './private/xy-chart-tooltip-overlay';
10
+ import type { SeriesData, DataPointDate } from '../../types';
11
+ import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
12
+ import type { ReactNode } from 'react';
30
13
 
31
- return {
32
- ...pickPaint( props ),
33
- ...( props.className === undefined ? {} : { className: props.className } ),
34
- ...( props.style === undefined ? {} : { style: pickPaint( props.style ) } ),
35
- };
36
- };
37
- const DEFAULT_GLYPH_RADIUS = 4;
38
- const FALLBACK_COLOR = '#222';
39
- // Portal-era options, accepted for compatibility and dropped before the box
40
- // renders: it sits in the chart wrapper and moves with it, and nothing
41
- // measures it any more.
42
- const PORTAL_OPTIONS = new Set( [ 'scroll', 'debounce', 'resizeObserverPolyfill' ] );
43
-
44
- type ScaleFn = ( value: unknown ) => number | undefined;
45
-
46
- const isValidNumber = ( value: unknown ): value is number =>
47
- typeof value === 'number' && Number.isFinite( value );
48
-
49
- // Band scales place a datum at the start of its band; snapping targets the band's centre.
50
- // A d3 scale is a function that carries `bandwidth` as a property.
51
- const scaleBandwidth = ( scale: unknown ): number => {
52
- const bandwidth = ( scale as { bandwidth?: unknown } | null | undefined )?.bandwidth;
53
- return typeof bandwidth === 'function' ? Number( bandwidth() ) : 0;
14
+ // Type for flattened tooltip data used in individual mode
15
+ export type FlattenedTooltipData = {
16
+ datum: DataPointDate;
17
+ seriesLabel: string;
18
+ seriesIndex: number;
19
+ dataPointIndex: number;
54
20
  };
55
21
 
56
- const DefaultGlyph = < Datum extends object >( {
57
- x,
58
- y,
59
- size,
60
- color,
61
- glyphStyle,
62
- seriesKey,
63
- }: Omit< RenderTooltipGlyphProps< Datum >, 'key' > & { seriesKey: string } ) => {
64
- const { theme } = useContext( DataContext ) || {};
65
-
66
- return (
67
- <circle
68
- data-testid={ `xy-chart-tooltip-glyph-${ seriesKey }` }
69
- cx={ x }
70
- cy={ y }
71
- r={ size }
72
- fill={ color }
73
- stroke={ theme?.backgroundColor }
74
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
75
- paintOrder="fill"
76
- { ...glyphStyle }
77
- />
78
- );
79
- };
80
-
81
- const defaultRenderGlyph = < Datum extends object >( {
82
- key,
83
- ...props
84
- }: RenderTooltipGlyphProps< Datum > ) => (
85
- <DefaultGlyph key={ key } seriesKey={ key } { ...props } />
86
- );
87
-
88
- type XyChartTooltipContentProps< Datum extends object > = XyChartTooltipProps< Datum > & {
89
- tooltipContext: TooltipContextType< Datum >;
90
- /** The SVG's HTML parent once found. The box waits for it; the SVG overlay does not. */
91
- container: HTMLElement | null;
92
- };
22
+ /** The XY chart tooltip that LineChart, AreaChart and BarChart render; place it inside a visx `XYChart`. */
23
+ interface XYChartTooltipProps extends Omit<
24
+ XyChartTooltipProps< DataPointDate >,
25
+ 'renderTooltip'
26
+ > {
27
+ renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
28
+ selectedIndex?: number | undefined;
29
+ tooltipRef?: ( element: HTMLDivElement | null ) => void;
30
+ keyboardFocusedClassName?: string;
31
+ /**
32
+ * Flattened tooltip data prepared by parent component
33
+ * Each index corresponds to one tooltip to show
34
+ */
35
+ tooltipData?: FlattenedTooltipData[];
36
+ /**
37
+ * For line charts: series data to show all series at selected data point
38
+ * When provided, shows all series instead of individual tooltips
39
+ */
40
+ series?: SeriesData[];
41
+ /**
42
+ * Whether to combine tooltip information from multiple series into a single tooltip. This is useful for line charts.
43
+ * Or to show individual tooltips for each series. This is useful for bar charts.
44
+ */
45
+ mode?: 'individual' | 'group';
46
+ }
93
47
 
94
- const XyChartTooltipContent = < Datum extends object >( {
95
- tooltipContext,
96
- container,
48
+ export const XYChartTooltip: React.FC< XYChartTooltipProps > = ( {
97
49
  renderTooltip,
98
- renderGlyph = defaultRenderGlyph,
99
- glyphStyle,
100
- snapTooltipToDatumX = false,
101
- snapTooltipToDatumY = false,
102
- showVerticalCrosshair = false,
103
- showHorizontalCrosshair = false,
104
- showDatumGlyph = false,
105
- showSeriesGlyphs = false,
50
+ selectedIndex,
51
+ tooltipRef,
52
+ keyboardFocusedClassName,
53
+ series = [],
54
+ mode = 'group',
106
55
  verticalCrosshairStyle,
107
56
  horizontalCrosshairStyle,
108
- detectBounds = true,
109
- tooltipPlacement = 'auto',
110
- tooltipAnchorTop,
111
- zIndex = TOOLTIP_Z_INDEX,
112
- style,
113
- ...rest
114
- }: XyChartTooltipContentProps< Datum > ) => {
115
- const tooltipProps = Object.fromEntries(
116
- Object.entries( rest ).filter( ( [ key ] ) => ! PORTAL_OPTIONS.has( key ) )
117
- ) as Omit< typeof rest, 'scroll' | 'debounce' | 'resizeObserverPolyfill' >;
118
- const {
119
- colorScale,
120
- theme,
121
- innerHeight = 0,
122
- innerWidth = 0,
123
- margin,
124
- xScale,
125
- yScale,
126
- dataRegistry,
127
- } = useContext( DataContext ) || {};
128
-
129
- const tooltipContent = renderTooltip ? renderTooltip( { ...tooltipContext, colorScale } ) : null;
130
- if ( tooltipContent == null ) {
131
- return null;
132
- }
133
-
134
- const scaleX = xScale as ScaleFn | undefined;
135
- const scaleY = yScale as ScaleFn | undefined;
136
-
137
- // Datum position in SVG coordinates, from the accessors the series registered.
138
- const getDatumLeftTop = ( key: string, datum: Datum ) => {
139
- const entry = dataRegistry?.get( key );
140
- const left =
141
- scaleX && entry?.xAccessor
142
- ? Number( scaleX( entry.xAccessor( datum ) ) ) + scaleBandwidth( xScale ) / 2
143
- : undefined;
144
- const top =
145
- scaleY && entry?.yAccessor
146
- ? Number( scaleY( entry.yAccessor( datum ) ) ) + scaleBandwidth( yScale ) / 2
147
- : undefined;
148
- return { left, top };
149
- };
57
+ ...props
58
+ } ) => {
59
+ const tooltipContext = useContext( TooltipContext );
60
+ const scopeElement = useChartScopeElement();
150
61
 
151
- const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
152
- let { tooltipLeft, tooltipTop } = tooltipContext;
62
+ // The stroke is read at the scope element, which a consumer can set outside the chart's own ancestors; see TOKENS.md#the-svg-bridge.
63
+ const crosshairStroke = useMemo( () => {
64
+ const stroke = resolveCssVariable( CATALOG_POINTERS.grid, scopeElement );
153
65
 
154
- if (
155
- nearestDatum &&
156
- ( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
157
- ) {
158
- const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
159
- if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
160
- tooltipLeft = left;
161
- }
162
- if ( snapTooltipToDatumY && isValidNumber( top ) ) {
163
- tooltipTop = top;
66
+ // Passing `stroke: undefined` would erase the crosshair: it overrides visx's own value, and SVG's initial `stroke` is `none`.
67
+ return stroke ? { stroke } : undefined;
68
+ }, [ scopeElement ] );
69
+
70
+ const standaloneScopeClass = useStandaloneScopeClass();
71
+
72
+ const tooltipData = useMemo( () => {
73
+ if ( mode !== 'individual' ) return [];
74
+ if ( series.length === 0 ) return [];
75
+
76
+ const maxDataPoints = Math.max( ...series.map( s => s.data.length ) );
77
+ const flattened: Array< {
78
+ datum: DataPointDate;
79
+ seriesLabel: string;
80
+ seriesIndex: number;
81
+ dataPointIndex: number;
82
+ } > = [];
83
+
84
+ // Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
85
+ for ( let dataPointIndex = 0; dataPointIndex < maxDataPoints; dataPointIndex++ ) {
86
+ for ( let seriesIndex = 0; seriesIndex < series.length; seriesIndex++ ) {
87
+ const seriesData = series[ seriesIndex ];
88
+ if ( dataPointIndex < seriesData.data.length ) {
89
+ flattened.push( {
90
+ datum: seriesData.data[ dataPointIndex ] as DataPointDate,
91
+ seriesLabel: seriesData.label,
92
+ seriesIndex,
93
+ dataPointIndex,
94
+ } );
95
+ }
96
+ }
164
97
  }
165
- }
166
-
167
- const glyphs: ReactNode[] = [];
168
- if ( showDatumGlyph || showSeriesGlyphs ) {
169
- const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
170
- const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
171
- // visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
172
- const fallbackColor = showSeriesGlyphs
173
- ? labelColor
174
- : ( theme?.gridStyles?.stroke ?? labelColor );
175
- let entries: Array< { key: string; datum: Datum; index: number } > = [];
176
- if ( showSeriesGlyphs ) {
177
- entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
178
- } else if ( nearestDatum ) {
179
- entries = [ nearestDatum ];
98
+
99
+ return flattened;
100
+ }, [ series, mode ] );
101
+
102
+ // Tracks whether this effect opened a tooltip, so it only closes its own.
103
+ const hasKeyboardSelection = useRef( false );
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();
180
136
  }
137
+ }, [ hiddenSeriesKey, selectedIndex, closeTooltipNow ] );
181
138
 
182
- for ( const { key, datum, index } of entries ) {
183
- const { left, top } = getDatumLeftTop( key, datum );
184
- if ( ! isValidNumber( left ) || ! isValidNumber( top ) ) {
185
- continue;
139
+ // Handle tooltip highlighting for keyboard navigation
140
+ useEffect( () => {
141
+ if ( selectedIndex === undefined ) {
142
+ // visx debounces the hide and cancels only the most recently scheduled one,
143
+ // so hiding on every run leaves earlier hides pending. One of those lands
144
+ // mid-navigation and closes the tooltip the user is reading.
145
+ if ( hasKeyboardSelection.current ) {
146
+ hasKeyboardSelection.current = false;
147
+ closeTooltipNow();
186
148
  }
187
- // The group carries the position and the renderer draws at the origin,
188
- // as with visx's portal: a renderer that ignores `x` and `y` still lands
189
- // on its datum.
190
- glyphs.push(
191
- <g
192
- key={ key }
193
- className="visx-tooltip-glyph"
194
- data-testid={ `xy-chart-tooltip-glyph-group-${ key }` }
195
- transform={ `translate(${ left }, ${ top })` }
196
- >
197
- { renderGlyph( {
198
- key,
199
- color: colorScale?.( key ) ?? fallbackColor,
149
+ return;
150
+ }
151
+
152
+ hasKeyboardSelection.current = true;
153
+
154
+ if ( mode === 'group' ) {
155
+ // Show all series at the selected data point index in single tooltip.
156
+ series.forEach( ( s, index ) => {
157
+ if ( selectedIndex < s.data.length ) {
158
+ const datum = s.data[ selectedIndex ];
159
+
160
+ tooltipContext?.showTooltip( {
200
161
  datum,
162
+ key: s.label,
201
163
  index,
202
- size,
203
- x: 0,
204
- y: 0,
205
- glyphStyle,
206
- isNearestDatum: nearestDatum?.key === key,
207
- } ) }
208
- </g>
209
- );
164
+ } );
165
+ }
166
+ } );
167
+ } else if ( mode === 'individual' ) {
168
+ // Show individual tooltips for each datapoint from each series.
169
+ if ( selectedIndex < tooltipData.length ) {
170
+ const tooltipItem = tooltipData[ selectedIndex ];
171
+
172
+ tooltipContext?.showTooltip( {
173
+ datum: tooltipItem.datum,
174
+ key: tooltipItem.seriesLabel,
175
+ index: tooltipItem.dataPointIndex,
176
+ } );
177
+ }
210
178
  }
211
- }
212
-
213
- const crosshairStroke = theme?.gridStyles?.stroke ?? theme?.htmlLabel?.color ?? FALLBACK_COLOR;
214
- const marginTop = margin?.top ?? 0;
215
- const marginLeft = margin?.left ?? 0;
216
-
217
- const TooltipComponent = detectBounds || tooltipPlacement !== 'auto' ? BoundedTooltip : Tooltip;
218
- const boxStyle: CSSProperties = {
219
- ...defaultStyles,
220
- zIndex,
221
- backgroundColor: theme?.backgroundColor ?? 'white',
222
- boxShadow: `0 1px 2px ${
223
- isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
224
- }`,
225
- ...theme?.htmlLabel,
226
- ...style,
227
- };
228
179
 
229
- return (
230
- <>
231
- <g className="visx-tooltip-overlay" pointerEvents="none">
232
- { showVerticalCrosshair && isValidNumber( tooltipLeft ) && (
233
- <line
234
- className="visx-crosshair visx-crosshair-vertical"
235
- data-testid="xy-chart-tooltip-crosshair-vertical"
236
- stroke={ crosshairStroke }
237
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
238
- { ...crosshairPaintProps( verticalCrosshairStyle ) }
239
- x1={ tooltipLeft }
240
- x2={ tooltipLeft }
241
- y1={ marginTop }
242
- y2={ marginTop + innerHeight }
243
- />
244
- ) }
245
- { showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
246
- <line
247
- className="visx-crosshair visx-crosshair-horizontal"
248
- data-testid="xy-chart-tooltip-crosshair-horizontal"
249
- stroke={ crosshairStroke }
250
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
251
- { ...crosshairPaintProps( horizontalCrosshairStyle ) }
252
- x1={ marginLeft }
253
- x2={ marginLeft + innerWidth }
254
- y1={ tooltipTop }
255
- y2={ tooltipTop }
256
- />
257
- ) }
258
- { glyphs }
259
- </g>
260
- { container &&
261
- createPortal(
262
- <TooltipComponent
263
- left={ tooltipLeft }
264
- top={
265
- tooltipPlacement === 'below-axis'
266
- ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
267
- : ( tooltipAnchorTop ?? tooltipTop )
268
- }
269
- style={ boxStyle }
270
- applyPositionStyle
271
- { ...tooltipProps }
272
- { ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
180
+ // Don't include tooltipContext in the dependency array to avoid loop.
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ }, [ selectedIndex, tooltipData, series ] );
183
+
184
+ // Create a focusable renderTooltip that includes accessibility features
185
+ const focusableRenderTooltip = useMemo( () => {
186
+ if ( ! renderTooltip ) return undefined;
187
+
188
+ return ( params: RenderTooltipParams< DataPointDate > ) => {
189
+ const tooltipContent = renderTooltip( params );
190
+
191
+ if ( selectedIndex !== undefined ) {
192
+ return (
193
+ <div
194
+ ref={ tooltipRef }
195
+ tabIndex={ -1 }
196
+ role="tooltip"
197
+ aria-atomic="true"
198
+ className={ clsx( standaloneScopeClass, keyboardFocusedClassName ) }
199
+ data-testid={ `chart-tooltip-${ selectedIndex }` }
200
+ key={ `chart-tooltip-${ selectedIndex }` }
273
201
  >
274
202
  { tooltipContent }
275
- </TooltipComponent>,
276
- container
277
- ) }
278
- </>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ return (
208
+ <div className={ standaloneScopeClass } role="tooltip" aria-live="polite">
209
+ { tooltipContent }
210
+ </div>
211
+ );
212
+ };
213
+ }, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName, standaloneScopeClass ] );
214
+
215
+ return (
216
+ <XyChartTooltipOverlay
217
+ { ...props }
218
+ verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
219
+ horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
220
+ renderTooltip={ focusableRenderTooltip }
221
+ />
279
222
  );
280
223
  };
281
224
 
282
- /**
283
- * In-tree replacement for `@visx/xychart`'s `Tooltip`.
284
- *
285
- * visx renders its tooltip box, glyphs and crosshairs through portals appended
286
- * to `document.body`. That puts them in the page's root stacking context, where
287
- * they paint above every sticky or fixed element that lives inside a nested
288
- * stacking context, and no z-index on that element can change the order. This
289
- * component draws the glyphs and crosshairs straight into the chart SVG and
290
- * renders the tooltip box into the SVG's parent element, so all of them stack
291
- * as ordinary descendants of the chart.
292
- *
293
- * Render it as a child of `XYChart`. The element wrapping that `XYChart` must
294
- * be `position: relative` with the SVG at its origin, and `isolation: isolate`:
295
- * the box is placed with the SVG-local coordinates visx reports, and the
296
- * isolation keeps the box's `zIndex` from competing with page chrome. The box
297
- * flips and clamps to stay inside the nearest ancestor that clips its overflow
298
- * (or the viewport), so it may extend past the chart wrapper but is never cut
299
- * off unless that ancestor is smaller than the box itself. Below-axis placement
300
- * pins it beneath the x-axis and applies horizontal bounds only.
301
- *
302
- * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
303
- * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
304
- */
305
- export const XyChartTooltip = < Datum extends object >( props: XyChartTooltipProps< Datum > ) => {
306
- const tooltipContext = useContext( TooltipContext ) as TooltipContextType< Datum > | null;
307
- const [ container, setContainer ] = useState< HTMLElement | null >( null );
308
-
309
- // Children of XYChart render inside its SVG, and the box needs an HTML parent.
310
- const anchorRef = useCallback( ( node: SVGGElement | null ) => {
311
- setContainer( node?.ownerSVGElement?.parentElement ?? null );
312
- }, [] );
225
+ /** @deprecated Use `XYChartTooltip`. */
226
+ export const AccessibleTooltip = XYChartTooltip;
313
227
 
314
- return (
315
- <>
316
- <g ref={ anchorRef } data-testid="xy-chart-tooltip-anchor" />
317
- { tooltipContext?.tooltipOpen && (
318
- <XyChartTooltipContent
319
- { ...props }
320
- tooltipContext={ tooltipContext }
321
- container={ container }
322
- />
323
- ) }
324
- </>
228
+ // Keyboard navigation hook for charts
229
+ interface UseKeyboardNavigationProps {
230
+ selectedIndex: number | undefined;
231
+ setSelectedIndex: ( index: number | undefined ) => void;
232
+ isNavigating: boolean;
233
+ setIsNavigating: ( navigating: boolean ) => void;
234
+ chartRef: React.RefObject< HTMLDivElement >;
235
+ /**
236
+ * Total number of navigation points (length of tooltip data array)
237
+ */
238
+ totalPoints: number;
239
+ /**
240
+ * Called with the selected index on Enter or Space, so a chart can treat the
241
+ * keyboard selection the way it treats a click.
242
+ */
243
+ onActivate?: ( index: number ) => void;
244
+ preventTooltipScroll?: boolean;
245
+ /**
246
+ * Identity of the series the navigation indexes run over, for charts whose index space depends
247
+ * on which series are visible. Swapping one hidden series for another of the same length leaves
248
+ * `totalPoints` unchanged, so the count alone cannot detect it.
249
+ */
250
+ visibleSeriesKey?: string;
251
+ }
252
+
253
+ export const useKeyboardNavigation = ( {
254
+ selectedIndex,
255
+ setSelectedIndex,
256
+ isNavigating,
257
+ setIsNavigating,
258
+ chartRef,
259
+ totalPoints,
260
+ onActivate,
261
+ preventTooltipScroll = false,
262
+ visibleSeriesKey,
263
+ }: UseKeyboardNavigationProps ) => {
264
+ // `chartRef` sits inside the focusable grid, so the grid is what focus returns to and is measured against.
265
+ const getChartRoot = useCallback(
266
+ (): HTMLElement | null =>
267
+ chartRef.current?.closest< HTMLElement >( '[role="grid"]' ) ?? chartRef.current,
268
+ [ chartRef ]
269
+ );
270
+
271
+ const focusWithoutScrollIfNeeded = useCallback(
272
+ ( element: HTMLElement | null | undefined ) => {
273
+ if ( preventTooltipScroll ) {
274
+ element?.focus( { preventScroll: true } );
275
+ } else {
276
+ element?.focus();
277
+ }
278
+ },
279
+ [ preventTooltipScroll ]
280
+ );
281
+
282
+ // Focus the tooltip as soon as it is rendered
283
+ const tooltipRef = useCallback(
284
+ ( element: HTMLDivElement | null ) => {
285
+ if ( element && selectedIndex !== undefined ) {
286
+ focusWithoutScrollIfNeeded( element );
287
+ }
288
+ },
289
+ [ focusWithoutScrollIfNeeded, selectedIndex ]
290
+ );
291
+
292
+ const previousTotalPoints = useRef( totalPoints );
293
+ const previousVisibleSeriesKey = useRef( visibleSeriesKey );
294
+
295
+ // Hiding or showing a series moves every index onto a different bar. The count alone misses a
296
+ // swap that keeps it the same, such as hiding one series while revealing another of equal length.
297
+ // Clear rather than clamp once focus has left the chart: a new tooltip would pull focus back.
298
+ useEffect( () => {
299
+ const countChanged = previousTotalPoints.current !== totalPoints;
300
+ const seriesChanged = previousVisibleSeriesKey.current !== visibleSeriesKey;
301
+ previousTotalPoints.current = totalPoints;
302
+ previousVisibleSeriesKey.current = visibleSeriesKey;
303
+
304
+ if ( ( ! countChanged && ! seriesChanged ) || selectedIndex === undefined ) {
305
+ return;
306
+ }
307
+
308
+ const { activeElement } = document;
309
+ const focusIsInChart = activeElement !== null && !! getChartRoot()?.contains( activeElement );
310
+
311
+ // A swap that leaves the count alone gives the same index a different series, so unlike a
312
+ // count change there is no shorter range to clamp into.
313
+ if ( totalPoints === 0 || ! focusIsInChart || ! countChanged ) {
314
+ setSelectedIndex( undefined );
315
+ setIsNavigating( false );
316
+ // Clearing unmounts the focused tooltip, which would drop focus to the body.
317
+ if ( focusIsInChart ) {
318
+ focusWithoutScrollIfNeeded( getChartRoot() );
319
+ }
320
+ return;
321
+ }
322
+
323
+ setSelectedIndex( Math.min( selectedIndex, totalPoints - 1 ) );
324
+ }, [
325
+ selectedIndex,
326
+ totalPoints,
327
+ visibleSeriesKey,
328
+ setSelectedIndex,
329
+ setIsNavigating,
330
+ getChartRoot,
331
+ focusWithoutScrollIfNeeded,
332
+ ] );
333
+
334
+ // Returning focus from the tooltip must not restore the selection Escape just cleared.
335
+ const onChartFocus = useCallback(
336
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
337
+ if ( event.currentTarget.contains( event.relatedTarget ) ) {
338
+ return;
339
+ }
340
+ if ( ! isNavigating && selectedIndex !== undefined ) {
341
+ setSelectedIndex( 0 );
342
+ }
343
+ },
344
+ [ isNavigating, selectedIndex, setSelectedIndex ]
325
345
  );
346
+
347
+ // The point the pointer took over a keyboard selection at, where the next arrow key continues from.
348
+ const pointerIndex = useRef< number | undefined >( undefined );
349
+
350
+ // The pointer is the latest input, so it ends a keyboard selection instead of competing with it.
351
+ const onChartPointerMove = useCallback(
352
+ ( index: number ) => {
353
+ if ( selectedIndex === undefined ) {
354
+ if ( pointerIndex.current !== undefined ) {
355
+ pointerIndex.current = index;
356
+ }
357
+ return;
358
+ }
359
+ const root = getChartRoot();
360
+ const activeElement = root?.ownerDocument.activeElement;
361
+ // Focus elsewhere on the page stays there; a hover must not pull it back into the chart.
362
+ if ( activeElement && root.contains( activeElement ) ) {
363
+ pointerIndex.current = index;
364
+ focusWithoutScrollIfNeeded( root );
365
+ }
366
+ setSelectedIndex( undefined );
367
+ setIsNavigating( false );
368
+ },
369
+ [ selectedIndex, setSelectedIndex, setIsNavigating, getChartRoot, focusWithoutScrollIfNeeded ]
370
+ );
371
+
372
+ // On each blur of chart, keyboard navigation should restart from first tooltip
373
+ const onChartBlur = useCallback(
374
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
375
+ setIsNavigating( false );
376
+ if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
377
+ pointerIndex.current = undefined;
378
+ }
379
+ },
380
+ [ setIsNavigating ]
381
+ );
382
+
383
+ const onChartKeyDown = useCallback(
384
+ ( event: React.KeyboardEvent< HTMLDivElement > ) => {
385
+ if ( event.key === 'Tab' || event.key === 'Escape' ) {
386
+ // Consuming Escape with nothing open would swallow the first Escape of a surrounding Modal.
387
+ if ( event.key === 'Escape' && selectedIndex !== undefined ) {
388
+ event.preventDefault();
389
+ }
390
+ focusWithoutScrollIfNeeded( getChartRoot() );
391
+ setSelectedIndex( undefined );
392
+ setIsNavigating( false );
393
+ pointerIndex.current = undefined;
394
+ return;
395
+ }
396
+
397
+ if ( totalPoints === 0 ) return;
398
+
399
+ const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
400
+ if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) {
401
+ pointerIndex.current = undefined;
402
+ }
403
+
404
+ // WAI-ARIA grid: arrows stop at the first and last cell.
405
+ if ( event.key === 'ArrowRight' ) {
406
+ event.preventDefault();
407
+ setIsNavigating( true );
408
+ setSelectedIndex( Math.min( currentSelectedIndex + 1, totalPoints - 1 ) );
409
+ } else if ( event.key === 'ArrowLeft' ) {
410
+ event.preventDefault();
411
+ setIsNavigating( true );
412
+ setSelectedIndex( Math.max( currentSelectedIndex - 1, 0 ) );
413
+ } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
414
+ event.preventDefault();
415
+ onActivate?.( selectedIndex );
416
+ }
417
+ },
418
+ [
419
+ totalPoints,
420
+ selectedIndex,
421
+ setSelectedIndex,
422
+ setIsNavigating,
423
+ getChartRoot,
424
+ focusWithoutScrollIfNeeded,
425
+ onActivate,
426
+ ]
427
+ );
428
+
429
+ return {
430
+ tooltipRef,
431
+ onChartFocus,
432
+ onChartBlur,
433
+ onChartKeyDown,
434
+ onChartPointerMove,
435
+ };
326
436
  };