@automattic/charts 4.1.1 → 4.3.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 +39 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1335 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +129 -8
- package/dist/index.d.ts +129 -8
- package/dist/index.js +1336 -351
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +137 -70
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +87 -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 +3 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
- package/src/charts/bar-chart/types.ts +39 -0
- 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 +149 -36
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- 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/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +37 -22
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +128 -41
- 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-chart-margin.test.tsx +35 -0
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +14 -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
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useChartRegistration,
|
|
10
10
|
useGlobalChartsContext,
|
|
11
11
|
} from '../../../providers';
|
|
12
|
+
import { useChartLegendItems } from '../hooks/use-chart-legend-items';
|
|
12
13
|
import { Legend } from '../legend';
|
|
13
14
|
import { BaseLegend } from '../private/base-legend';
|
|
14
15
|
import type { ChartType } from '../../../types';
|
|
@@ -49,6 +50,26 @@ describe( 'BaseLegend', () => {
|
|
|
49
50
|
expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
|
|
50
51
|
} );
|
|
51
52
|
|
|
53
|
+
test( 'preserves each duplicate label, color, and following item', () => {
|
|
54
|
+
render(
|
|
55
|
+
<BaseLegend
|
|
56
|
+
items={ [
|
|
57
|
+
{ label: 'Views', color: '#ff0000' },
|
|
58
|
+
{ label: 'Views', color: '#00ff00', interactive: false },
|
|
59
|
+
{ label: 'Visitors', color: '#0000ff' },
|
|
60
|
+
] }
|
|
61
|
+
shape={ TestShape }
|
|
62
|
+
labelFormat={ label => `Metric: ${ label }` }
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
expect( screen.getAllByText( 'Metric: Views' ) ).toHaveLength( 2 );
|
|
66
|
+
expect( screen.getByText( 'Metric: Visitors' ) ).toBeInTheDocument();
|
|
67
|
+
const markers = screen.getAllByTestId( 'legend-marker' );
|
|
68
|
+
expect( markers[ 0 ] ).toHaveAttribute( 'fill', '#ff0000' );
|
|
69
|
+
expect( markers[ 1 ] ).toHaveAttribute( 'fill', '#00ff00' );
|
|
70
|
+
expect( markers[ 2 ] ).toHaveAttribute( 'fill', '#0000ff' );
|
|
71
|
+
} );
|
|
72
|
+
|
|
52
73
|
test( 'handles empty items array', () => {
|
|
53
74
|
render( <BaseLegend items={ [] } orientation="horizontal" /> );
|
|
54
75
|
const legendItems = screen.queryAllByRole( 'listitem' );
|
|
@@ -754,6 +775,69 @@ describe( 'BaseLegend', () => {
|
|
|
754
775
|
expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
|
|
755
776
|
} );
|
|
756
777
|
|
|
778
|
+
it( 'keeps an item with interactive: false static inside an interactive legend', async () => {
|
|
779
|
+
const user = userEvent.setup();
|
|
780
|
+
const items = [
|
|
781
|
+
{ label: 'Views', color: '#0000ff' },
|
|
782
|
+
{ label: 'Comparison period', color: '#0000ff', interactive: false },
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
render(
|
|
786
|
+
<GlobalChartsProvider>
|
|
787
|
+
<BaseLegend items={ items } interactive={ true } chartId="test-chart" />
|
|
788
|
+
</GlobalChartsProvider>
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
|
|
792
|
+
const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
|
|
793
|
+
expect( staticItem ).toHaveTextContent( 'Comparison period' );
|
|
794
|
+
expect( staticItem ).not.toHaveAttribute( 'role' );
|
|
795
|
+
expect( staticItem ).not.toHaveAttribute( 'tabindex' );
|
|
796
|
+
expect( staticItem ).not.toHaveAttribute( 'aria-pressed' );
|
|
797
|
+
|
|
798
|
+
await user.click( staticItem );
|
|
799
|
+
|
|
800
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
801
|
+
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
802
|
+
} );
|
|
803
|
+
|
|
804
|
+
it( 'keeps a comparison item distinct when its label collides', async () => {
|
|
805
|
+
const user = userEvent.setup();
|
|
806
|
+
const Example = () => {
|
|
807
|
+
const items = useChartLegendItems(
|
|
808
|
+
[
|
|
809
|
+
{
|
|
810
|
+
label: 'Comparison period',
|
|
811
|
+
group: 'metric',
|
|
812
|
+
data: [ { label: 'Mon', value: 10 } ],
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
label: 'Previous metric',
|
|
816
|
+
group: 'metric',
|
|
817
|
+
options: { type: 'comparison' },
|
|
818
|
+
data: [ { label: 'Mon', value: 5 } ],
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
{ collapseGroups: true, comparisonItem: true },
|
|
822
|
+
'line'
|
|
823
|
+
);
|
|
824
|
+
return <BaseLegend items={ items } interactive chartId="test-chart" />;
|
|
825
|
+
};
|
|
826
|
+
render(
|
|
827
|
+
<GlobalChartsProvider>
|
|
828
|
+
<Example />
|
|
829
|
+
</GlobalChartsProvider>
|
|
830
|
+
);
|
|
831
|
+
expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
|
|
832
|
+
const staticItem = screen.getAllByTestId( 'legend-item' )[ 1 ];
|
|
833
|
+
expect( staticItem ).not.toHaveAttribute( 'role' );
|
|
834
|
+
await user.click( screen.getByRole( 'button' ) );
|
|
835
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
836
|
+
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
837
|
+
await user.click( staticItem );
|
|
838
|
+
expect( screen.getByRole( 'button' ) ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
839
|
+
} );
|
|
840
|
+
|
|
757
841
|
it( 'falls back to the item label when seriesLabels is an empty array', async () => {
|
|
758
842
|
const user = userEvent.setup();
|
|
759
843
|
|
|
@@ -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
|
}
|
|
@@ -134,7 +172,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
134
172
|
tooltipContext?.showTooltip( {
|
|
135
173
|
datum: tooltipItem.datum,
|
|
136
174
|
key: tooltipItem.seriesLabel,
|
|
137
|
-
index: tooltipItem.
|
|
175
|
+
index: tooltipItem.dataPointIndex,
|
|
138
176
|
} );
|
|
139
177
|
}
|
|
140
178
|
}
|
|
@@ -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 = ( {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
3
4
|
import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
|
|
4
5
|
import { GlobalChartsProvider } from '../../../providers';
|
|
5
6
|
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
7
|
+
import { useKeyboardNavigation } from '../accessible-tooltip';
|
|
6
8
|
import type { ReactNode } from 'react';
|
|
7
9
|
|
|
8
10
|
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
@@ -124,3 +126,129 @@ describe( 'AccessibleTooltip', () => {
|
|
|
124
126
|
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
|
|
125
127
|
} );
|
|
126
128
|
} );
|
|
129
|
+
|
|
130
|
+
// Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
|
|
131
|
+
const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
|
|
132
|
+
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
133
|
+
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
134
|
+
const chartRef = useRef< HTMLDivElement >( null );
|
|
135
|
+
const { onChartKeyDown } = useKeyboardNavigation( {
|
|
136
|
+
selectedIndex,
|
|
137
|
+
setSelectedIndex,
|
|
138
|
+
isNavigating,
|
|
139
|
+
setIsNavigating,
|
|
140
|
+
chartRef,
|
|
141
|
+
totalPoints,
|
|
142
|
+
} );
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
|
|
147
|
+
<div ref={ chartRef } data-testid="selected-index">
|
|
148
|
+
{ selectedIndex ?? 'none' }
|
|
149
|
+
<button type="button">Inside</button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<button type="button">Outside</button>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe( 'useKeyboardNavigation', () => {
|
|
158
|
+
const navigate = async ( presses: number ) => {
|
|
159
|
+
const user = userEvent.setup();
|
|
160
|
+
const view = render( <NavigationHarness totalPoints={ 6 } /> );
|
|
161
|
+
|
|
162
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
163
|
+
for ( let i = 0; i < presses; i++ ) {
|
|
164
|
+
await user.keyboard( '{ArrowRight}' );
|
|
165
|
+
}
|
|
166
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
|
|
167
|
+
|
|
168
|
+
return { user, view };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
|
|
172
|
+
const { view } = await navigate( 6 );
|
|
173
|
+
|
|
174
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
175
|
+
|
|
176
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
177
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
// In range rather than past the end: the effect reconciles on any count change, not only an overflow.
|
|
181
|
+
it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
|
|
182
|
+
const { view } = await navigate( 2 );
|
|
183
|
+
|
|
184
|
+
screen.getByRole( 'button', { name: 'Outside' } ).focus();
|
|
185
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
186
|
+
|
|
187
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'returns focus to the grid on Escape when there are no points', async () => {
|
|
191
|
+
const user = userEvent.setup();
|
|
192
|
+
render( <NavigationHarness totalPoints={ 0 } /> );
|
|
193
|
+
|
|
194
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
195
|
+
await user.keyboard( '{Escape}' );
|
|
196
|
+
|
|
197
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
198
|
+
} );
|
|
199
|
+
|
|
200
|
+
it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
|
|
201
|
+
const { view } = await navigate( 2 );
|
|
202
|
+
|
|
203
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
204
|
+
view.rerender( <NavigationHarness totalPoints={ 0 } /> );
|
|
205
|
+
|
|
206
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
207
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
208
|
+
} );
|
|
209
|
+
|
|
210
|
+
// The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
|
|
211
|
+
const recordPageKeyDown = () => {
|
|
212
|
+
const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
|
|
213
|
+
document.addEventListener( 'keydown', onKeyDown );
|
|
214
|
+
return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
it( 'leaves keys it does not handle available to the page', async () => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
220
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
224
|
+
await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
|
|
225
|
+
|
|
226
|
+
expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
|
|
227
|
+
for ( const [ event ] of onKeyDown.mock.calls ) {
|
|
228
|
+
expect( event.defaultPrevented ).toBe( false );
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
stop();
|
|
232
|
+
}
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
// `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
|
|
236
|
+
it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
239
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
243
|
+
await user.keyboard( '{Escape}' );
|
|
244
|
+
|
|
245
|
+
expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
|
|
246
|
+
|
|
247
|
+
await user.keyboard( '{ArrowRight}{Escape}' );
|
|
248
|
+
|
|
249
|
+
expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
|
|
250
|
+
} finally {
|
|
251
|
+
stop();
|
|
252
|
+
}
|
|
253
|
+
} );
|
|
254
|
+
} );
|
|
@@ -5,6 +5,23 @@ const BOX = { width: 208, height: 36 };
|
|
|
5
5
|
const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
|
|
6
6
|
|
|
7
7
|
describe( 'getBoundedPosition', () => {
|
|
8
|
+
test.each( [
|
|
9
|
+
[ 40, 50 ],
|
|
10
|
+
[ 700, 425 ],
|
|
11
|
+
] )( 'keeps a beside tooltip at its anchor and flips horizontally at %s', ( left, x ) => {
|
|
12
|
+
expect(
|
|
13
|
+
getBoundedPosition( {
|
|
14
|
+
left,
|
|
15
|
+
top: -20,
|
|
16
|
+
...OFFSETS,
|
|
17
|
+
box: { width: 265, height: 382 },
|
|
18
|
+
wrapper: { left: 0, top: 0, right: 800, bottom: 300 },
|
|
19
|
+
bounds: { left: 0, top: -100, right: 800, bottom: 500 },
|
|
20
|
+
placement: 'beside',
|
|
21
|
+
} )
|
|
22
|
+
).toEqual( { x, y: -20 } );
|
|
23
|
+
} );
|
|
24
|
+
|
|
8
25
|
test( 'sits below and to the right of the anchor when that fits', () => {
|
|
9
26
|
expect(
|
|
10
27
|
getBoundedPosition( {
|
|
@@ -139,6 +139,13 @@ describe( 'XyChartTooltip', () => {
|
|
|
139
139
|
} );
|
|
140
140
|
} );
|
|
141
141
|
|
|
142
|
+
test( 'pins beside placement to the explicit SVG top anchor', async () => {
|
|
143
|
+
renderChart( { tooltipPlacement: 'beside', tooltipAnchorTop: 20, snapTooltipToDatumY: true } );
|
|
144
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
|
|
145
|
+
transform: 'translate(133px, 20px)',
|
|
146
|
+
} );
|
|
147
|
+
} );
|
|
148
|
+
|
|
142
149
|
test( 'snaps the box to the nearest datum', async () => {
|
|
143
150
|
renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
|
|
144
151
|
|