@automattic/charts 4.1.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +875 -237
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +106 -6
- package/dist/index.d.ts +106 -6
- package/dist/index.js +875 -239
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- 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/build-calendar-data.ts +5 -11
- 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 +6 -0
- 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 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- 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-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- 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 +1 -1
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- 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 +127 -40
- 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 +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- 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/test/chart-scope.test.ts +1 -1
- 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,6 +107,14 @@ export interface HeatmapChartProps
|
|
|
80
107
|
*/
|
|
81
108
|
primaryColor?: string;
|
|
82
109
|
renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
|
|
110
|
+
/**
|
|
111
|
+
* The tooltip box: `light` is the plain white box, `dark` the package's
|
|
112
|
+
* tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
|
|
113
|
+
* Default `light`.
|
|
114
|
+
*/
|
|
115
|
+
tooltipVariant?: 'light' | 'dark';
|
|
116
|
+
/** Inline styles merged onto the tooltip box, over the variant's own. */
|
|
117
|
+
tooltipStyle?: CSSProperties;
|
|
83
118
|
children?: ReactNode;
|
|
84
119
|
}
|
|
85
120
|
|
|
@@ -111,3 +146,24 @@ export type CalendarHeatmapOptions = {
|
|
|
111
146
|
*/
|
|
112
147
|
timeZone?: string;
|
|
113
148
|
};
|
|
149
|
+
|
|
150
|
+
export type MonthCalendarHeatmapRange = {
|
|
151
|
+
/** First measured day, `yyyy-MM-dd`. */
|
|
152
|
+
start: string;
|
|
153
|
+
/** Last measured day, `yyyy-MM-dd`, inclusive. */
|
|
154
|
+
end: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type MonthCalendarHeatmapOptions = {
|
|
158
|
+
/** 0 = Sunday, 1 = Monday. Default 1. */
|
|
159
|
+
weekStartsOn?: 0 | 1;
|
|
160
|
+
/** BCP-47 tag the month and day labels are written in. Defaults to the runtime's locale. */
|
|
161
|
+
locale?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type MonthCalendarHeatmapResult = {
|
|
165
|
+
/** For `HeatmapChart`'s `data`: 7 columns per month, 6 cells each. */
|
|
166
|
+
data: HeatmapColumn[];
|
|
167
|
+
/** For `HeatmapChart`'s `columnGroups`: one per month, in order. */
|
|
168
|
+
columnGroups: HeatmapColumnGroup[];
|
|
169
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useChartFormatting } from '../../providers';
|
|
3
|
+
import { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
|
|
4
|
+
import type {
|
|
5
|
+
MonthCalendarHeatmapOptions,
|
|
6
|
+
MonthCalendarHeatmapRange,
|
|
7
|
+
MonthCalendarHeatmapResult,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `buildMonthCalendarHeatmapData` with the locale taken from `GlobalChartsProvider`
|
|
12
|
+
* where the caller names none.
|
|
13
|
+
*
|
|
14
|
+
* @param valueByDay - Value per `yyyy-MM-dd`. Held by reference, so a caller that rebuilds it each render defeats the memo.
|
|
15
|
+
* @param range - As for `buildMonthCalendarHeatmapData`.
|
|
16
|
+
* @param options - As for `buildMonthCalendarHeatmapData`; `locale` wins over the provider.
|
|
17
|
+
* @return Columns and groups for `HeatmapChart`.
|
|
18
|
+
*/
|
|
19
|
+
export const useMonthCalendarHeatmapData = (
|
|
20
|
+
valueByDay: Record< string, number | null >,
|
|
21
|
+
range: MonthCalendarHeatmapRange,
|
|
22
|
+
options: MonthCalendarHeatmapOptions = {}
|
|
23
|
+
): MonthCalendarHeatmapResult => {
|
|
24
|
+
const formatting = useChartFormatting();
|
|
25
|
+
const locale = options.locale ?? formatting.locale;
|
|
26
|
+
const { weekStartsOn } = options;
|
|
27
|
+
const { start, end } = range;
|
|
28
|
+
|
|
29
|
+
// Spread into primitives: the option and range objects are rebuilt on each render.
|
|
30
|
+
return useMemo(
|
|
31
|
+
() => buildMonthCalendarHeatmapData( valueByDay, { start, end }, { locale, weekStartsOn } ),
|
|
32
|
+
[ valueByDay, start, end, locale, weekStartsOn ]
|
|
33
|
+
);
|
|
34
|
+
};
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { BaseChartProps, LeaderboardEntry } from '../../types';
|
|
3
3
|
|
|
4
|
-
export interface LeaderboardChartProps
|
|
5
|
-
|
|
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
|
);
|
|
@@ -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,21 +256,78 @@ export const useKeyboardNavigation = ( {
|
|
|
212
256
|
totalPoints,
|
|
213
257
|
onActivate,
|
|
214
258
|
preventTooltipScroll = false,
|
|
259
|
+
visibleSeriesKey,
|
|
215
260
|
}: UseKeyboardNavigationProps ) => {
|
|
261
|
+
// `chartRef` sits inside the focusable grid, so the grid is what focus returns to and is measured against.
|
|
262
|
+
const getChartRoot = useCallback(
|
|
263
|
+
(): HTMLElement | null =>
|
|
264
|
+
chartRef.current?.closest< HTMLElement >( '[role="grid"]' ) ?? chartRef.current,
|
|
265
|
+
[ chartRef ]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const focusWithoutScrollIfNeeded = useCallback(
|
|
269
|
+
( element: HTMLElement | null | undefined ) => {
|
|
270
|
+
if ( preventTooltipScroll ) {
|
|
271
|
+
element?.focus( { preventScroll: true } );
|
|
272
|
+
} else {
|
|
273
|
+
element?.focus();
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
[ preventTooltipScroll ]
|
|
277
|
+
);
|
|
278
|
+
|
|
216
279
|
// Focus the tooltip as soon as it is rendered
|
|
217
280
|
const tooltipRef = useCallback(
|
|
218
281
|
( element: HTMLDivElement | null ) => {
|
|
219
282
|
if ( element && selectedIndex !== undefined ) {
|
|
220
|
-
|
|
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
|
|
|
289
|
+
const previousTotalPoints = useRef( totalPoints );
|
|
290
|
+
const previousVisibleSeriesKey = useRef( visibleSeriesKey );
|
|
291
|
+
|
|
292
|
+
// Hiding or showing a series moves every index onto a different bar. The count alone misses a
|
|
293
|
+
// swap that keeps it the same, such as hiding one series while revealing another of equal length.
|
|
294
|
+
// Clear rather than clamp once focus has left the chart: a new tooltip would pull focus back.
|
|
295
|
+
useEffect( () => {
|
|
296
|
+
const countChanged = previousTotalPoints.current !== totalPoints;
|
|
297
|
+
const seriesChanged = previousVisibleSeriesKey.current !== visibleSeriesKey;
|
|
298
|
+
previousTotalPoints.current = totalPoints;
|
|
299
|
+
previousVisibleSeriesKey.current = visibleSeriesKey;
|
|
300
|
+
|
|
301
|
+
if ( ( ! countChanged && ! seriesChanged ) || selectedIndex === undefined ) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { activeElement } = document;
|
|
306
|
+
const focusIsInChart = activeElement !== null && !! getChartRoot()?.contains( activeElement );
|
|
307
|
+
|
|
308
|
+
// A swap that leaves the count alone gives the same index a different series, so unlike a
|
|
309
|
+
// count change there is no shorter range to clamp into.
|
|
310
|
+
if ( totalPoints === 0 || ! focusIsInChart || ! countChanged ) {
|
|
311
|
+
setSelectedIndex( undefined );
|
|
312
|
+
setIsNavigating( false );
|
|
313
|
+
// Clearing unmounts the focused tooltip, which would drop focus to the body.
|
|
314
|
+
if ( focusIsInChart ) {
|
|
315
|
+
focusWithoutScrollIfNeeded( getChartRoot() );
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
setSelectedIndex( Math.min( selectedIndex, totalPoints - 1 ) );
|
|
321
|
+
}, [
|
|
322
|
+
selectedIndex,
|
|
323
|
+
totalPoints,
|
|
324
|
+
visibleSeriesKey,
|
|
325
|
+
setSelectedIndex,
|
|
326
|
+
setIsNavigating,
|
|
327
|
+
getChartRoot,
|
|
328
|
+
focusWithoutScrollIfNeeded,
|
|
329
|
+
] );
|
|
330
|
+
|
|
230
331
|
// Returning focus from the tooltip must not restore the selection Escape just cleared.
|
|
231
332
|
const onChartFocus = useCallback(
|
|
232
333
|
( event: React.FocusEvent< HTMLDivElement > ) => {
|
|
@@ -247,46 +348,32 @@ export const useKeyboardNavigation = ( {
|
|
|
247
348
|
|
|
248
349
|
const onChartKeyDown = useCallback(
|
|
249
350
|
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
chartRef.current?.focus( { preventScroll: true } );
|
|
255
|
-
} else {
|
|
256
|
-
chartRef.current?.focus();
|
|
351
|
+
if ( event.key === 'Tab' || event.key === 'Escape' ) {
|
|
352
|
+
// Consuming Escape with nothing open would swallow the first Escape of a surrounding Modal.
|
|
353
|
+
if ( event.key === 'Escape' && selectedIndex !== undefined ) {
|
|
354
|
+
event.preventDefault();
|
|
257
355
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Keep focus on the chart if tab is pressed
|
|
261
|
-
if ( event.key === 'Tab' ) {
|
|
262
|
-
focusChart();
|
|
356
|
+
focusWithoutScrollIfNeeded( getChartRoot() );
|
|
263
357
|
setSelectedIndex( undefined );
|
|
264
358
|
setIsNavigating( false );
|
|
265
359
|
return;
|
|
266
360
|
}
|
|
267
361
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
|
|
271
|
-
focusChart();
|
|
272
|
-
setSelectedIndex( undefined );
|
|
273
|
-
setIsNavigating( false );
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
362
|
+
if ( totalPoints === 0 ) return;
|
|
276
363
|
|
|
277
|
-
|
|
364
|
+
const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
|
|
278
365
|
|
|
279
|
-
|
|
366
|
+
// WAI-ARIA grid: arrows stop at the first and last cell.
|
|
367
|
+
if ( event.key === 'ArrowRight' ) {
|
|
368
|
+
event.preventDefault();
|
|
280
369
|
setIsNavigating( true );
|
|
281
|
-
setSelectedIndex( ( currentSelectedIndex + 1
|
|
282
|
-
} else if (
|
|
370
|
+
setSelectedIndex( Math.min( currentSelectedIndex + 1, totalPoints - 1 ) );
|
|
371
|
+
} else if ( event.key === 'ArrowLeft' ) {
|
|
372
|
+
event.preventDefault();
|
|
283
373
|
setIsNavigating( true );
|
|
284
|
-
setSelectedIndex( ( currentSelectedIndex - 1
|
|
285
|
-
} else if ( event.key === 'Escape' ) {
|
|
286
|
-
setSelectedIndex( undefined );
|
|
287
|
-
setIsNavigating( false );
|
|
288
|
-
focusChart();
|
|
374
|
+
setSelectedIndex( Math.max( currentSelectedIndex - 1, 0 ) );
|
|
289
375
|
} else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
|
|
376
|
+
event.preventDefault();
|
|
290
377
|
onActivate?.( selectedIndex );
|
|
291
378
|
}
|
|
292
379
|
},
|
|
@@ -295,9 +382,9 @@ export const useKeyboardNavigation = ( {
|
|
|
295
382
|
selectedIndex,
|
|
296
383
|
setSelectedIndex,
|
|
297
384
|
setIsNavigating,
|
|
298
|
-
|
|
385
|
+
getChartRoot,
|
|
386
|
+
focusWithoutScrollIfNeeded,
|
|
299
387
|
onActivate,
|
|
300
|
-
preventTooltipScroll,
|
|
301
388
|
]
|
|
302
389
|
);
|
|
303
390
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// `.surface` is the look alone, for a chart that positions the box itself.
|
|
2
|
+
.surface,
|
|
1
3
|
.tooltip {
|
|
2
4
|
padding: var(--wpds-dimension-padding-sm);
|
|
3
5
|
// No WPDS token fits a translucent dark tooltip surface:
|
|
@@ -9,7 +11,10 @@
|
|
|
9
11
|
border-radius: var(--wpds-border-radius-md);
|
|
10
12
|
font-size: var(--wpds-typography-font-size-md);
|
|
11
13
|
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
12
|
-
position: absolute;
|
|
13
14
|
pointer-events: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.tooltip {
|
|
18
|
+
position: absolute;
|
|
14
19
|
transform: translate(-50%, -100%);
|
|
15
20
|
}
|
|
@@ -14,6 +14,9 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const DEFAULT_OFFSET = 10;
|
|
17
|
+
// Above anything else layered inside a chart, such as the zoom reset button (2).
|
|
18
|
+
// Chart wrappers isolate their stacking context, so it never reaches page chrome.
|
|
19
|
+
export const TOOLTIP_Z_INDEX = 3;
|
|
17
20
|
const POINTER_HEIGHT = 6;
|
|
18
21
|
|
|
19
22
|
const clamp = ( position: number, min: number, max: number, size: number ) =>
|
|
@@ -41,7 +44,8 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
|
41
44
|
/**
|
|
42
45
|
* Where the box goes, in wrapper coordinates. Automatic placement flips to
|
|
43
46
|
* the side that clips less, then clamps inside `bounds`; below-axis placement
|
|
44
|
-
* is centered on the anchor and clamped horizontally
|
|
47
|
+
* is centered on the anchor and clamped horizontally; beside placement preserves
|
|
48
|
+
* the anchor's y and only flips horizontally before bounds clamping.
|
|
45
49
|
*
|
|
46
50
|
* The flip measures against the visible part of the wrapper, its intersection
|
|
47
51
|
* with `bounds`. Only the clamp is bound by the page: a box that fits inside the
|
|
@@ -55,7 +59,7 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
|
55
59
|
* @param params.offsetTop - Gap between the anchor and the box, vertically.
|
|
56
60
|
* @param params.box - Rendered size of the box.
|
|
57
61
|
* @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
|
|
58
|
-
* @param params.placement -
|
|
62
|
+
* @param params.placement - Below-axis, beside without vertical flipping, or automatic flipping.
|
|
59
63
|
* @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
|
|
60
64
|
* @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
|
|
61
65
|
*/
|
|
@@ -103,7 +107,10 @@ export const getBoundedPosition = ( {
|
|
|
103
107
|
const upY = top - offsetTop - box.height;
|
|
104
108
|
const downOverflow = downY + box.height - fit.bottom;
|
|
105
109
|
const upOverflow = fit.top - upY;
|
|
106
|
-
let y =
|
|
110
|
+
let y = top;
|
|
111
|
+
if ( placement !== 'beside' ) {
|
|
112
|
+
y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
|
|
113
|
+
}
|
|
107
114
|
|
|
108
115
|
x = clamp( x, bounds.left, bounds.right, box.width );
|
|
109
116
|
y = clamp( y, bounds.top, bounds.bottom, box.height );
|
|
@@ -122,8 +129,7 @@ export const getBoundedPosition = ( {
|
|
|
122
129
|
* alone would let the box spill into an `overflow: hidden` card and be cut off.
|
|
123
130
|
*
|
|
124
131
|
* Re-measures on every render, so a box whose content changes width between
|
|
125
|
-
* two hovers is placed for its current size.
|
|
126
|
-
* below-axis exception.
|
|
132
|
+
* two hovers is placed for its current size.
|
|
127
133
|
*
|
|
128
134
|
* @param props - visx `Tooltip` props.
|
|
129
135
|
* @param props.left - Anchor x, in wrapper coordinates.
|
|
@@ -133,7 +139,7 @@ export const getBoundedPosition = ( {
|
|
|
133
139
|
* @param props.style - Box styles; visx's defaults unless `unstyled`.
|
|
134
140
|
* @param props.unstyled - Skip `style` and leave the box bare.
|
|
135
141
|
* @param props.children - Box content.
|
|
136
|
-
* @param props.placement -
|
|
142
|
+
* @param props.placement - Below-axis, beside without vertical flipping, or automatic flipping.
|
|
137
143
|
* @return The tooltip box.
|
|
138
144
|
*/
|
|
139
145
|
export const BoundedTooltip = ( {
|