@automattic/charts 4.8.0 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/index.cjs +766 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -8
- package/dist/index.d.cts +54 -15
- package/dist/index.d.ts +54 -15
- package/dist/index.js +770 -281
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +7 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +12 -4
- package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
- package/src/charts/conversion-funnel-chart/types.ts +1 -1
- package/src/charts/geo-chart/geo-chart.tsx +17 -2
- package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
- package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
- package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
- package/src/charts/heatmap-chart/private/index.ts +4 -2
- package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
- package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
- package/src/charts/leaderboard-chart/types.ts +7 -1
- package/src/charts/line-chart/line-chart.tsx +8 -2
- package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
- package/src/charts/line-chart/types.ts +3 -3
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +144 -139
- package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +117 -120
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
- package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
- package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
- package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
- package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
- package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
- package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
- package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
- package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
- package/src/charts/test/accessible-name.test.tsx +181 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
- package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
- package/src/components/legend/private/base-legend.tsx +27 -15
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
- package/src/hooks/use-xychart-theme.ts +33 -2
- package/src/index.ts +1 -0
- package/src/types.ts +37 -3
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { __ } from '@wordpress/i18n';
|
|
2
2
|
import { Stack, Text } from '@wordpress/ui';
|
|
3
|
+
import clsx from 'clsx';
|
|
3
4
|
import { useContext } from 'react';
|
|
4
5
|
import { useGlobalChartsTheme } from '../../../providers';
|
|
5
6
|
import styles from '../heatmap-chart.module.scss';
|
|
6
7
|
import { HeatmapContext } from './heatmap-context';
|
|
8
|
+
import { isEmptyValue } from './use-heatmap-colors';
|
|
7
9
|
import type { CSSProperties, FC } from 'react';
|
|
8
10
|
|
|
9
11
|
export interface HeatmapLegendProps {
|
|
10
|
-
/** Number of swatches in the scale. Default 5. */
|
|
12
|
+
/** Number of color swatches in the scale, not counting the empty-cell swatch. Default 5. */
|
|
11
13
|
steps?: number;
|
|
12
14
|
/**
|
|
13
15
|
* `swatches` spaces the steps out as cell-sized squares; `bar` joins them
|
|
@@ -29,8 +31,9 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
|
29
31
|
if ( ! context ) {
|
|
30
32
|
return null;
|
|
31
33
|
}
|
|
32
|
-
const { fillVars } = context;
|
|
34
|
+
const { extent, fillVars } = context;
|
|
33
35
|
const labelStyle = legend.labelStyles;
|
|
36
|
+
const showsEmptyCell = isEmptyValue( 0, extent );
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<Stack direction="row" gap="xs" align="center">
|
|
@@ -48,6 +51,15 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
|
48
51
|
data-testid="heatmap-legend-scale"
|
|
49
52
|
className={ variant === 'bar' ? styles[ 'heatmap-chart__legend-scale--bar' ] : undefined }
|
|
50
53
|
>
|
|
54
|
+
{ showsEmptyCell && (
|
|
55
|
+
<span
|
|
56
|
+
data-testid="heatmap-legend-empty-swatch"
|
|
57
|
+
className={ clsx(
|
|
58
|
+
styles[ 'heatmap-chart__legend-swatch' ],
|
|
59
|
+
styles[ 'heatmap-chart__legend-swatch--empty' ]
|
|
60
|
+
) }
|
|
61
|
+
/>
|
|
62
|
+
) }
|
|
51
63
|
{ Array.from( { length: steps }, ( _, index ) => {
|
|
52
64
|
const intensity = steps <= 1 ? 1 : index / ( steps - 1 );
|
|
53
65
|
return (
|
|
@@ -4,5 +4,7 @@ export { HeatmapLegend } from './heatmap-legend';
|
|
|
4
4
|
export { HeatmapContext } from './heatmap-context';
|
|
5
5
|
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
6
6
|
export type { HeatmapContextValue } from './heatmap-context';
|
|
7
|
-
export { resolveColumnGroups } from './column-groups';
|
|
8
|
-
export type { ColumnGroupLayout } from './column-groups';
|
|
7
|
+
export { resolveColumnGroups, wrapColumnGroups } from './column-groups';
|
|
8
|
+
export type { ColumnGroupLayout, WrappedColumnGroupLayout } from './column-groups';
|
|
9
|
+
export { fitCompactCells } from './fit-compact-cells';
|
|
10
|
+
export type { CompactCellFit } from './fit-compact-cells';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveColumnGroups } from '../private/column-groups';
|
|
1
|
+
import { resolveColumnGroups, wrapColumnGroups } from '../private/column-groups';
|
|
2
2
|
|
|
3
3
|
const lines = ( columns: { line: number }[] ) => columns.map( column => column.line );
|
|
4
4
|
const gaps = ( columns: { gapBefore: boolean }[] ) => columns.map( column => column.gapBefore );
|
|
@@ -16,6 +16,7 @@ describe( 'resolveColumnGroups', () => {
|
|
|
16
16
|
expect( resolveColumnGroups( [ { label: 'A', span: 3 } ], 0, 2 ) ).toEqual( {
|
|
17
17
|
columns: [],
|
|
18
18
|
groups: [],
|
|
19
|
+
bands: 1,
|
|
19
20
|
} );
|
|
20
21
|
expect( console ).not.toHaveWarned();
|
|
21
22
|
} );
|
|
@@ -30,9 +31,9 @@ describe( 'resolveColumnGroups', () => {
|
|
|
30
31
|
expect( lines( layout.columns ) ).toEqual( [ 2, 3, 5, 7, 8 ] );
|
|
31
32
|
expect( gaps( layout.columns ) ).toEqual( [ false, false, true, true, false ] );
|
|
32
33
|
expect( layout.groups ).toEqual( [
|
|
33
|
-
{ label: 'A', span: 2, line: 2 },
|
|
34
|
-
{ label: 'B', span: 1, line: 5 },
|
|
35
|
-
{ label: 'C', span: 2, line: 7 },
|
|
34
|
+
{ label: 'A', span: 2, line: 2, band: 0 },
|
|
35
|
+
{ label: 'B', span: 1, line: 5, band: 0 },
|
|
36
|
+
{ label: 'C', span: 2, line: 7, band: 0 },
|
|
36
37
|
] );
|
|
37
38
|
expect( layout.columns.map( column => column.group ) ).toEqual( [ 0, 0, 1, 2, 2 ] );
|
|
38
39
|
} );
|
|
@@ -81,3 +82,39 @@ describe( 'resolveColumnGroups', () => {
|
|
|
81
82
|
expect( console ).toHaveWarned();
|
|
82
83
|
} );
|
|
83
84
|
} );
|
|
85
|
+
|
|
86
|
+
describe( 'wrapColumnGroups', () => {
|
|
87
|
+
const groups = [
|
|
88
|
+
{ label: 'A', span: 2 },
|
|
89
|
+
{ label: 'B', span: 1 },
|
|
90
|
+
{ label: 'C', span: 2 },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
test( 'restarts each band at the first line, aligning the groups in slots as wide as their widest', () => {
|
|
94
|
+
const wrapped = wrapColumnGroups( resolveColumnGroups( groups, 5, 2 ), 2 );
|
|
95
|
+
expect( wrapped?.slotSpans ).toEqual( [ 2, 1 ] );
|
|
96
|
+
expect(
|
|
97
|
+
wrapped?.columns.map( ( { line, band, gapBefore } ) => [ line, band, gapBefore ] )
|
|
98
|
+
).toEqual( [
|
|
99
|
+
[ 2, 0, false ],
|
|
100
|
+
[ 3, 0, false ],
|
|
101
|
+
[ 5, 0, true ],
|
|
102
|
+
[ 2, 1, false ],
|
|
103
|
+
[ 3, 1, false ],
|
|
104
|
+
] );
|
|
105
|
+
expect( wrapped?.groups.map( ( { label, line, band } ) => [ label, line, band ] ) ).toEqual( [
|
|
106
|
+
[ 'A', 2, 0 ],
|
|
107
|
+
[ 'B', 5, 0 ],
|
|
108
|
+
[ 'C', 2, 1 ],
|
|
109
|
+
] );
|
|
110
|
+
} );
|
|
111
|
+
|
|
112
|
+
test.each( [
|
|
113
|
+
[ 'one band', groups, 5, 1 ],
|
|
114
|
+
[ 'an ungrouped tail', groups, 6, 2 ],
|
|
115
|
+
] )( 'leaves the layout unwrapped for %s', ( _, columnGroups, columnCount, bands ) => {
|
|
116
|
+
expect(
|
|
117
|
+
wrapColumnGroups( resolveColumnGroups( columnGroups, columnCount, 2 ), bands )
|
|
118
|
+
).toBeNull();
|
|
119
|
+
} );
|
|
120
|
+
} );
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { resolveColumnGroups } from '../private/column-groups';
|
|
2
|
+
import { fitCompactCells } from '../private/fit-compact-cells';
|
|
3
|
+
|
|
4
|
+
const monthGroups = Array.from( { length: 12 }, ( _, index ) => ( {
|
|
5
|
+
label: `M${ index + 1 }`,
|
|
6
|
+
span: 7,
|
|
7
|
+
} ) );
|
|
8
|
+
|
|
9
|
+
// Twelve month calendars, weekdays across and six weeks down, labelled beneath.
|
|
10
|
+
const months = {
|
|
11
|
+
layout: resolveColumnGroups( monthGroups, 84, 2 ),
|
|
12
|
+
rows: 6,
|
|
13
|
+
cellGap: 2,
|
|
14
|
+
groupGap: 16,
|
|
15
|
+
rowLabelWidth: 0,
|
|
16
|
+
columnLabelHeight: null,
|
|
17
|
+
groupLabelHeight: 22,
|
|
18
|
+
minCellSize: 11,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe( 'fitCompactCells', () => {
|
|
22
|
+
test.each( [
|
|
23
|
+
[ 'a one-row tile keeps one band at the floor', 1189, 110, { bands: 1, cellSize: 11 } ],
|
|
24
|
+
[ 'a wide one-row tile grows its single band', 1900, 130, { bands: 1, cellSize: 16 } ],
|
|
25
|
+
[ 'a two-row tile wraps into two bands of six', 1189, 300, { bands: 2, cellSize: 17 } ],
|
|
26
|
+
[ 'a tall narrow tile wraps into four bands of three', 372, 641, { bands: 4, cellSize: 14 } ],
|
|
27
|
+
[
|
|
28
|
+
'a narrow tile wraps rather than overflow at the floor',
|
|
29
|
+
320,
|
|
30
|
+
641,
|
|
31
|
+
{ bands: 4, cellSize: 11 },
|
|
32
|
+
],
|
|
33
|
+
] )( '%s', ( _, width, height, expected ) => {
|
|
34
|
+
expect( fitCompactCells( { ...months, width, height } ) ).toEqual( expected );
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
test( 'sizes a grid with an ungrouped tail for the one band it can draw', () => {
|
|
38
|
+
const layout = resolveColumnGroups( monthGroups.slice( 0, 3 ), 22, 2 );
|
|
39
|
+
expect( fitCompactCells( { ...months, layout, width: 400, height: 400 } ) ).toEqual( {
|
|
40
|
+
bands: 1,
|
|
41
|
+
cellSize: 14,
|
|
42
|
+
} );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
test( 'leaves room for the row-label track and the column-label row', () => {
|
|
46
|
+
expect(
|
|
47
|
+
fitCompactCells( {
|
|
48
|
+
...months,
|
|
49
|
+
layout: resolveColumnGroups( undefined, 10, 2 ),
|
|
50
|
+
rows: 2,
|
|
51
|
+
groupLabelHeight: null,
|
|
52
|
+
rowLabelWidth: 40,
|
|
53
|
+
columnLabelHeight: 20,
|
|
54
|
+
width: 300,
|
|
55
|
+
height: 100,
|
|
56
|
+
} )
|
|
57
|
+
).toEqual( { bands: 1, cellSize: 24 } );
|
|
58
|
+
} );
|
|
59
|
+
} );
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, within } from '@testing-library/react';
|
|
1
|
+
import { act, render, screen, within } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import { buildMonthCalendarHeatmapData } from '../build-month-calendar-data';
|
|
@@ -172,6 +172,29 @@ describe( 'HeatmapChart', () => {
|
|
|
172
172
|
).toHaveLength( 4 );
|
|
173
173
|
} );
|
|
174
174
|
|
|
175
|
+
test.each( [
|
|
176
|
+
[ 'with zeros painted empty', data, 1 ],
|
|
177
|
+
[ 'with negative values', [ { label: 'W1', data: [ { value: -2 }, { value: 0 } ] } ], 0 ],
|
|
178
|
+
] )( 'starts the legend scale with an empty-cell swatch %s', ( _case, rows, count ) => {
|
|
179
|
+
render(
|
|
180
|
+
<GlobalChartsProvider>
|
|
181
|
+
<HeatmapChart width={ 500 } height={ 300 } data={ rows }>
|
|
182
|
+
<HeatmapChart.Legend variant="bar" />
|
|
183
|
+
</HeatmapChart>
|
|
184
|
+
</GlobalChartsProvider>
|
|
185
|
+
);
|
|
186
|
+
const empty = screen.queryAllByTestId( 'heatmap-legend-empty-swatch' );
|
|
187
|
+
const scale = screen.getByTestId( 'heatmap-legend-scale' );
|
|
188
|
+
|
|
189
|
+
expect( empty ).toHaveLength( count );
|
|
190
|
+
expect( within( scale ).getAllByTestId( 'heatmap-legend-swatch' ) ).toHaveLength( 5 );
|
|
191
|
+
expect(
|
|
192
|
+
within( scale )
|
|
193
|
+
.getAllByTestId( /^heatmap-legend-(empty-)?swatch$/ )
|
|
194
|
+
.slice( 0, count )
|
|
195
|
+
).toEqual( empty );
|
|
196
|
+
} );
|
|
197
|
+
|
|
175
198
|
test( 'paints the legend on the same scale as the cells', () => {
|
|
176
199
|
render(
|
|
177
200
|
<GlobalChartsProvider>
|
|
@@ -943,6 +966,114 @@ describe( 'HeatmapChart column groups', () => {
|
|
|
943
966
|
} );
|
|
944
967
|
} );
|
|
945
968
|
|
|
969
|
+
describe( 'HeatmapChart fitCells', () => {
|
|
970
|
+
const threeMonths = Array.from( { length: 6 }, ( _, index ) => ( {
|
|
971
|
+
data: [ { value: index }, { value: index + 1 } ],
|
|
972
|
+
} ) );
|
|
973
|
+
const columnGroups = [
|
|
974
|
+
{ label: 'Jan', span: 2 },
|
|
975
|
+
{ label: 'Feb', span: 2 },
|
|
976
|
+
{ label: 'Mar', span: 2 },
|
|
977
|
+
];
|
|
978
|
+
|
|
979
|
+
let widthSpy: jest.SpyInstance;
|
|
980
|
+
let heightSpy: jest.SpyInstance;
|
|
981
|
+
|
|
982
|
+
beforeEach( () => {
|
|
983
|
+
widthSpy = jest.spyOn( Element.prototype, 'clientWidth', 'get' ).mockReturnValue( 400 );
|
|
984
|
+
heightSpy = jest.spyOn( Element.prototype, 'clientHeight', 'get' ).mockReturnValue( 400 );
|
|
985
|
+
} );
|
|
986
|
+
|
|
987
|
+
afterEach( () => {
|
|
988
|
+
widthSpy.mockRestore();
|
|
989
|
+
heightSpy.mockRestore();
|
|
990
|
+
} );
|
|
991
|
+
|
|
992
|
+
test( 'wraps the groups that fit better on a second band below the first, cells grown to match', () => {
|
|
993
|
+
renderChart( { data: threeMonths, columnGroups, compact: true, fitCells: true } );
|
|
994
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
995
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
996
|
+
'91px'
|
|
997
|
+
);
|
|
998
|
+
const [ , feb, mar ] = screen.getAllByTestId( 'heatmap-group-label' );
|
|
999
|
+
expect( feb ).toHaveStyle( { gridColumn: '5 / span 2', gridRow: '3' } );
|
|
1000
|
+
// Band one takes rows 1-2, its labels 3 and the gap 4, so band two's labels sit on 7.
|
|
1001
|
+
expect( mar ).toHaveStyle( { gridColumn: '2 / span 2', gridRow: '7' } );
|
|
1002
|
+
const marchCell = screen
|
|
1003
|
+
.getAllByTestId( 'heatmap-cell' )
|
|
1004
|
+
.find( element => element.dataset.column === '4' && element.dataset.row === '0' );
|
|
1005
|
+
expect( marchCell ).toHaveStyle( { gridColumn: '2', gridRow: '5' } );
|
|
1006
|
+
} );
|
|
1007
|
+
|
|
1008
|
+
test( 'keeps the theme size, with a warning, when a column is a summary', () => {
|
|
1009
|
+
renderChart( {
|
|
1010
|
+
data: [
|
|
1011
|
+
...threeMonths,
|
|
1012
|
+
{ label: 'Total', summary: true, data: [ { value: 1 }, { value: 2 } ] },
|
|
1013
|
+
],
|
|
1014
|
+
columnGroups,
|
|
1015
|
+
compact: true,
|
|
1016
|
+
fitCells: true,
|
|
1017
|
+
} );
|
|
1018
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1019
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
1020
|
+
'11px'
|
|
1021
|
+
);
|
|
1022
|
+
expect( console ).toHaveWarned();
|
|
1023
|
+
} );
|
|
1024
|
+
|
|
1025
|
+
test( 'leaves the grid the box less the content beside it', () => {
|
|
1026
|
+
const offsetHeight = jest
|
|
1027
|
+
.spyOn( HTMLElement.prototype, 'offsetHeight', 'get' )
|
|
1028
|
+
.mockImplementation( function ( this: HTMLElement ) {
|
|
1029
|
+
return this.dataset.testid === 'beside-grid' ? 100 : 0;
|
|
1030
|
+
} );
|
|
1031
|
+
try {
|
|
1032
|
+
renderChart( {
|
|
1033
|
+
data: threeMonths,
|
|
1034
|
+
columnGroups,
|
|
1035
|
+
compact: true,
|
|
1036
|
+
fitCells: true,
|
|
1037
|
+
children: <div data-testid="beside-grid" />,
|
|
1038
|
+
} );
|
|
1039
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1040
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' ) ).toBe(
|
|
1041
|
+
'66px'
|
|
1042
|
+
);
|
|
1043
|
+
} finally {
|
|
1044
|
+
offsetHeight.mockRestore();
|
|
1045
|
+
}
|
|
1046
|
+
} );
|
|
1047
|
+
|
|
1048
|
+
test( 'refits within the resize delivery, before later observers read the grid', () => {
|
|
1049
|
+
const observers: ResizeObserverCallback[] = [];
|
|
1050
|
+
const original = window.ResizeObserver;
|
|
1051
|
+
window.ResizeObserver = class {
|
|
1052
|
+
constructor( private callback: ResizeObserverCallback ) {}
|
|
1053
|
+
observe() {
|
|
1054
|
+
observers.push( this.callback );
|
|
1055
|
+
}
|
|
1056
|
+
unobserve() {}
|
|
1057
|
+
disconnect() {}
|
|
1058
|
+
} as unknown as typeof ResizeObserver;
|
|
1059
|
+
try {
|
|
1060
|
+
renderChart( { data: threeMonths, columnGroups, compact: true, fitCells: true } );
|
|
1061
|
+
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
1062
|
+
heightSpy.mockReturnValue( 100 );
|
|
1063
|
+
let cellSize = '';
|
|
1064
|
+
act( () => {
|
|
1065
|
+
observers.forEach( callback => callback( [], {} as ResizeObserver ) );
|
|
1066
|
+
cellSize = grid.style.getPropertyValue( '--a8c-charts-dimension-heatmap-cell-size' );
|
|
1067
|
+
} );
|
|
1068
|
+
expect( cellSize ).toBe( '48px' );
|
|
1069
|
+
const [ , , mar ] = screen.getAllByTestId( 'heatmap-group-label' );
|
|
1070
|
+
expect( mar ).toHaveStyle( { gridRow: '3' } );
|
|
1071
|
+
} finally {
|
|
1072
|
+
window.ResizeObserver = original;
|
|
1073
|
+
}
|
|
1074
|
+
} );
|
|
1075
|
+
} );
|
|
1076
|
+
|
|
946
1077
|
describe( 'HeatmapChart calendar navigation', () => {
|
|
947
1078
|
const calendar = ( range: { start: string; end: string }, days: string[] ) =>
|
|
948
1079
|
buildMonthCalendarHeatmapData(
|
|
@@ -1091,12 +1222,6 @@ describe( 'HeatmapChart calendar navigation', () => {
|
|
|
1091
1222
|
} );
|
|
1092
1223
|
|
|
1093
1224
|
describe( 'HeatmapChart naming and focus', () => {
|
|
1094
|
-
test( 'names the grid from ariaLabel when given', () => {
|
|
1095
|
-
renderChart( { ariaLabel: 'Monthly posting activity' } );
|
|
1096
|
-
expect( screen.getByRole( 'grid', { name: 'Monthly posting activity' } ) ).toBeInTheDocument();
|
|
1097
|
-
expect( screen.queryByRole( 'grid', { name: /heatmap chart/i } ) ).not.toBeInTheDocument();
|
|
1098
|
-
} );
|
|
1099
|
-
|
|
1100
1225
|
test( 'scrolls the keyboard-selected cell into view', async () => {
|
|
1101
1226
|
// jsdom has no scrollIntoView; the assertion is that the chart asks for it.
|
|
1102
1227
|
const scrollIntoView = jest.fn();
|
|
@@ -74,10 +74,10 @@ export interface HeatmapChartProps extends Omit<
|
|
|
74
74
|
* group past the edge, Page Up/Down by one group. Default `grid`.
|
|
75
75
|
*/
|
|
76
76
|
keyboardNavigation?: 'grid' | 'calendar';
|
|
77
|
-
/** Accessible name of the grid. Defaults to a localized "Heatmap chart". */
|
|
78
|
-
ariaLabel?: string;
|
|
79
77
|
/** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
|
|
80
78
|
compact?: boolean;
|
|
79
|
+
/** Compact mode only: grow the cells to fill the chart's box, wrapping the column groups onto more rows when that makes them larger. Default false. */
|
|
80
|
+
fitCells?: boolean;
|
|
81
81
|
/** Render the numeric value inside each cell. Default `! compact`. */
|
|
82
82
|
showValues?: boolean;
|
|
83
83
|
/**
|
|
@@ -16,8 +16,8 @@ export const SUBPIXEL_TOLERANCE = 0.5;
|
|
|
16
16
|
* depends on `--a8c-charts-dimension-leaderboard-row-gap`, label wrapping, and
|
|
17
17
|
* whatever a caller renders as a label, so any restated number would drift.
|
|
18
18
|
*
|
|
19
|
-
* Every row is a direct grid child marked with `data-row-index`;
|
|
20
|
-
*
|
|
19
|
+
* Every row is a `listitem` div, a direct grid child marked with `data-row-index`;
|
|
20
|
+
* interactive rows hold a button inside it.
|
|
21
21
|
*
|
|
22
22
|
* @param enabled - Whether to measure at all. When false every row fits.
|
|
23
23
|
* @param rowCount - Total number of rows rendered.
|
|
@@ -147,6 +147,12 @@
|
|
|
147
147
|
// needs no runtime cascade and never lands on the DOM as an unprefixed var.
|
|
148
148
|
$focus-ring-width: var(--wpds-border-width-focus);
|
|
149
149
|
|
|
150
|
+
.rowItem {
|
|
151
|
+
display: grid;
|
|
152
|
+
grid-column: 1 / -1;
|
|
153
|
+
grid-template-columns: subgrid;
|
|
154
|
+
}
|
|
155
|
+
|
|
150
156
|
.row {
|
|
151
157
|
display: grid;
|
|
152
158
|
grid-column: 1 / -1;
|
|
@@ -193,8 +199,10 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
193
199
|
var(--wpds-motion-easing-subtle);
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
|
|
197
|
-
&:
|
|
202
|
+
// A layered row's own link or button does not advertise the drill-down.
|
|
203
|
+
&:hover:not(:has(:is(a, button):not(.rowButton):hover)),
|
|
204
|
+
&:focus-visible,
|
|
205
|
+
&:has(> .rowButton:focus-visible) {
|
|
198
206
|
|
|
199
207
|
// Fixed reserve for the chevron — the value slides by it in full, and the
|
|
200
208
|
// bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
|
|
@@ -214,8 +222,9 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
// In RTL the value slides toward the inline start the other way (rightward).
|
|
217
|
-
[dir="rtl"] &:hover .valueContainer,
|
|
218
|
-
[dir="rtl"] &:focus-visible .valueContainer
|
|
225
|
+
[dir="rtl"] &:hover:not(:has(:is(a, button):not(.rowButton):hover)) .valueContainer,
|
|
226
|
+
[dir="rtl"] &:focus-visible .valueContainer,
|
|
227
|
+
[dir="rtl"] &:has(> .rowButton:focus-visible) .valueContainer {
|
|
219
228
|
transform: translateX(var(--a8c-charts-dimension-leaderboard-bar-hover-inset));
|
|
220
229
|
}
|
|
221
230
|
|
|
@@ -231,6 +240,45 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
231
240
|
}
|
|
232
241
|
}
|
|
233
242
|
|
|
243
|
+
// A row whose label holds a link: the button follows the cells for tab order
|
|
244
|
+
// but is layered beneath them, and the cells pass clicks through except on the
|
|
245
|
+
// label's own links and buttons. The label's isolate traps their z-index, so
|
|
246
|
+
// layering the cells is what keeps them above the button.
|
|
247
|
+
.layeredRow {
|
|
248
|
+
isolation: isolate;
|
|
249
|
+
|
|
250
|
+
> :not(.rowButton, .chevron) {
|
|
251
|
+
position: relative;
|
|
252
|
+
z-index: 1;
|
|
253
|
+
pointer-events: none;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
:is(a, button):not(.rowButton) {
|
|
257
|
+
position: relative;
|
|
258
|
+
z-index: 1;
|
|
259
|
+
pointer-events: auto;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.rowButton {
|
|
264
|
+
position: absolute;
|
|
265
|
+
inset: 0;
|
|
266
|
+
appearance: none;
|
|
267
|
+
padding: 0;
|
|
268
|
+
border: 0;
|
|
269
|
+
background: none;
|
|
270
|
+
cursor: pointer;
|
|
271
|
+
z-index: 0;
|
|
272
|
+
|
|
273
|
+
&:focus-visible {
|
|
274
|
+
border-radius: var(--wpds-border-radius-sm);
|
|
275
|
+
outline:
|
|
276
|
+
$focus-ring-width solid
|
|
277
|
+
var(--wpds-color-stroke-focus);
|
|
278
|
+
outline-offset: calc(-1 * #{$focus-ring-width});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
234
282
|
.chevron {
|
|
235
283
|
position: absolute;
|
|
236
284
|
inset-inline-end: var(--wpds-dimension-padding-xs);
|
|
@@ -4,7 +4,7 @@ import { __ } from '@wordpress/i18n';
|
|
|
4
4
|
import { Icon, chevronRight } from '@wordpress/icons';
|
|
5
5
|
import { Stack, Text } from '@wordpress/ui';
|
|
6
6
|
import clsx from 'clsx';
|
|
7
|
-
import { useContext, useMemo, type FC } from 'react';
|
|
7
|
+
import { useContext, useMemo, type FC, type MouseEvent } from 'react';
|
|
8
8
|
import { Legend } from '../../components/legend';
|
|
9
9
|
import { usePrefersReducedMotion } from '../../hooks';
|
|
10
10
|
import {
|
|
@@ -89,6 +89,20 @@ const hasDeltaValue = (
|
|
|
89
89
|
delta: number;
|
|
90
90
|
} => hasPreviousValue( entry ) && entry.delta != null;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A layered row's cell that takes pointer events of its own (a value tooltip)
|
|
94
|
+
* sits above the row button; pass its clicks on so they still drill down.
|
|
95
|
+
*
|
|
96
|
+
* @param event - The click on the row.
|
|
97
|
+
*/
|
|
98
|
+
function forwardCellClickToRowButton( event: MouseEvent< HTMLDivElement > ) {
|
|
99
|
+
if ( ( event.target as Element ).closest( 'a, button' ) ) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
event.currentTarget.querySelector< HTMLButtonElement >( ':scope > button' )?.click();
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
const BarLabel = ( { label }: { label: LeaderboardEntry[ 'label' ] } ) => (
|
|
93
107
|
<>{ typeof label === 'string' ? <Text className={ styles.label }>{ label }</Text> : label }</>
|
|
94
108
|
);
|
|
@@ -174,6 +188,7 @@ const BarWithLabel = ( {
|
|
|
174
188
|
* @param props.children - Child components for composition API
|
|
175
189
|
* @param props.className - Additional CSS class name
|
|
176
190
|
* @param props.style - Custom styling for the chart container
|
|
191
|
+
* @param props.ariaLabel - Accessible name for the list, defaulting to "Leaderboard chart"
|
|
177
192
|
* @return JSX element representing the leaderboard chart
|
|
178
193
|
*/
|
|
179
194
|
const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
@@ -194,6 +209,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
194
209
|
legend = {},
|
|
195
210
|
legendLabels,
|
|
196
211
|
gap = 'md',
|
|
212
|
+
ariaLabel,
|
|
197
213
|
className,
|
|
198
214
|
style,
|
|
199
215
|
children,
|
|
@@ -375,6 +391,8 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
375
391
|
</div>
|
|
376
392
|
) : (
|
|
377
393
|
<Grid
|
|
394
|
+
role="list"
|
|
395
|
+
aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
|
|
378
396
|
templateColumns="minmax(0, 1fr) auto"
|
|
379
397
|
rowGap={ ROW_GAP }
|
|
380
398
|
columnGap={ COLUMN_GAP }
|
|
@@ -449,26 +467,59 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
449
467
|
</>
|
|
450
468
|
);
|
|
451
469
|
|
|
470
|
+
if ( entry.onClick && entry.hasInteractiveLabel ) {
|
|
471
|
+
return (
|
|
472
|
+
<div
|
|
473
|
+
key={ entry.id }
|
|
474
|
+
role="listitem"
|
|
475
|
+
data-row-index={ rowIndex }
|
|
476
|
+
style={ rowStyle }
|
|
477
|
+
className={ styles.rowItem }
|
|
478
|
+
>
|
|
479
|
+
{ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Mouse-only fallback; the row button carries keyboard access. */ }
|
|
480
|
+
<div
|
|
481
|
+
className={ clsx( styles.row, styles.interactiveRow, styles.layeredRow ) }
|
|
482
|
+
onClick={ forwardCellClickToRowButton }
|
|
483
|
+
>
|
|
484
|
+
{ rowCells }
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
className={ styles.rowButton }
|
|
488
|
+
onClick={ entry.onClick }
|
|
489
|
+
aria-label={ entry.ariaLabel }
|
|
490
|
+
/>
|
|
491
|
+
<Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
452
497
|
if ( entry.onClick ) {
|
|
453
498
|
return (
|
|
454
|
-
<
|
|
499
|
+
<div
|
|
455
500
|
key={ entry.id }
|
|
456
|
-
|
|
501
|
+
role="listitem"
|
|
457
502
|
data-row-index={ rowIndex }
|
|
458
503
|
style={ rowStyle }
|
|
459
|
-
className={
|
|
460
|
-
onClick={ entry.onClick }
|
|
461
|
-
aria-label={ entry.ariaLabel }
|
|
504
|
+
className={ styles.rowItem }
|
|
462
505
|
>
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
className={ clsx( styles.row, styles.interactiveRow ) }
|
|
509
|
+
onClick={ entry.onClick }
|
|
510
|
+
aria-label={ entry.ariaLabel }
|
|
511
|
+
>
|
|
512
|
+
{ rowCells }
|
|
513
|
+
<Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
466
516
|
);
|
|
467
517
|
}
|
|
468
518
|
|
|
469
519
|
return (
|
|
470
520
|
<div
|
|
471
521
|
key={ entry.id }
|
|
522
|
+
role="listitem"
|
|
472
523
|
data-row-index={ rowIndex }
|
|
473
524
|
style={ rowStyle }
|
|
474
525
|
className={ styles.row }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, act } from '@testing-library/react';
|
|
1
|
+
import { render, screen, act, within } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
@@ -547,6 +547,59 @@ describe( 'LeaderboardChart', () => {
|
|
|
547
547
|
expect( screen.getByRole( 'button' ).tagName ).toBe( 'BUTTON' );
|
|
548
548
|
} );
|
|
549
549
|
|
|
550
|
+
it( 'keeps a label link beside the row button, not inside it, when the label is interactive', async () => {
|
|
551
|
+
const user = userEvent.setup();
|
|
552
|
+
const onClick = jest.fn();
|
|
553
|
+
render(
|
|
554
|
+
<LeaderboardChart
|
|
555
|
+
data={ [
|
|
556
|
+
{
|
|
557
|
+
...mockData[ 0 ],
|
|
558
|
+
label: <a href="/author/1">Author</a>,
|
|
559
|
+
onClick,
|
|
560
|
+
ariaLabel: 'View posts by Author',
|
|
561
|
+
hasInteractiveLabel: true,
|
|
562
|
+
},
|
|
563
|
+
] }
|
|
564
|
+
/>
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const button = screen.getByRole( 'button', { name: 'View posts by Author' } );
|
|
568
|
+
const link = screen.getByRole( 'link', { name: 'Author' } );
|
|
569
|
+
link.addEventListener( 'click', event => event.preventDefault() );
|
|
570
|
+
expect( button ).not.toContainElement( link );
|
|
571
|
+
|
|
572
|
+
await user.click( button );
|
|
573
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
574
|
+
await user.click( link );
|
|
575
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
576
|
+
// A value tooltip takes pointer events above the button; its click still drills down.
|
|
577
|
+
await user.click( screen.getByText( '12.5K' ) );
|
|
578
|
+
expect( onClick ).toHaveBeenCalledTimes( 2 );
|
|
579
|
+
} );
|
|
580
|
+
|
|
581
|
+
it( 'tabs to the label link before the row button when the label is interactive', async () => {
|
|
582
|
+
const user = userEvent.setup();
|
|
583
|
+
render(
|
|
584
|
+
<LeaderboardChart
|
|
585
|
+
data={ [
|
|
586
|
+
{
|
|
587
|
+
...mockData[ 0 ],
|
|
588
|
+
label: <a href="/author/1">Author</a>,
|
|
589
|
+
onClick: jest.fn(),
|
|
590
|
+
ariaLabel: 'View posts by Author',
|
|
591
|
+
hasInteractiveLabel: true,
|
|
592
|
+
},
|
|
593
|
+
] }
|
|
594
|
+
/>
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
await user.tab();
|
|
598
|
+
expect( screen.getByRole( 'link', { name: 'Author' } ) ).toHaveFocus();
|
|
599
|
+
await user.tab();
|
|
600
|
+
expect( screen.getByRole( 'button', { name: 'View posts by Author' } ) ).toHaveFocus();
|
|
601
|
+
} );
|
|
602
|
+
|
|
550
603
|
it( 'calls onClick when the row is clicked', async () => {
|
|
551
604
|
const user = userEvent.setup();
|
|
552
605
|
const onClick = jest.fn();
|
|
@@ -653,6 +706,32 @@ describe( 'LeaderboardChart', () => {
|
|
|
653
706
|
);
|
|
654
707
|
} );
|
|
655
708
|
|
|
709
|
+
it( 'renders each entry as a list item, keeping clickable rows as named buttons', () => {
|
|
710
|
+
render(
|
|
711
|
+
<LeaderboardChart
|
|
712
|
+
data={ [
|
|
713
|
+
mockData[ 0 ],
|
|
714
|
+
{ ...mockData[ 1 ], ariaLabel: 'Open Home', onClick: jest.fn() },
|
|
715
|
+
{
|
|
716
|
+
...mockData[ 1 ],
|
|
717
|
+
id: 'about',
|
|
718
|
+
ariaLabel: 'Open About',
|
|
719
|
+
onClick: jest.fn(),
|
|
720
|
+
hasInteractiveLabel: true,
|
|
721
|
+
},
|
|
722
|
+
] }
|
|
723
|
+
/>
|
|
724
|
+
);
|
|
725
|
+
const items = screen.getAllByRole( 'listitem' );
|
|
726
|
+
expect( items ).toHaveLength( 3 );
|
|
727
|
+
expect(
|
|
728
|
+
within( items[ 1 ] ).getByRole( 'button', { name: 'Open Home' } )
|
|
729
|
+
).toBeInTheDocument();
|
|
730
|
+
expect(
|
|
731
|
+
within( items[ 2 ] ).getByRole( 'button', { name: 'Open About' } )
|
|
732
|
+
).toBeInTheDocument();
|
|
733
|
+
} );
|
|
734
|
+
|
|
656
735
|
it( 'does not render a button for entries without onClick', () => {
|
|
657
736
|
render( <LeaderboardChart data={ [ mockData[ 0 ] ] } /> );
|
|
658
737
|
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
|