@automattic/charts 4.3.0 → 4.5.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 +20 -0
- package/TOKENS.md +2 -2
- package/dist/index.cjs +584 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +4 -0
- package/dist/index.d.cts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +585 -210
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +57 -23
- package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +1 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
- package/src/charts/bar-chart/types.ts +5 -1
- package/src/charts/geo-chart/geo-chart.tsx +4 -0
- package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
- package/src/charts/geo-chart/types.ts +5 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
- package/src/charts/pie-chart/pie-chart.module.scss +4 -0
- package/src/charts/pie-chart/pie-chart.tsx +103 -7
- package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
- package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
- package/src/components/tooltip/accessible-tooltip.tsx +40 -4
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
- package/src/providers/chart-context/global-charts-provider.tsx +57 -41
- package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
- package/src/providers/chart-context/private/palette-generator.ts +305 -0
- package/src/providers/chart-context/private/perceptual-color.ts +219 -0
- package/src/providers/chart-context/private/series-palette.ts +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
- package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
- package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
- package/src/test-utils/zoned-parsing-suite.ts +1 -1
- package/src/utils/color-utils.ts +5 -3
- package/src/utils/test/date-formatting.test.ts +1 -1
- package/src/providers/chart-context/private/get-chart-color.ts +0 -223
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { Pie } from '@visx/shape';
|
|
3
3
|
import { useTooltip } from '@visx/tooltip';
|
|
4
|
+
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
4
5
|
import clsx from 'clsx';
|
|
5
|
-
import { useCallback, useContext, useMemo, useRef } from 'react';
|
|
6
|
+
import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
6
7
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
7
8
|
import { BaseTooltip } from '../../components/tooltip';
|
|
8
9
|
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
@@ -19,8 +20,16 @@ import {
|
|
|
19
20
|
useGlobalChartsTheme,
|
|
20
21
|
GlobalChartsContext,
|
|
21
22
|
} from '../../providers';
|
|
23
|
+
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
24
|
+
import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
|
|
22
25
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
23
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
attachSubComponents,
|
|
28
|
+
createCssVariableResolver,
|
|
29
|
+
isValidHexColor,
|
|
30
|
+
normalizeColorToHex,
|
|
31
|
+
resolveFontSize,
|
|
32
|
+
} from '../../utils';
|
|
24
33
|
import { getStringWidth } from '../../visx/text';
|
|
25
34
|
import { Center } from '../private/center';
|
|
26
35
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
@@ -161,6 +170,50 @@ const validateData = ( data: DataPointPercentage[] ) => {
|
|
|
161
170
|
return { isValid: true, message: '' };
|
|
162
171
|
};
|
|
163
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The label pointers, resolved at the pie chart's own root element so JS and CSS agree on what paints.
|
|
175
|
+
*/
|
|
176
|
+
interface ResolvedLabelPointers {
|
|
177
|
+
/** True while a label plate is set; the label text then always uses the inverse role. */
|
|
178
|
+
hasPlate: boolean;
|
|
179
|
+
labelHex: string;
|
|
180
|
+
labelInverseHex: string;
|
|
181
|
+
/** True when the inverse role is see-through, so only the `label` role can be seen on a fill. */
|
|
182
|
+
isInverseSeeThrough: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
|
|
187
|
+
*
|
|
188
|
+
* A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
|
|
189
|
+
* Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
|
|
190
|
+
* yet resolved, or labels off) keep the default inverse role.
|
|
191
|
+
*
|
|
192
|
+
* @param fill - The slice's resolved fill color.
|
|
193
|
+
* @param pointers - The label pointers, resolved at the chart's own root element.
|
|
194
|
+
* @return Whether the label needs dark text.
|
|
195
|
+
*/
|
|
196
|
+
const labelNeedsDarkText = ( fill: string, pointers: ResolvedLabelPointers | null ): boolean => {
|
|
197
|
+
if ( ! pointers || pointers.hasPlate ) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fillHex = normalizeColorToHex( fill );
|
|
202
|
+
if ( ! isValidHexColor( fillHex ) || ! isValidHexColor( pointers.labelHex ) ) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if ( pointers.isInverseSeeThrough ) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if ( ! isValidHexColor( pointers.labelInverseHex ) ) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
contrastRatio( fillHex, pointers.labelHex ) > contrastRatio( fillHex, pointers.labelInverseHex )
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
164
217
|
/**
|
|
165
218
|
* Renders a pie or donut chart using the provided data.
|
|
166
219
|
*
|
|
@@ -202,6 +255,11 @@ const PieChartInternal = ( {
|
|
|
202
255
|
// The tooltip renders inside this element, so pointer coordinates are taken relative to it.
|
|
203
256
|
const containerRef = useRef< HTMLDivElement >( null );
|
|
204
257
|
|
|
258
|
+
// The element the chart's own `className` lands on, so an override set there reaches this
|
|
259
|
+
// decision the same way it reaches CSS.
|
|
260
|
+
const rootRef = useRef< HTMLDivElement >( null );
|
|
261
|
+
const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
|
|
262
|
+
|
|
205
263
|
const onMouseLeave = useCallback( () => {
|
|
206
264
|
if ( ! withTooltips ) {
|
|
207
265
|
return;
|
|
@@ -209,7 +267,41 @@ const PieChartInternal = ( {
|
|
|
209
267
|
hideTooltip();
|
|
210
268
|
}, [ withTooltips, hideTooltip ] );
|
|
211
269
|
|
|
212
|
-
const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
|
|
270
|
+
const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
|
|
271
|
+
const { isValid, message } = validateData( data );
|
|
272
|
+
|
|
273
|
+
// Skipped when labels are off, or before the chart's own root element mounts (the invalid-data
|
|
274
|
+
// branch renders a plain div, so `rootRef` is not yet attached).
|
|
275
|
+
useLayoutEffect( () => {
|
|
276
|
+
if ( ! showLabels || ! rootRef.current ) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const resolve = createCssVariableResolver( rootRef.current );
|
|
281
|
+
const rawLabelBackground = resolve( CATALOG_POINTERS.labelBackground );
|
|
282
|
+
// Hex drops alpha, so a see-through label role would win the comparison and paint nothing.
|
|
283
|
+
const rawLabel = resolve( CATALOG_POINTERS.label );
|
|
284
|
+
const isLabelOpaque = rawLabel ? d3Color( rawLabel )?.opacity === 1 : false;
|
|
285
|
+
const rawLabelInverse = resolve( CATALOG_POINTERS.labelInverse );
|
|
286
|
+
const labelInverseColor = rawLabelInverse ? d3Color( rawLabelInverse ) : null;
|
|
287
|
+
// A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
|
|
288
|
+
const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
|
|
289
|
+
const next: ResolvedLabelPointers = {
|
|
290
|
+
hasPlate: rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false,
|
|
291
|
+
labelHex: isLabelOpaque ? normalizeColorToHex( CATALOG_POINTERS.label, null, resolve ) : '',
|
|
292
|
+
labelInverseHex: normalizeColorToHex( CATALOG_POINTERS.labelInverse, null, resolve ),
|
|
293
|
+
isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false,
|
|
294
|
+
};
|
|
295
|
+
setLabelPointers( previous =>
|
|
296
|
+
previous &&
|
|
297
|
+
previous.hasPlate === next.hasPlate &&
|
|
298
|
+
previous.labelHex === next.labelHex &&
|
|
299
|
+
previous.labelInverseHex === next.labelInverseHex &&
|
|
300
|
+
previous.isInverseSeeThrough === next.isInverseSeeThrough
|
|
301
|
+
? previous
|
|
302
|
+
: next
|
|
303
|
+
);
|
|
304
|
+
}, [ showLabels, className, isColorPaletteResolved, isValid ] );
|
|
213
305
|
|
|
214
306
|
// Calculate percentages from values (single source of truth)
|
|
215
307
|
const dataWithPercentages = useDataWithPercentages( data );
|
|
@@ -230,8 +322,6 @@ const PieChartInternal = ( {
|
|
|
230
322
|
// Create legend items using legendData (has recalculated percentages for visible items)
|
|
231
323
|
const legendItems = useChartLegendItems( legendData, legendOptions );
|
|
232
324
|
|
|
233
|
-
const { isValid, message } = validateData( data );
|
|
234
|
-
|
|
235
325
|
// Process children to extract compound components
|
|
236
326
|
const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
|
|
237
327
|
children,
|
|
@@ -309,6 +399,7 @@ const PieChartInternal = ( {
|
|
|
309
399
|
legendElement={ legendElement }
|
|
310
400
|
legendChildren={ legendChildren }
|
|
311
401
|
gap={ gap }
|
|
402
|
+
rootRef={ rootRef }
|
|
312
403
|
className={ clsx(
|
|
313
404
|
'pie-chart',
|
|
314
405
|
styles[ 'pie-chart' ],
|
|
@@ -405,11 +496,12 @@ const PieChartInternal = ( {
|
|
|
405
496
|
} );
|
|
406
497
|
};
|
|
407
498
|
|
|
499
|
+
const fill = accessors.fill( arc.data );
|
|
408
500
|
const pathProps: SVGProps< SVGPathElement > & {
|
|
409
501
|
'data-testid'?: string;
|
|
410
502
|
} = {
|
|
411
503
|
d: pie.path( arc ) || '',
|
|
412
|
-
fill
|
|
504
|
+
fill,
|
|
413
505
|
'data-testid': 'pie-segment',
|
|
414
506
|
};
|
|
415
507
|
|
|
@@ -446,7 +538,11 @@ const PieChartInternal = ( {
|
|
|
446
538
|
pointerEvents="none"
|
|
447
539
|
/>
|
|
448
540
|
<text
|
|
449
|
-
className={ styles[ 'pie-chart__label-text' ]
|
|
541
|
+
className={ clsx( styles[ 'pie-chart__label-text' ], {
|
|
542
|
+
[ styles[ 'pie-chart__label-text--on-light' ] ]:
|
|
543
|
+
labelNeedsDarkText( fill, labelPointers ),
|
|
544
|
+
} ) }
|
|
545
|
+
data-testid="pie-label"
|
|
450
546
|
x={ centroidX }
|
|
451
547
|
y={ centroidY }
|
|
452
548
|
dy=".33em"
|
|
@@ -149,6 +149,155 @@ describe( 'PieChart', () => {
|
|
|
149
149
|
} );
|
|
150
150
|
} );
|
|
151
151
|
|
|
152
|
+
describe( 'Label Text Contrast', () => {
|
|
153
|
+
const contrastData = [
|
|
154
|
+
{ label: 'Light', value: 60, color: '#f5d76e' },
|
|
155
|
+
{ label: 'Dark', value: 40, color: '#1e3a8a' },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
let injectedStyle: HTMLStyleElement | null = null;
|
|
159
|
+
|
|
160
|
+
afterEach( () => {
|
|
161
|
+
if ( injectedStyle ) {
|
|
162
|
+
document.head.removeChild( injectedStyle );
|
|
163
|
+
injectedStyle = null;
|
|
164
|
+
}
|
|
165
|
+
} );
|
|
166
|
+
|
|
167
|
+
test( 'uses dark label text on a light slice', () => {
|
|
168
|
+
renderWithTheme( { data: contrastData } );
|
|
169
|
+
|
|
170
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
171
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
172
|
+
expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
173
|
+
} );
|
|
174
|
+
|
|
175
|
+
test( "keeps the inverse label color when a label plate is set on the chart's own class", () => {
|
|
176
|
+
injectedStyle = document.createElement( 'style' );
|
|
177
|
+
injectedStyle.textContent =
|
|
178
|
+
'.plated-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
|
|
179
|
+
document.head.appendChild( injectedStyle );
|
|
180
|
+
|
|
181
|
+
renderWithTheme( { data: contrastData, className: 'plated-pie' } );
|
|
182
|
+
|
|
183
|
+
screen
|
|
184
|
+
.getAllByTestId( 'pie-label' )
|
|
185
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
186
|
+
} );
|
|
187
|
+
|
|
188
|
+
test( "compares against a label-inverse override set on the chart's own class", () => {
|
|
189
|
+
injectedStyle = document.createElement( 'style' );
|
|
190
|
+
// Matches the slice fill exactly, so the inverse role loses to the default dark label role.
|
|
191
|
+
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #1e3a8a; }';
|
|
192
|
+
document.head.appendChild( injectedStyle );
|
|
193
|
+
|
|
194
|
+
renderWithTheme( {
|
|
195
|
+
data: [ { label: 'Dark', value: 100, color: '#1e3a8a' } ],
|
|
196
|
+
className: 'overridden-pie',
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
const [ label ] = screen.getAllByTestId( 'pie-label' );
|
|
200
|
+
expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
201
|
+
} );
|
|
202
|
+
|
|
203
|
+
test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
|
|
204
|
+
injectedStyle = document.createElement( 'style' );
|
|
205
|
+
injectedStyle.textContent =
|
|
206
|
+
'.modern-plate-pie { --a8c-charts-color-label-background: rgb(0 0 0 / 50%); }';
|
|
207
|
+
document.head.appendChild( injectedStyle );
|
|
208
|
+
|
|
209
|
+
renderWithTheme( { data: contrastData, className: 'modern-plate-pie' } );
|
|
210
|
+
|
|
211
|
+
screen
|
|
212
|
+
.getAllByTestId( 'pie-label' )
|
|
213
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
214
|
+
} );
|
|
215
|
+
|
|
216
|
+
test( 'keeps the inverse label color when the label role is see-through', () => {
|
|
217
|
+
injectedStyle = document.createElement( 'style' );
|
|
218
|
+
injectedStyle.textContent =
|
|
219
|
+
'.clear-label-pie { --a8c-charts-color-label: rgba(0, 0, 0, 0); }';
|
|
220
|
+
document.head.appendChild( injectedStyle );
|
|
221
|
+
|
|
222
|
+
renderWithTheme( { data: contrastData, className: 'clear-label-pie' } );
|
|
223
|
+
|
|
224
|
+
screen
|
|
225
|
+
.getAllByTestId( 'pie-label' )
|
|
226
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
227
|
+
} );
|
|
228
|
+
|
|
229
|
+
test( 'keeps one label color on every slice when both label roles are the same', () => {
|
|
230
|
+
injectedStyle = document.createElement( 'style' );
|
|
231
|
+
injectedStyle.textContent =
|
|
232
|
+
'.single-label-pie { --a8c-charts-color-label: #ffffff; --a8c-charts-color-label-inverse: #ffffff; }';
|
|
233
|
+
document.head.appendChild( injectedStyle );
|
|
234
|
+
|
|
235
|
+
renderWithTheme( { data: contrastData, className: 'single-label-pie' } );
|
|
236
|
+
|
|
237
|
+
screen
|
|
238
|
+
.getAllByTestId( 'pie-label' )
|
|
239
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
240
|
+
} );
|
|
241
|
+
|
|
242
|
+
test( 'uses the label role on every slice when the inverse role is see-through', () => {
|
|
243
|
+
injectedStyle = document.createElement( 'style' );
|
|
244
|
+
injectedStyle.textContent =
|
|
245
|
+
'.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
|
|
246
|
+
document.head.appendChild( injectedStyle );
|
|
247
|
+
|
|
248
|
+
renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
|
|
249
|
+
|
|
250
|
+
screen
|
|
251
|
+
.getAllByTestId( 'pie-label' )
|
|
252
|
+
.forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
test( 'keeps the inverse label color when the fill cannot be resolved', () => {
|
|
256
|
+
renderWithTheme( {
|
|
257
|
+
data: [ { label: 'Unresolved', value: 100, color: 'var(--not-set)' } ],
|
|
258
|
+
} );
|
|
259
|
+
|
|
260
|
+
screen
|
|
261
|
+
.getAllByTestId( 'pie-label' )
|
|
262
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
263
|
+
} );
|
|
264
|
+
|
|
265
|
+
describe( 'after an invalid-to-valid data transition', () => {
|
|
266
|
+
test( "keeps the inverse label color when a plate is set on the chart's own class", () => {
|
|
267
|
+
injectedStyle = document.createElement( 'style' );
|
|
268
|
+
injectedStyle.textContent =
|
|
269
|
+
'.plated-late-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
|
|
270
|
+
document.head.appendChild( injectedStyle );
|
|
271
|
+
|
|
272
|
+
const { rerender } = renderWithTheme( { data: [], className: 'plated-late-pie' } );
|
|
273
|
+
|
|
274
|
+
rerender(
|
|
275
|
+
<GlobalChartsProvider>
|
|
276
|
+
<PieChart { ...defaultProps } data={ contrastData } className="plated-late-pie" />
|
|
277
|
+
</GlobalChartsProvider>
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
screen
|
|
281
|
+
.getAllByTestId( 'pie-label' )
|
|
282
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
test( 'resolves the label pointers once the chart mounts its own root element', () => {
|
|
286
|
+
const { rerender } = renderWithTheme( { data: [] } );
|
|
287
|
+
|
|
288
|
+
rerender(
|
|
289
|
+
<GlobalChartsProvider>
|
|
290
|
+
<PieChart { ...defaultProps } data={ contrastData } />
|
|
291
|
+
</GlobalChartsProvider>
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
295
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
296
|
+
expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
297
|
+
} );
|
|
298
|
+
} );
|
|
299
|
+
} );
|
|
300
|
+
|
|
152
301
|
describe( 'Legend Value Display', () => {
|
|
153
302
|
// Values that give clean percentages: 60/100=60%, 23/100=23%, 17/100=17%
|
|
154
303
|
const testData = [
|
|
@@ -5,7 +5,7 @@ import { renderLegendSlot } from '../chart-composition';
|
|
|
5
5
|
import styles from './chart-layout.module.scss';
|
|
6
6
|
import type { GapSize, LegendPosition } from '../../../types';
|
|
7
7
|
import type { LegendChild } from '../chart-composition/use-chart-children';
|
|
8
|
-
import type { CSSProperties, ReactNode } from 'react';
|
|
8
|
+
import type { CSSProperties, ReactNode, Ref } from 'react';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Measurements provided to the render prop when ChartLayout handles resize listening.
|
|
@@ -36,6 +36,8 @@ export interface ChartLayoutProps {
|
|
|
36
36
|
gap?: GapSize;
|
|
37
37
|
/** Additional class names */
|
|
38
38
|
className?: string;
|
|
39
|
+
/** Ref to the root element, the one `className` lands on */
|
|
40
|
+
rootRef?: Ref< HTMLDivElement >;
|
|
39
41
|
/** Inline styles (width, height, etc.) */
|
|
40
42
|
style?: CSSProperties;
|
|
41
43
|
/** Test ID for the container */
|
|
@@ -53,6 +55,7 @@ export const ChartLayout = ( {
|
|
|
53
55
|
onContentHeightChange,
|
|
54
56
|
gap,
|
|
55
57
|
className,
|
|
58
|
+
rootRef,
|
|
56
59
|
style,
|
|
57
60
|
'data-testid': dataTestId,
|
|
58
61
|
'data-chart-id': dataChartId,
|
|
@@ -78,6 +81,7 @@ export const ChartLayout = ( {
|
|
|
78
81
|
|
|
79
82
|
return (
|
|
80
83
|
<Stack
|
|
84
|
+
ref={ rootRef }
|
|
81
85
|
direction="column"
|
|
82
86
|
gap={ gap }
|
|
83
87
|
className={ className }
|
|
@@ -341,10 +341,41 @@ export const useKeyboardNavigation = ( {
|
|
|
341
341
|
[ isNavigating, selectedIndex, setSelectedIndex ]
|
|
342
342
|
);
|
|
343
343
|
|
|
344
|
+
// The point the pointer took over a keyboard selection at, where the next arrow key continues from.
|
|
345
|
+
const pointerIndex = useRef< number | undefined >( undefined );
|
|
346
|
+
|
|
347
|
+
// The pointer is the latest input, so it ends a keyboard selection instead of competing with it.
|
|
348
|
+
const onChartPointerMove = useCallback(
|
|
349
|
+
( index: number ) => {
|
|
350
|
+
if ( selectedIndex === undefined ) {
|
|
351
|
+
if ( pointerIndex.current !== undefined ) {
|
|
352
|
+
pointerIndex.current = index;
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const root = getChartRoot();
|
|
357
|
+
const activeElement = root?.ownerDocument.activeElement;
|
|
358
|
+
// Focus elsewhere on the page stays there; a hover must not pull it back into the chart.
|
|
359
|
+
if ( activeElement && root.contains( activeElement ) ) {
|
|
360
|
+
pointerIndex.current = index;
|
|
361
|
+
focusWithoutScrollIfNeeded( root );
|
|
362
|
+
}
|
|
363
|
+
setSelectedIndex( undefined );
|
|
364
|
+
setIsNavigating( false );
|
|
365
|
+
},
|
|
366
|
+
[ selectedIndex, setSelectedIndex, setIsNavigating, getChartRoot, focusWithoutScrollIfNeeded ]
|
|
367
|
+
);
|
|
368
|
+
|
|
344
369
|
// On each blur of chart, keyboard navigation should restart from first tooltip
|
|
345
|
-
const onChartBlur = useCallback(
|
|
346
|
-
|
|
347
|
-
|
|
370
|
+
const onChartBlur = useCallback(
|
|
371
|
+
( event: React.FocusEvent< HTMLDivElement > ) => {
|
|
372
|
+
setIsNavigating( false );
|
|
373
|
+
if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
|
|
374
|
+
pointerIndex.current = undefined;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
[ setIsNavigating ]
|
|
378
|
+
);
|
|
348
379
|
|
|
349
380
|
const onChartKeyDown = useCallback(
|
|
350
381
|
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
@@ -356,12 +387,16 @@ export const useKeyboardNavigation = ( {
|
|
|
356
387
|
focusWithoutScrollIfNeeded( getChartRoot() );
|
|
357
388
|
setSelectedIndex( undefined );
|
|
358
389
|
setIsNavigating( false );
|
|
390
|
+
pointerIndex.current = undefined;
|
|
359
391
|
return;
|
|
360
392
|
}
|
|
361
393
|
|
|
362
394
|
if ( totalPoints === 0 ) return;
|
|
363
395
|
|
|
364
|
-
const currentSelectedIndex = selectedIndex
|
|
396
|
+
const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
|
|
397
|
+
if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) {
|
|
398
|
+
pointerIndex.current = undefined;
|
|
399
|
+
}
|
|
365
400
|
|
|
366
401
|
// WAI-ARIA grid: arrows stop at the first and last cell.
|
|
367
402
|
if ( event.key === 'ArrowRight' ) {
|
|
@@ -393,5 +428,6 @@ export const useKeyboardNavigation = ( {
|
|
|
393
428
|
onChartFocus,
|
|
394
429
|
onChartBlur,
|
|
395
430
|
onChartKeyDown,
|
|
431
|
+
onChartPointerMove,
|
|
396
432
|
};
|
|
397
433
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
4
4
|
import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
|
|
5
5
|
import { GlobalChartsProvider } from '../../../providers';
|
|
6
6
|
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
@@ -128,11 +128,17 @@ describe( 'AccessibleTooltip', () => {
|
|
|
128
128
|
} );
|
|
129
129
|
|
|
130
130
|
// Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
|
|
131
|
-
const NavigationHarness = ( {
|
|
131
|
+
const NavigationHarness = ( {
|
|
132
|
+
totalPoints,
|
|
133
|
+
trackBlur = false,
|
|
134
|
+
}: {
|
|
135
|
+
totalPoints: number;
|
|
136
|
+
trackBlur?: boolean;
|
|
137
|
+
} ) => {
|
|
132
138
|
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
133
139
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
134
140
|
const chartRef = useRef< HTMLDivElement >( null );
|
|
135
|
-
const { onChartKeyDown } = useKeyboardNavigation( {
|
|
141
|
+
const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
|
|
136
142
|
selectedIndex,
|
|
137
143
|
setSelectedIndex,
|
|
138
144
|
isNavigating,
|
|
@@ -140,14 +146,24 @@ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
|
|
|
140
146
|
chartRef,
|
|
141
147
|
totalPoints,
|
|
142
148
|
} );
|
|
149
|
+
const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
|
|
150
|
+
const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
|
|
143
151
|
|
|
144
152
|
return (
|
|
145
153
|
<>
|
|
146
|
-
<div
|
|
154
|
+
<div
|
|
155
|
+
role="grid"
|
|
156
|
+
aria-label="Harness"
|
|
157
|
+
tabIndex={ 0 }
|
|
158
|
+
onKeyDown={ onChartKeyDown }
|
|
159
|
+
onBlur={ trackBlur ? onChartBlur : undefined }
|
|
160
|
+
>
|
|
147
161
|
<div ref={ chartRef } data-testid="selected-index">
|
|
148
162
|
{ selectedIndex ?? 'none' }
|
|
149
163
|
<button type="button">Inside</button>
|
|
150
164
|
</div>
|
|
165
|
+
<div data-testid="point-4" onPointerMove={ moveToFour } />
|
|
166
|
+
<div data-testid="point-1" onPointerMove={ moveToOne } />
|
|
151
167
|
</div>
|
|
152
168
|
<button type="button">Outside</button>
|
|
153
169
|
</>
|
|
@@ -207,6 +223,67 @@ describe( 'useKeyboardNavigation', () => {
|
|
|
207
223
|
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
208
224
|
} );
|
|
209
225
|
|
|
226
|
+
it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
|
|
227
|
+
const { user } = await navigate( 2 );
|
|
228
|
+
|
|
229
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
230
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
231
|
+
|
|
232
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
233
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
234
|
+
|
|
235
|
+
await user.keyboard( '{ArrowRight}' );
|
|
236
|
+
|
|
237
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
|
|
238
|
+
} );
|
|
239
|
+
|
|
240
|
+
it( 'continues from the last point the pointer reached after taking over', async () => {
|
|
241
|
+
const { user } = await navigate( 2 );
|
|
242
|
+
|
|
243
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
244
|
+
await user.hover( screen.getByTestId( 'point-1' ) );
|
|
245
|
+
await user.keyboard( '{ArrowRight}' );
|
|
246
|
+
|
|
247
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
248
|
+
} );
|
|
249
|
+
|
|
250
|
+
it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
252
|
+
render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
|
|
253
|
+
const grid = screen.getByRole( 'grid', { name: 'Harness' } );
|
|
254
|
+
|
|
255
|
+
act( () => grid.focus() );
|
|
256
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
257
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
258
|
+
act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
|
|
259
|
+
act( () => grid.focus() );
|
|
260
|
+
await user.keyboard( '{ArrowRight}' );
|
|
261
|
+
|
|
262
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
263
|
+
} );
|
|
264
|
+
|
|
265
|
+
it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
|
|
266
|
+
const { user } = await navigate( 2 );
|
|
267
|
+
const outside = screen.getByRole( 'button', { name: 'Outside' } );
|
|
268
|
+
|
|
269
|
+
outside.focus();
|
|
270
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
271
|
+
|
|
272
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
273
|
+
expect( outside ).toHaveFocus();
|
|
274
|
+
} );
|
|
275
|
+
|
|
276
|
+
it( 'ignores the pointer when there is no keyboard selection', async () => {
|
|
277
|
+
const user = userEvent.setup();
|
|
278
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
279
|
+
|
|
280
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
281
|
+
await user.hover( screen.getByTestId( 'point-4' ) );
|
|
282
|
+
await user.keyboard( '{ArrowRight}' );
|
|
283
|
+
|
|
284
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
|
|
285
|
+
} );
|
|
286
|
+
|
|
210
287
|
// The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
|
|
211
288
|
const recordPageKeyDown = () => {
|
|
212
289
|
const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
|