@automattic/charts 4.7.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.
- package/CHANGELOG.md +16 -0
- package/TOKENS.md +6 -3
- package/dist/index.cjs +212 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +10 -9
- package/dist/index.d.cts +60 -20
- package/dist/index.d.ts +60 -20
- package/dist/index.js +212 -97
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +2 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
- package/src/charts/bar-chart/bar-chart.module.scss +7 -0
- package/src/charts/bar-chart/bar-chart.tsx +20 -14
- package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +6 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +39 -29
- package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
- package/src/charts/heatmap-chart/private/index.ts +2 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +99 -7
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
- package/src/charts/heatmap-chart/types.ts +4 -3
- package/src/charts/line-chart/line-chart.tsx +2 -2
- package/src/charts/pie-chart/pie-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +6 -8
- package/src/components/tooltip/base-tooltip.tsx +31 -32
- package/src/components/tooltip/index.ts +3 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
- package/src/components/tooltip/private/label-value-content.tsx +8 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
- package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
- package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
- package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +282 -365
- package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
- package/src/components/tooltip/tooltip-box.tsx +43 -0
- package/src/components/tooltip/types.ts +7 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
- package/src/hooks/test/use-chart-margin.test.tsx +8 -4
- package/src/hooks/use-chart-margin.tsx +21 -18
- package/src/index.ts +8 -3
- package/src/providers/chart-context/global-charts-provider.tsx +5 -14
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
- package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
- package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
- package/src/styles/chart-scope.scss +1 -0
- package/src/test-utils/zoned-parsing-suite.ts +0 -10
- package/src/utils/test/date-formatting.test.ts +0 -10
- package/src/components/tooltip/accessible-tooltip.tsx +0 -433
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
|
@@ -1,326 +1,436 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
1
|
+
import { TooltipContext } from '@visx/xychart';
|
|
3
2
|
import clsx from 'clsx';
|
|
4
|
-
import { useCallback,
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from '../../visx/types';
|
|
15
|
-
import type { TooltipContextType } from '@visx/xychart';
|
|
16
|
-
import type { CSSProperties, ReactNode } from 'react';
|
|
17
|
-
|
|
18
|
-
const CROSSHAIR_STROKE_WIDTH = 1.5;
|
|
19
|
-
const CROSSHAIR_PAINT_PROPERTIES = new Set( [
|
|
20
|
-
'stroke',
|
|
21
|
-
'strokeWidth',
|
|
22
|
-
'strokeOpacity',
|
|
23
|
-
'strokeDasharray',
|
|
24
|
-
'strokeLinecap',
|
|
25
|
-
'opacity',
|
|
26
|
-
] );
|
|
27
|
-
|
|
28
|
-
const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
|
|
29
|
-
const pickPaint = ( value: object ) =>
|
|
30
|
-
Object.fromEntries(
|
|
31
|
-
Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
|
|
32
|
-
);
|
|
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';
|
|
33
13
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const DEFAULT_GLYPH_RADIUS = 4;
|
|
41
|
-
const FALLBACK_COLOR = '#222';
|
|
42
|
-
// Portal-era options, accepted for compatibility and dropped before the box
|
|
43
|
-
// renders: it sits in the chart wrapper and moves with it, and nothing
|
|
44
|
-
// measures it any more.
|
|
45
|
-
const PORTAL_OPTIONS = new Set( [ 'scroll', 'debounce', 'resizeObserverPolyfill' ] );
|
|
46
|
-
|
|
47
|
-
type ScaleFn = ( value: unknown ) => number | undefined;
|
|
48
|
-
|
|
49
|
-
const isValidNumber = ( value: unknown ): value is number =>
|
|
50
|
-
typeof value === 'number' && Number.isFinite( value );
|
|
51
|
-
|
|
52
|
-
// Band scales place a datum at the start of its band; snapping targets the band's centre.
|
|
53
|
-
// A d3 scale is a function that carries `bandwidth` as a property.
|
|
54
|
-
const scaleBandwidth = ( scale: unknown ): number => {
|
|
55
|
-
const bandwidth = ( scale as { bandwidth?: unknown } | null | undefined )?.bandwidth;
|
|
56
|
-
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;
|
|
57
20
|
};
|
|
58
21
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const defaultRenderGlyph = < Datum extends object >( {
|
|
85
|
-
key,
|
|
86
|
-
...props
|
|
87
|
-
}: RenderTooltipGlyphProps< Datum > ) => (
|
|
88
|
-
<DefaultGlyph key={ key } seriesKey={ key } { ...props } />
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
type XyChartTooltipContentProps< Datum extends object > = XyChartTooltipProps< Datum > & {
|
|
92
|
-
tooltipContext: TooltipContextType< Datum >;
|
|
93
|
-
/** The SVG's HTML parent once found. The box waits for it; the SVG overlay does not. */
|
|
94
|
-
container: HTMLElement | null;
|
|
95
|
-
};
|
|
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
|
+
}
|
|
96
47
|
|
|
97
|
-
const
|
|
98
|
-
tooltipContext,
|
|
99
|
-
container,
|
|
48
|
+
export const XYChartTooltip: React.FC< XYChartTooltipProps > = ( {
|
|
100
49
|
renderTooltip,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
showHorizontalCrosshair = false,
|
|
107
|
-
showDatumGlyph = false,
|
|
108
|
-
showSeriesGlyphs = false,
|
|
50
|
+
selectedIndex,
|
|
51
|
+
tooltipRef,
|
|
52
|
+
keyboardFocusedClassName,
|
|
53
|
+
series = [],
|
|
54
|
+
mode = 'group',
|
|
109
55
|
verticalCrosshairStyle,
|
|
110
56
|
horizontalCrosshairStyle,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
style,
|
|
116
|
-
...rest
|
|
117
|
-
}: XyChartTooltipContentProps< Datum > ) => {
|
|
118
|
-
const tooltipProps = Object.fromEntries(
|
|
119
|
-
Object.entries( rest ).filter( ( [ key ] ) => ! PORTAL_OPTIONS.has( key ) )
|
|
120
|
-
) as Omit< typeof rest, 'scroll' | 'debounce' | 'resizeObserverPolyfill' >;
|
|
121
|
-
const {
|
|
122
|
-
colorScale,
|
|
123
|
-
theme,
|
|
124
|
-
innerHeight = 0,
|
|
125
|
-
innerWidth = 0,
|
|
126
|
-
margin,
|
|
127
|
-
xScale,
|
|
128
|
-
yScale,
|
|
129
|
-
dataRegistry,
|
|
130
|
-
} = useContext( DataContext ) || {};
|
|
131
|
-
|
|
132
|
-
const tooltipContent = renderTooltip ? renderTooltip( { ...tooltipContext, colorScale } ) : null;
|
|
133
|
-
if ( tooltipContent == null ) {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const scaleX = xScale as ScaleFn | undefined;
|
|
138
|
-
const scaleY = yScale as ScaleFn | undefined;
|
|
139
|
-
|
|
140
|
-
// Datum position in SVG coordinates, from the accessors the series registered.
|
|
141
|
-
const getDatumLeftTop = ( key: string, datum: Datum ) => {
|
|
142
|
-
const entry = dataRegistry?.get( key );
|
|
143
|
-
const left =
|
|
144
|
-
scaleX && entry?.xAccessor
|
|
145
|
-
? Number( scaleX( entry.xAccessor( datum ) ) ) + scaleBandwidth( xScale ) / 2
|
|
146
|
-
: undefined;
|
|
147
|
-
const top =
|
|
148
|
-
scaleY && entry?.yAccessor
|
|
149
|
-
? Number( scaleY( entry.yAccessor( datum ) ) ) + scaleBandwidth( yScale ) / 2
|
|
150
|
-
: undefined;
|
|
151
|
-
return { left, top };
|
|
152
|
-
};
|
|
57
|
+
...props
|
|
58
|
+
} ) => {
|
|
59
|
+
const tooltipContext = useContext( TooltipContext );
|
|
60
|
+
const scopeElement = useChartScopeElement();
|
|
153
61
|
|
|
154
|
-
|
|
155
|
-
|
|
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 );
|
|
156
65
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
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
|
+
}
|
|
167
97
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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();
|
|
183
136
|
}
|
|
137
|
+
}, [ hiddenSeriesKey, selectedIndex, closeTooltipNow ] );
|
|
184
138
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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();
|
|
189
148
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
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( {
|
|
203
161
|
datum,
|
|
162
|
+
key: s.label,
|
|
204
163
|
index,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
}
|
|
213
178
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
179
|
+
|
|
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 }` }
|
|
201
|
+
>
|
|
202
|
+
{ tooltipContent }
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
231
205
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
>
|
|
241
|
-
{ tooltipContent }
|
|
242
|
-
</TooltipComponent>
|
|
243
|
-
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className={ standaloneScopeClass } role="tooltip" aria-live="polite">
|
|
209
|
+
{ tooltipContent }
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
}, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName, standaloneScopeClass ] );
|
|
244
214
|
|
|
245
215
|
return (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
stroke={ crosshairStroke }
|
|
253
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
254
|
-
{ ...crosshairPaintProps( verticalCrosshairStyle ) }
|
|
255
|
-
x1={ tooltipLeft }
|
|
256
|
-
x2={ tooltipLeft }
|
|
257
|
-
y1={ marginTop }
|
|
258
|
-
y2={ marginTop + innerHeight }
|
|
259
|
-
/>
|
|
260
|
-
) }
|
|
261
|
-
{ showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
|
|
262
|
-
<line
|
|
263
|
-
className="visx-crosshair visx-crosshair-horizontal"
|
|
264
|
-
data-testid="xy-chart-tooltip-crosshair-horizontal"
|
|
265
|
-
stroke={ crosshairStroke }
|
|
266
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
267
|
-
{ ...crosshairPaintProps( horizontalCrosshairStyle ) }
|
|
268
|
-
x1={ marginLeft }
|
|
269
|
-
x2={ marginLeft + innerWidth }
|
|
270
|
-
y1={ tooltipTop }
|
|
271
|
-
y2={ tooltipTop }
|
|
272
|
-
/>
|
|
273
|
-
) }
|
|
274
|
-
{ glyphs }
|
|
275
|
-
</g>
|
|
276
|
-
{ container &&
|
|
277
|
-
createPortal( themesOwnBox ? <TooltipTheme>{ box }</TooltipTheme> : box, container ) }
|
|
278
|
-
</>
|
|
216
|
+
<XyChartTooltipOverlay
|
|
217
|
+
{ ...props }
|
|
218
|
+
verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
|
|
219
|
+
horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
|
|
220
|
+
renderTooltip={ focusableRenderTooltip }
|
|
221
|
+
/>
|
|
279
222
|
);
|
|
280
223
|
};
|
|
281
224
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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 ]
|
|
325
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 ]
|
|
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
|
};
|