@automattic/charts 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +1115 -471
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +39 -1
- package/dist/index.d.cts +116 -16
- package/dist/index.d.ts +116 -16
- package/dist/index.js +1115 -473
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/bar-chart/bar-chart.tsx +67 -17
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +15 -45
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +15 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -2
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +37 -1
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +88 -3
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +3 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
- package/src/charts/line-chart/types.ts +2 -2
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/private/base-legend.tsx +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +146 -37
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +160 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +29 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -11
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +36 -2
- package/src/types.ts +6 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- 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
|
-
|
|
57
|
+
export interface HeatmapChartProps extends Omit<
|
|
58
|
+
BaseChartProps< HeatmapColumn[] >,
|
|
59
|
+
'showLegend' | 'legend' | 'gridVisibility'
|
|
60
|
+
> {
|
|
50
61
|
/** y-axis labels by row index. Empty entries render blank. */
|
|
51
62
|
rowLabels?: string[];
|
|
63
|
+
/**
|
|
64
|
+
* Consecutive runs of columns sharing one label beneath the grid, set one
|
|
65
|
+
* group gap apart. Runs from the first column; columns past the last group
|
|
66
|
+
* stay ungrouped. Ignored, with a warning, when a span is not a positive
|
|
67
|
+
* integer or the spans reach past the last column.
|
|
68
|
+
*/
|
|
69
|
+
columnGroups?: HeatmapColumnGroup[];
|
|
70
|
+
/**
|
|
71
|
+
* How the arrow keys move the selection. `grid` steps across the whole grid.
|
|
72
|
+
* `calendar` reads each column group as a page: Left/Right by one cell (wrapping
|
|
73
|
+
* to the next row, then the next group), Up/Down by one row into the neighboring
|
|
74
|
+
* group past the edge, Page Up/Down by one group. Default `grid`.
|
|
75
|
+
*/
|
|
76
|
+
keyboardNavigation?: 'grid' | 'calendar';
|
|
77
|
+
/** Accessible name of the grid. Defaults to a localized "Heatmap chart". */
|
|
78
|
+
ariaLabel?: string;
|
|
52
79
|
/** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
|
|
53
80
|
compact?: boolean;
|
|
54
81
|
/** Render the numeric value inside each cell. Default `! compact`. */
|
|
@@ -80,5 +107,63 @@ export interface HeatmapChartProps
|
|
|
80
107
|
*/
|
|
81
108
|
primaryColor?: string;
|
|
82
109
|
renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
|
|
110
|
+
/**
|
|
111
|
+
* The tooltip box: `light` is the plain white box, `dark` the package's
|
|
112
|
+
* tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
|
|
113
|
+
* Default `light`.
|
|
114
|
+
*/
|
|
115
|
+
tooltipVariant?: 'light' | 'dark';
|
|
116
|
+
/** Inline styles merged onto the tooltip box, over the variant's own. */
|
|
117
|
+
tooltipStyle?: CSSProperties;
|
|
83
118
|
children?: ReactNode;
|
|
84
119
|
}
|
|
120
|
+
|
|
121
|
+
export type CalendarHeatmapResult = {
|
|
122
|
+
data: HeatmapColumn[];
|
|
123
|
+
rowLabels: string[];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type CalendarHeatmapOptions = {
|
|
127
|
+
weekStartsOn?: 0 | 1;
|
|
128
|
+
/**
|
|
129
|
+
* Mark the days completing the first and last week outside the series' span as
|
|
130
|
+
* hidden cells rather than blank ones, giving the calendar ragged edges. Days
|
|
131
|
+
* inside the span stay blank even where the series has no entry.
|
|
132
|
+
*/
|
|
133
|
+
hideOutOfRangeDays?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
|
|
136
|
+
* Days inside the grid but outside the series become placeholder cells: painted,
|
|
137
|
+
* but reporting nothing, since they were never measured. A start bound opens from
|
|
138
|
+
* the beginning of its week, and bounds narrower than the series are ignored.
|
|
139
|
+
*/
|
|
140
|
+
gridSpan?: { start?: string; end?: string };
|
|
141
|
+
/** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
|
|
142
|
+
locale?: string;
|
|
143
|
+
/**
|
|
144
|
+
* IANA zone the series' instants are bucketed into days in. Defaults to the
|
|
145
|
+
* runtime's zone. A `dateString` carrying no offset is taken as written.
|
|
146
|
+
*/
|
|
147
|
+
timeZone?: string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type MonthCalendarHeatmapRange = {
|
|
151
|
+
/** First measured day, `yyyy-MM-dd`. */
|
|
152
|
+
start: string;
|
|
153
|
+
/** Last measured day, `yyyy-MM-dd`, inclusive. */
|
|
154
|
+
end: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type MonthCalendarHeatmapOptions = {
|
|
158
|
+
/** 0 = Sunday, 1 = Monday. Default 1. */
|
|
159
|
+
weekStartsOn?: 0 | 1;
|
|
160
|
+
/** BCP-47 tag the month and day labels are written in. Defaults to the runtime's locale. */
|
|
161
|
+
locale?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type MonthCalendarHeatmapResult = {
|
|
165
|
+
/** For `HeatmapChart`'s `data`: 7 columns per month, 6 cells each. */
|
|
166
|
+
data: HeatmapColumn[];
|
|
167
|
+
/** For `HeatmapChart`'s `columnGroups`: one per month, in order. */
|
|
168
|
+
columnGroups: HeatmapColumnGroup[];
|
|
169
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { useChartFormatting } from '../../providers';
|
|
3
|
-
import { buildCalendarHeatmapData } from './
|
|
4
|
-
import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './
|
|
3
|
+
import { buildCalendarHeatmapData } from './build-calendar-data';
|
|
4
|
+
import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './types';
|
|
5
5
|
import type { DataPointDate } from '../../types';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useChartFormatting } from '../../providers';
|
|
3
|
+
import { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
|
|
4
|
+
import type {
|
|
5
|
+
MonthCalendarHeatmapOptions,
|
|
6
|
+
MonthCalendarHeatmapRange,
|
|
7
|
+
MonthCalendarHeatmapResult,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `buildMonthCalendarHeatmapData` with the locale taken from `GlobalChartsProvider`
|
|
12
|
+
* where the caller names none.
|
|
13
|
+
*
|
|
14
|
+
* @param valueByDay - Value per `yyyy-MM-dd`. Held by reference, so a caller that rebuilds it each render defeats the memo.
|
|
15
|
+
* @param range - As for `buildMonthCalendarHeatmapData`.
|
|
16
|
+
* @param options - As for `buildMonthCalendarHeatmapData`; `locale` wins over the provider.
|
|
17
|
+
* @return Columns and groups for `HeatmapChart`.
|
|
18
|
+
*/
|
|
19
|
+
export const useMonthCalendarHeatmapData = (
|
|
20
|
+
valueByDay: Record< string, number | null >,
|
|
21
|
+
range: MonthCalendarHeatmapRange,
|
|
22
|
+
options: MonthCalendarHeatmapOptions = {}
|
|
23
|
+
): MonthCalendarHeatmapResult => {
|
|
24
|
+
const formatting = useChartFormatting();
|
|
25
|
+
const locale = options.locale ?? formatting.locale;
|
|
26
|
+
const { weekStartsOn } = options;
|
|
27
|
+
const { start, end } = range;
|
|
28
|
+
|
|
29
|
+
// Spread into primitives: the option and range objects are rebuilt on each render.
|
|
30
|
+
return useMemo(
|
|
31
|
+
() => buildMonthCalendarHeatmapData( valueByDay, { start, end }, { locale, weekStartsOn } ),
|
|
32
|
+
[ valueByDay, start, end, locale, weekStartsOn ]
|
|
33
|
+
);
|
|
34
|
+
};
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { BaseChartProps, LeaderboardEntry } from '../../types';
|
|
3
3
|
|
|
4
|
-
export interface LeaderboardChartProps
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
> {
|
|
4
|
+
export interface LeaderboardChartProps extends Pick<
|
|
5
|
+
BaseChartProps< LeaderboardEntry >,
|
|
6
|
+
| 'className'
|
|
7
|
+
| 'data'
|
|
8
|
+
| 'showLegend'
|
|
9
|
+
| 'legend'
|
|
10
|
+
| 'chartId'
|
|
11
|
+
| 'width'
|
|
12
|
+
| 'height'
|
|
13
|
+
| 'size'
|
|
14
|
+
| 'gap'
|
|
15
|
+
| 'animation'
|
|
16
|
+
> {
|
|
18
17
|
/**
|
|
19
18
|
* Whether to show comparison data
|
|
20
19
|
*/
|
|
@@ -505,7 +505,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
505
505
|
background: tooltipStyle?.background,
|
|
506
506
|
backgroundColor: tooltipStyle?.backgroundColor,
|
|
507
507
|
}
|
|
508
|
-
|
|
508
|
+
)
|
|
509
509
|
: renderTooltip( { ...params, bucketInfo } ),
|
|
510
510
|
[ renderTooltip, bucketInfo, tooltipStyle ]
|
|
511
511
|
);
|
|
@@ -562,6 +562,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
562
562
|
|
|
563
563
|
return (
|
|
564
564
|
<div
|
|
565
|
+
ref={ chartRef }
|
|
565
566
|
role="grid"
|
|
566
567
|
aria-label={ __( 'Line chart', 'jetpack-charts' ) }
|
|
567
568
|
tabIndex={ 0 }
|
|
@@ -570,7 +571,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
570
571
|
onBlur={ onChartBlur }
|
|
571
572
|
>
|
|
572
573
|
{ chartHeight > 0 && (
|
|
573
|
-
<div
|
|
574
|
+
<div className={ plotStyles[ 'xy-plot' ] }>
|
|
574
575
|
{ zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
|
|
575
576
|
<XYChart
|
|
576
577
|
theme={ theme }
|
|
@@ -204,6 +204,37 @@ describe( 'LineChart', () => {
|
|
|
204
204
|
);
|
|
205
205
|
} );
|
|
206
206
|
|
|
207
|
+
test.each( [ [ 'Escape', '{Escape}' ] ] )(
|
|
208
|
+
'returns focus to the grid after %s',
|
|
209
|
+
async ( _name, keys ) => {
|
|
210
|
+
jest.useFakeTimers();
|
|
211
|
+
try {
|
|
212
|
+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
|
|
213
|
+
renderWithTheme();
|
|
214
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
215
|
+
|
|
216
|
+
await user.tab();
|
|
217
|
+
expect( chart ).toHaveFocus();
|
|
218
|
+
await user.keyboard( '{ArrowRight}' );
|
|
219
|
+
expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
|
|
220
|
+
|
|
221
|
+
await user.keyboard( keys );
|
|
222
|
+
await act( async () => {
|
|
223
|
+
jest.advanceTimersByTime( 5000 );
|
|
224
|
+
} );
|
|
225
|
+
expect( chart ).toHaveFocus();
|
|
226
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
227
|
+
|
|
228
|
+
await user.keyboard( '{ArrowRight}' );
|
|
229
|
+
const tooltip = screen.getByRole( 'tooltip' );
|
|
230
|
+
expect( tooltip ).toHaveFocus();
|
|
231
|
+
expect( tooltip ).toHaveTextContent( 'Series A:10' );
|
|
232
|
+
} finally {
|
|
233
|
+
jest.useRealTimers();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
207
238
|
describe( 'Data Validation', () => {
|
|
208
239
|
test( 'handles empty data array', () => {
|
|
209
240
|
renderWithTheme( { data: [] } );
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
AnnotationStyles,
|
|
9
9
|
DataPoint,
|
|
10
10
|
} from '../../types';
|
|
11
|
-
import type { CrosshairStyle, RenderTooltipParams
|
|
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?:
|
|
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
|
-
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
147
|
+
closeTooltipNow();
|
|
110
148
|
}
|
|
111
149
|
return;
|
|
112
150
|
}
|
|
@@ -201,6 +239,12 @@ interface UseKeyboardNavigationProps {
|
|
|
201
239
|
*/
|
|
202
240
|
onActivate?: ( index: number ) => void;
|
|
203
241
|
preventTooltipScroll?: boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Identity of the series the navigation indexes run over, for charts whose index space depends
|
|
244
|
+
* on which series are visible. Swapping one hidden series for another of the same length leaves
|
|
245
|
+
* `totalPoints` unchanged, so the count alone cannot detect it.
|
|
246
|
+
*/
|
|
247
|
+
visibleSeriesKey?: string;
|
|
204
248
|
}
|
|
205
249
|
|
|
206
250
|
export const useKeyboardNavigation = ( {
|
|
@@ -212,27 +256,90 @@ export const useKeyboardNavigation = ( {
|
|
|
212
256
|
totalPoints,
|
|
213
257
|
onActivate,
|
|
214
258
|
preventTooltipScroll = false,
|
|
259
|
+
visibleSeriesKey,
|
|
215
260
|
}: UseKeyboardNavigationProps ) => {
|
|
261
|
+
// `chartRef` sits inside the focusable grid, so the grid is what focus returns to and is measured against.
|
|
262
|
+
const getChartRoot = useCallback(
|
|
263
|
+
(): HTMLElement | null =>
|
|
264
|
+
chartRef.current?.closest< HTMLElement >( '[role="grid"]' ) ?? chartRef.current,
|
|
265
|
+
[ chartRef ]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const focusWithoutScrollIfNeeded = useCallback(
|
|
269
|
+
( element: HTMLElement | null | undefined ) => {
|
|
270
|
+
if ( preventTooltipScroll ) {
|
|
271
|
+
element?.focus( { preventScroll: true } );
|
|
272
|
+
} else {
|
|
273
|
+
element?.focus();
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
[ preventTooltipScroll ]
|
|
277
|
+
);
|
|
278
|
+
|
|
216
279
|
// Focus the tooltip as soon as it is rendered
|
|
217
280
|
const tooltipRef = useCallback(
|
|
218
281
|
( element: HTMLDivElement | null ) => {
|
|
219
282
|
if ( element && selectedIndex !== undefined ) {
|
|
220
|
-
|
|
221
|
-
element.focus( { preventScroll: true } );
|
|
222
|
-
} else {
|
|
223
|
-
element.focus();
|
|
224
|
-
}
|
|
283
|
+
focusWithoutScrollIfNeeded( element );
|
|
225
284
|
}
|
|
226
285
|
},
|
|
227
|
-
[
|
|
286
|
+
[ focusWithoutScrollIfNeeded, selectedIndex ]
|
|
228
287
|
);
|
|
229
288
|
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
289
|
+
const previousTotalPoints = useRef( totalPoints );
|
|
290
|
+
const previousVisibleSeriesKey = useRef( visibleSeriesKey );
|
|
291
|
+
|
|
292
|
+
// Hiding or showing a series moves every index onto a different bar. The count alone misses a
|
|
293
|
+
// swap that keeps it the same, such as hiding one series while revealing another of equal length.
|
|
294
|
+
// Clear rather than clamp once focus has left the chart: a new tooltip would pull focus back.
|
|
295
|
+
useEffect( () => {
|
|
296
|
+
const countChanged = previousTotalPoints.current !== totalPoints;
|
|
297
|
+
const seriesChanged = previousVisibleSeriesKey.current !== visibleSeriesKey;
|
|
298
|
+
previousTotalPoints.current = totalPoints;
|
|
299
|
+
previousVisibleSeriesKey.current = visibleSeriesKey;
|
|
300
|
+
|
|
301
|
+
if ( ( ! countChanged && ! seriesChanged ) || selectedIndex === undefined ) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { activeElement } = document;
|
|
306
|
+
const focusIsInChart = activeElement !== null && !! getChartRoot()?.contains( activeElement );
|
|
307
|
+
|
|
308
|
+
// A swap that leaves the count alone gives the same index a different series, so unlike a
|
|
309
|
+
// count change there is no shorter range to clamp into.
|
|
310
|
+
if ( totalPoints === 0 || ! focusIsInChart || ! countChanged ) {
|
|
311
|
+
setSelectedIndex( undefined );
|
|
312
|
+
setIsNavigating( false );
|
|
313
|
+
// Clearing unmounts the focused tooltip, which would drop focus to the body.
|
|
314
|
+
if ( focusIsInChart ) {
|
|
315
|
+
focusWithoutScrollIfNeeded( getChartRoot() );
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
234
318
|
}
|
|
235
|
-
|
|
319
|
+
|
|
320
|
+
setSelectedIndex( Math.min( selectedIndex, totalPoints - 1 ) );
|
|
321
|
+
}, [
|
|
322
|
+
selectedIndex,
|
|
323
|
+
totalPoints,
|
|
324
|
+
visibleSeriesKey,
|
|
325
|
+
setSelectedIndex,
|
|
326
|
+
setIsNavigating,
|
|
327
|
+
getChartRoot,
|
|
328
|
+
focusWithoutScrollIfNeeded,
|
|
329
|
+
] );
|
|
330
|
+
|
|
331
|
+
// Returning focus from the tooltip must not restore the selection Escape just cleared.
|
|
332
|
+
const onChartFocus = useCallback(
|
|
333
|
+
( event: React.FocusEvent< HTMLDivElement > ) => {
|
|
334
|
+
if ( event.currentTarget.contains( event.relatedTarget ) ) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if ( ! isNavigating && selectedIndex !== undefined ) {
|
|
338
|
+
setSelectedIndex( 0 );
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
[ isNavigating, selectedIndex, setSelectedIndex ]
|
|
342
|
+
);
|
|
236
343
|
|
|
237
344
|
// On each blur of chart, keyboard navigation should restart from first tooltip
|
|
238
345
|
const onChartBlur = useCallback( () => {
|
|
@@ -241,42 +348,44 @@ export const useKeyboardNavigation = ( {
|
|
|
241
348
|
|
|
242
349
|
const onChartKeyDown = useCallback(
|
|
243
350
|
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
351
|
+
if ( event.key === 'Tab' || event.key === 'Escape' ) {
|
|
352
|
+
// Consuming Escape with nothing open would swallow the first Escape of a surrounding Modal.
|
|
353
|
+
if ( event.key === 'Escape' && selectedIndex !== undefined ) {
|
|
354
|
+
event.preventDefault();
|
|
355
|
+
}
|
|
356
|
+
focusWithoutScrollIfNeeded( getChartRoot() );
|
|
249
357
|
setSelectedIndex( undefined );
|
|
250
358
|
setIsNavigating( false );
|
|
251
359
|
return;
|
|
252
360
|
}
|
|
253
361
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
|
|
257
|
-
chartRef.current?.focus();
|
|
258
|
-
setSelectedIndex( undefined );
|
|
259
|
-
setIsNavigating( false );
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
362
|
+
if ( totalPoints === 0 ) return;
|
|
262
363
|
|
|
263
|
-
|
|
364
|
+
const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
|
|
264
365
|
|
|
265
|
-
|
|
366
|
+
// WAI-ARIA grid: arrows stop at the first and last cell.
|
|
367
|
+
if ( event.key === 'ArrowRight' ) {
|
|
368
|
+
event.preventDefault();
|
|
266
369
|
setIsNavigating( true );
|
|
267
|
-
setSelectedIndex( ( currentSelectedIndex + 1
|
|
268
|
-
} else if (
|
|
370
|
+
setSelectedIndex( Math.min( currentSelectedIndex + 1, totalPoints - 1 ) );
|
|
371
|
+
} else if ( event.key === 'ArrowLeft' ) {
|
|
372
|
+
event.preventDefault();
|
|
269
373
|
setIsNavigating( true );
|
|
270
|
-
setSelectedIndex( ( currentSelectedIndex - 1
|
|
271
|
-
} else if ( event.key === 'Escape' ) {
|
|
272
|
-
setSelectedIndex( undefined );
|
|
273
|
-
setIsNavigating( false );
|
|
274
|
-
chartRef.current?.focus();
|
|
374
|
+
setSelectedIndex( Math.max( currentSelectedIndex - 1, 0 ) );
|
|
275
375
|
} else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
|
|
376
|
+
event.preventDefault();
|
|
276
377
|
onActivate?.( selectedIndex );
|
|
277
378
|
}
|
|
278
379
|
},
|
|
279
|
-
[
|
|
380
|
+
[
|
|
381
|
+
totalPoints,
|
|
382
|
+
selectedIndex,
|
|
383
|
+
setSelectedIndex,
|
|
384
|
+
setIsNavigating,
|
|
385
|
+
getChartRoot,
|
|
386
|
+
focusWithoutScrollIfNeeded,
|
|
387
|
+
onActivate,
|
|
388
|
+
]
|
|
280
389
|
);
|
|
281
390
|
|
|
282
391
|
return {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// `.surface` is the look alone, for a chart that positions the box itself.
|
|
2
|
+
.surface,
|
|
1
3
|
.tooltip {
|
|
2
4
|
padding: var(--wpds-dimension-padding-sm);
|
|
3
5
|
// No WPDS token fits a translucent dark tooltip surface:
|
|
@@ -9,7 +11,10 @@
|
|
|
9
11
|
border-radius: var(--wpds-border-radius-md);
|
|
10
12
|
font-size: var(--wpds-typography-font-size-md);
|
|
11
13
|
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
12
|
-
position: absolute;
|
|
13
14
|
pointer-events: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.tooltip {
|
|
18
|
+
position: absolute;
|
|
14
19
|
transform: translate(-50%, -100%);
|
|
15
20
|
}
|