@automattic/charts 4.9.0 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/TOKENS.md +7 -3
- package/dist/index.cjs +496 -774
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +66 -3
- package/dist/index.d.cts +65 -60
- package/dist/index.d.ts +65 -60
- package/dist/index.js +467 -726
- package/dist/index.js.map +1 -1
- package/dist/visx/group/index.js +1 -2
- package/dist/visx/legend/index.js +1 -2
- package/dist/visx/text/index.js +1 -2
- package/package.json +13 -12
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
- package/src/charts/area-chart/types.ts +0 -2
- package/src/charts/bar-chart/bar-chart.tsx +7 -2
- package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
- package/src/charts/bar-chart/types.ts +0 -2
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
- 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.module.scss +27 -0
- package/src/charts/geo-chart/geo-chart.tsx +85 -45
- package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
- package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
- package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
- package/src/charts/heatmap-chart/types.ts +1 -3
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
- package/src/charts/leaderboard-chart/types.ts +1 -0
- package/src/charts/line-chart/line-chart.tsx +3 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
- package/src/charts/line-chart/types.ts +0 -2
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +0 -5
- package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
- 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 +0 -5
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
- 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.module.scss +5 -0
- package/src/components/legend/private/base-legend.tsx +35 -17
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/_surface.scss +19 -0
- package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
- package/src/components/tooltip/tooltip-box.module.scss +3 -17
- 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/styles/chart-scope.scss +6 -4
- package/src/styles/test/chart-scope.test.ts +6 -2
- package/src/types.ts +19 -1
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_OFFSET,
|
|
4
|
+
findClippingAncestor,
|
|
5
|
+
getBoundedPosition,
|
|
6
|
+
} from '../../../components/tooltip/private/bounded-tooltip';
|
|
7
|
+
|
|
8
|
+
const TOOLTIP_CLASS = 'google-visualization-tooltip';
|
|
9
|
+
|
|
10
|
+
const isTooltip = ( node: Node ): node is HTMLElement =>
|
|
11
|
+
node instanceof HTMLElement && node.classList.contains( TOOLTIP_CLASS );
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Moves Google's HTML tooltip to where the other chart tooltips sit, just off the cursor. Google places it about 30px away and has no option for it.
|
|
15
|
+
*
|
|
16
|
+
* @param containerRef - The element Google draws the chart into.
|
|
17
|
+
*/
|
|
18
|
+
export function useTooltipPosition( containerRef: RefObject< HTMLElement > ) {
|
|
19
|
+
useEffect( () => {
|
|
20
|
+
const container = containerRef.current;
|
|
21
|
+
|
|
22
|
+
if ( ! container || typeof MutationObserver === 'undefined' ) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let pointer: { x: number; y: number } | null = null;
|
|
27
|
+
let clip: { parent: Element; element: Element | null } | null = null;
|
|
28
|
+
const ownStyles = new WeakMap< HTMLElement, string >();
|
|
29
|
+
|
|
30
|
+
const place = ( tooltip: HTMLElement ) => {
|
|
31
|
+
const parent = tooltip.offsetParent;
|
|
32
|
+
|
|
33
|
+
if ( ! pointer || ! parent ) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ( clip?.parent !== parent ) {
|
|
38
|
+
clip = { parent, element: findClippingAncestor( parent ) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parentRect = parent.getBoundingClientRect();
|
|
42
|
+
const clipRect = clip.element?.getBoundingClientRect() ?? {
|
|
43
|
+
left: 0,
|
|
44
|
+
top: 0,
|
|
45
|
+
right: window.innerWidth,
|
|
46
|
+
bottom: window.innerHeight,
|
|
47
|
+
};
|
|
48
|
+
const { x, y } = getBoundedPosition( {
|
|
49
|
+
left: pointer.x - parentRect.left,
|
|
50
|
+
top: pointer.y - parentRect.top,
|
|
51
|
+
offsetLeft: DEFAULT_OFFSET,
|
|
52
|
+
offsetTop: DEFAULT_OFFSET,
|
|
53
|
+
box: { width: tooltip.offsetWidth, height: tooltip.offsetHeight },
|
|
54
|
+
wrapper: { left: 0, top: 0, right: parentRect.width, bottom: parentRect.height },
|
|
55
|
+
bounds: {
|
|
56
|
+
left: clipRect.left - parentRect.left,
|
|
57
|
+
top: clipRect.top - parentRect.top,
|
|
58
|
+
right: clipRect.right - parentRect.left,
|
|
59
|
+
bottom: clipRect.bottom - parentRect.top,
|
|
60
|
+
},
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
tooltip.style.left = `${ x }px`;
|
|
64
|
+
tooltip.style.top = `${ y }px`;
|
|
65
|
+
ownStyles.set( tooltip, tooltip.style.cssText );
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const trackPointer = ( event: MouseEvent ) => {
|
|
69
|
+
pointer = { x: event.clientX, y: event.clientY };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const observer = new MutationObserver( records => {
|
|
73
|
+
const tooltips = new Set< HTMLElement >();
|
|
74
|
+
|
|
75
|
+
for ( const record of records ) {
|
|
76
|
+
if ( isTooltip( record.target ) ) {
|
|
77
|
+
tooltips.add( record.target );
|
|
78
|
+
}
|
|
79
|
+
record.addedNodes.forEach( node => isTooltip( node ) && tooltips.add( node ) );
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for ( const tooltip of tooltips ) {
|
|
83
|
+
// Our own write is observed too; placing again would loop.
|
|
84
|
+
if ( ownStyles.get( tooltip ) !== tooltip.style.cssText ) {
|
|
85
|
+
place( tooltip );
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
// Google's own mouse events, captured so the pointer is current before Google writes the tooltip. Chrome sends mouse events, but no pointer events, when the map moves under a resting cursor.
|
|
91
|
+
container.addEventListener( 'mouseover', trackPointer, true );
|
|
92
|
+
container.addEventListener( 'mousemove', trackPointer, true );
|
|
93
|
+
observer.observe( container, {
|
|
94
|
+
childList: true,
|
|
95
|
+
subtree: true,
|
|
96
|
+
attributes: true,
|
|
97
|
+
attributeFilter: [ 'style' ],
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
container.removeEventListener( 'mouseover', trackPointer, true );
|
|
102
|
+
container.removeEventListener( 'mousemove', trackPointer, true );
|
|
103
|
+
observer.disconnect();
|
|
104
|
+
};
|
|
105
|
+
}, [ containerRef ] );
|
|
106
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { Chart } from 'react-google-charts';
|
|
1
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { Chart, type GoogleDataTableColumnRoleType } from 'react-google-charts';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import GeoChart, { GeoChartUnresponsive } from '../geo-chart';
|
|
5
|
+
import type { GeoChartProps } from '../types';
|
|
5
6
|
|
|
6
7
|
// Mock react-google-charts
|
|
7
8
|
jest.mock( 'react-google-charts', () => ( {
|
|
@@ -97,20 +98,54 @@ describe( 'GeoChart', () => {
|
|
|
97
98
|
expect( data ).toEqual( testData );
|
|
98
99
|
} );
|
|
99
100
|
|
|
100
|
-
test(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
[
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
test.each( [ true, 'true', 1, 'false' ] )(
|
|
102
|
+
'sanitizes HTML tooltip content when p.html is %p',
|
|
103
|
+
html => {
|
|
104
|
+
const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
|
|
105
|
+
[ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html } } ],
|
|
106
|
+
[ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
|
|
107
|
+
[ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
|
|
108
|
+
];
|
|
109
|
+
renderWithTheme( { data: testData } );
|
|
110
|
+
|
|
111
|
+
const chartData = screen.getByTestId( 'chart-data' );
|
|
112
|
+
const data = JSON.parse( chartData.textContent || '[]' );
|
|
113
|
+
|
|
114
|
+
// Script tags and img (not in allowlist) should be stripped
|
|
115
|
+
expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
|
|
116
|
+
expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
|
|
117
|
+
}
|
|
118
|
+
);
|
|
107
119
|
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
const unsafe = '<b>US</b><img src=x onerror="alert(1)">';
|
|
121
|
+
const htmlColumn = { type: 'string', role: 'tooltip', p: { html: true } };
|
|
122
|
+
const textColumn = { type: 'string', role: 'tooltip' };
|
|
123
|
+
|
|
124
|
+
test.each( [
|
|
125
|
+
[
|
|
126
|
+
'a cell flagged HTML in a text column',
|
|
127
|
+
textColumn,
|
|
128
|
+
{ v: unsafe, p: { html: true } },
|
|
129
|
+
{ v: '<b>US</b>', p: { html: true } },
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
'an object cell in an HTML column',
|
|
133
|
+
htmlColumn,
|
|
134
|
+
{ v: unsafe, f: unsafe },
|
|
135
|
+
{ v: '<b>US</b>', f: '<b>US</b>' },
|
|
136
|
+
],
|
|
137
|
+
[ 'a plain cell in a text column, which Google escapes', textColumn, unsafe, unsafe ],
|
|
138
|
+
] )( 'sanitizes tooltip markup in %s', ( _, column, cell, expected ) => {
|
|
139
|
+
renderWithTheme( {
|
|
140
|
+
data: [
|
|
141
|
+
[ 'Country', 'Value', column ],
|
|
142
|
+
[ 'US', 100, cell ],
|
|
143
|
+
],
|
|
144
|
+
} );
|
|
145
|
+
|
|
146
|
+
const data = JSON.parse( screen.getByTestId( 'chart-data' ).textContent || '[]' );
|
|
110
147
|
|
|
111
|
-
|
|
112
|
-
expect( data[ 1 ][ 2 ] ).toBe( '<b>United States</b>' );
|
|
113
|
-
expect( data[ 2 ][ 2 ] ).toBe( '<b>Canada</b>' );
|
|
148
|
+
expect( data[ 1 ][ 2 ] ).toEqual( expected );
|
|
114
149
|
} );
|
|
115
150
|
|
|
116
151
|
test( 'handles header-only data with HTML tooltip column and no data rows', () => {
|
|
@@ -170,41 +205,22 @@ describe( 'GeoChart', () => {
|
|
|
170
205
|
expect( options.legend ).toBe( 'none' );
|
|
171
206
|
} );
|
|
172
207
|
|
|
173
|
-
test( 'sets tooltip trigger to focus with isHtml
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
} );
|
|
181
|
-
|
|
182
|
-
test( 'enables HTML tooltips when data has HTML tooltip column', () => {
|
|
183
|
-
const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
|
|
184
|
-
[ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
|
|
185
|
-
[ 'US', 100, '<b>United States</b><br/>100 orders' ],
|
|
186
|
-
[ 'CA', 50, '<b>Canada</b><br/>50 orders' ],
|
|
187
|
-
];
|
|
188
|
-
renderWithTheme( { data: testData } );
|
|
189
|
-
|
|
190
|
-
const chartOptions = screen.getByTestId( 'chart-options' );
|
|
191
|
-
const options = JSON.parse( chartOptions.textContent || '{}' );
|
|
192
|
-
|
|
193
|
-
expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: true } );
|
|
194
|
-
} );
|
|
195
|
-
|
|
196
|
-
test( 'keeps isHtml false for text-only tooltips', () => {
|
|
197
|
-
const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
|
|
198
|
-
[ 'Country', 'Value', { type: 'string', role: 'tooltip' } ],
|
|
208
|
+
test( 'sets tooltip trigger to focus with isHtml true', () => {
|
|
209
|
+
const data: GeoChartProps[ 'data' ] = [
|
|
210
|
+
[
|
|
211
|
+
'Country',
|
|
212
|
+
'Value',
|
|
213
|
+
{ type: 'string', role: 'tooltip' as GoogleDataTableColumnRoleType },
|
|
214
|
+
],
|
|
199
215
|
[ 'US', 100, 'United States: 100 orders' ],
|
|
200
216
|
[ 'CA', 50, 'Canada: 50 orders' ],
|
|
201
217
|
];
|
|
202
|
-
renderWithTheme( { data
|
|
218
|
+
renderWithTheme( { data } );
|
|
203
219
|
|
|
204
220
|
const chartOptions = screen.getByTestId( 'chart-options' );
|
|
205
221
|
const options = JSON.parse( chartOptions.textContent || '{}' );
|
|
206
222
|
|
|
207
|
-
expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml:
|
|
223
|
+
expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: true } );
|
|
208
224
|
} );
|
|
209
225
|
|
|
210
226
|
test( 'does not include region in options when set to world (default)', () => {
|
|
@@ -281,13 +297,18 @@ describe( 'GeoChart', () => {
|
|
|
281
297
|
} );
|
|
282
298
|
|
|
283
299
|
describe( 'Chart Events', () => {
|
|
284
|
-
test( '
|
|
300
|
+
test( "hides Google's own SVG label from screen readers once the map is ready", () => {
|
|
285
301
|
renderWithTheme();
|
|
286
302
|
|
|
287
|
-
const
|
|
288
|
-
|
|
303
|
+
const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
|
|
304
|
+
screen.getByTestId( 'google-chart-mock' ).appendChild( svg );
|
|
289
305
|
|
|
290
|
-
|
|
306
|
+
const chartProps = ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ]?.[ 0 ];
|
|
307
|
+
chartProps.chartEvents
|
|
308
|
+
.find( ( event: { eventName: string } ) => event.eventName === 'ready' )
|
|
309
|
+
.callback( {} );
|
|
310
|
+
|
|
311
|
+
expect( svg ).toHaveAttribute( 'aria-hidden', 'true' );
|
|
291
312
|
} );
|
|
292
313
|
|
|
293
314
|
test( 'calls onError when Google Charts emits an error', () => {
|
|
@@ -413,6 +434,16 @@ describe( 'GeoChart', () => {
|
|
|
413
434
|
// The component should call the custom placeholder function
|
|
414
435
|
expect( customPlaceholder ).toHaveBeenCalled();
|
|
415
436
|
} );
|
|
437
|
+
|
|
438
|
+
test( 'keeps the placeholder outside the chart until Google Charts loads', () => {
|
|
439
|
+
renderWithTheme();
|
|
440
|
+
|
|
441
|
+
expect( screen.getByTestId( 'geo-chart-loading' ) ).toBeInTheDocument();
|
|
442
|
+
|
|
443
|
+
act( () => ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ][ 0 ].onLoad() );
|
|
444
|
+
|
|
445
|
+
expect( screen.queryByTestId( 'geo-chart-loading' ) ).not.toBeInTheDocument();
|
|
446
|
+
} );
|
|
416
447
|
} );
|
|
417
448
|
|
|
418
449
|
describe( 'Responsive Wrapper', () => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { useTooltipPosition } from '../private/use-tooltip-position';
|
|
3
|
+
|
|
4
|
+
const renderChart = () => {
|
|
5
|
+
const container = document.createElement( 'div' );
|
|
6
|
+
const chart = document.createElement( 'div' );
|
|
7
|
+
const tooltip = document.createElement( 'div' );
|
|
8
|
+
tooltip.className = 'google-visualization-tooltip';
|
|
9
|
+
chart.append( tooltip );
|
|
10
|
+
container.append( chart );
|
|
11
|
+
document.body.append( container );
|
|
12
|
+
|
|
13
|
+
chart.getBoundingClientRect = () => new DOMRect( 100, 50, 400, 300 );
|
|
14
|
+
Object.defineProperties( tooltip, {
|
|
15
|
+
offsetParent: { value: chart },
|
|
16
|
+
offsetWidth: { value: 80 },
|
|
17
|
+
offsetHeight: { value: 40 },
|
|
18
|
+
} );
|
|
19
|
+
|
|
20
|
+
renderHook( () => useTooltipPosition( { current: container } ) );
|
|
21
|
+
|
|
22
|
+
return { chart, tooltip };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe( 'useTooltipPosition', () => {
|
|
26
|
+
afterEach( () => document.body.replaceChildren() );
|
|
27
|
+
|
|
28
|
+
it.each( [
|
|
29
|
+
[ 'beside the cursor', 200, 100, '110px', '60px' ],
|
|
30
|
+
[ 'flipped left near the right edge', 480, 100, '290px', '60px' ],
|
|
31
|
+
] )( 'moves the tooltip Google places %s', async ( _, clientX, clientY, left, top ) => {
|
|
32
|
+
const { chart, tooltip } = renderChart();
|
|
33
|
+
chart.addEventListener( 'mousemove', () => {
|
|
34
|
+
tooltip.style.left = '0px';
|
|
35
|
+
tooltip.style.top = '0px';
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
// A bare mouse event, as Chrome sends when the map moves under a resting cursor.
|
|
39
|
+
chart.dispatchEvent( new MouseEvent( 'mousemove', { bubbles: true, clientX, clientY } ) );
|
|
40
|
+
|
|
41
|
+
await waitFor( () => expect( tooltip ).toHaveStyle( { left, top } ) );
|
|
42
|
+
} );
|
|
43
|
+
} );
|
|
@@ -31,7 +31,7 @@ export interface GeoChartError {
|
|
|
31
31
|
|
|
32
32
|
export interface GeoChartProps extends Pick<
|
|
33
33
|
BaseChartProps,
|
|
34
|
-
'className' | 'chartId' | 'width' | 'height'
|
|
34
|
+
'className' | 'chartId' | 'ariaLabel' | 'width' | 'height'
|
|
35
35
|
> {
|
|
36
36
|
/**
|
|
37
37
|
* Data in Google Charts native format for maximum flexibility.
|
|
@@ -5,8 +5,8 @@ $focus-ring-width: var(--wpds-border-width-focus);
|
|
|
5
5
|
|
|
6
6
|
$heatmap-empty-fill: var(--a8c-charts-color-track);
|
|
7
7
|
|
|
8
|
-
// JS sets the ends (getHeatmapScale)
|
|
9
|
-
//
|
|
8
|
+
// JS sets the ends (getHeatmapScale). Unresolved, the floor is 15% of the
|
|
9
|
+
// primary.
|
|
10
10
|
$heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c-charts-color-heatmap-primary)) calc(var(--a8c-charts-heatmap-cell-intensity) * 100%), var(--a8c-charts-color-heatmap-low, color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) 15%, var(--a8c-charts-color-background, #fff))));
|
|
11
11
|
|
|
12
12
|
.heatmap-chart {
|
|
@@ -49,7 +49,7 @@ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c
|
|
|
49
49
|
outline:
|
|
50
50
|
$focus-ring-width solid
|
|
51
51
|
var(--wpds-color-stroke-focus);
|
|
52
|
-
outline-offset:
|
|
52
|
+
outline-offset: $focus-ring-width;
|
|
53
53
|
border-radius: var(--wpds-border-radius-sm);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
|
|
2
1
|
import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
|
|
3
2
|
import { mixHexColors, relativeLuminance } from '../../../utils/color-utils';
|
|
4
3
|
|
|
4
|
+
/** Contrast the lowest step aims for against the background and the empty cell. */
|
|
5
|
+
export const HEATMAP_LOW_CONTRAST = 2.5;
|
|
6
|
+
|
|
7
|
+
/** Contrast every step keeps against the background, and the lowest against the highest. */
|
|
8
|
+
export const HEATMAP_STEP_CONTRAST = 2;
|
|
9
|
+
|
|
5
10
|
/** Contrast the highest step aims for against the background, so the steps stay apart. */
|
|
6
|
-
export const HEATMAP_HIGH_CONTRAST =
|
|
11
|
+
export const HEATMAP_HIGH_CONTRAST = 6;
|
|
7
12
|
|
|
8
13
|
const STEPS = 200;
|
|
9
14
|
|
|
@@ -12,20 +17,13 @@ export type HeatmapScale = {
|
|
|
12
17
|
high: string;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const color = mixHexColors( from, to, step / STEPS );
|
|
19
|
-
if ( meets( color ) ) {
|
|
20
|
-
return color;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
};
|
|
20
|
+
// Mixed to the rounded hex the browser paints, so an endpoint never lands under its threshold.
|
|
21
|
+
const ramp = ( from: string, to: string ) =>
|
|
22
|
+
Array.from( { length: STEPS + 1 }, ( _, step ) => mixHexColors( from, to, step / STEPS ) );
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
|
-
* The two ends of the heatmap fill scale:
|
|
28
|
-
* low end,
|
|
25
|
+
* The two ends of the heatmap fill scale: toward 2.5:1 against the background and the empty cell at
|
|
26
|
+
* the low end, never under 2:1 against the background or the high end, and toward 6:1 at the high end.
|
|
29
27
|
*
|
|
30
28
|
* @param primary - The resolved primary color, as six-digit hex.
|
|
31
29
|
* @param background - The resolved chart background, as six-digit hex.
|
|
@@ -37,38 +35,45 @@ export const getHeatmapScale = (
|
|
|
37
35
|
background: string,
|
|
38
36
|
emptyCell?: string | null
|
|
39
37
|
): HeatmapScale => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
const backgroundLuminance = relativeLuminance( background );
|
|
39
|
+
const ownSide = relativeLuminance( primary ) > backgroundLuminance ? '#ffffff' : '#000000';
|
|
40
|
+
const otherSide = ownSide === '#ffffff' ? '#000000' : '#ffffff';
|
|
41
|
+
// The scale deepens on the primary's own side of the background, unless only the other side
|
|
42
|
+
// reaches the high end or contrasts more; it never crosses the background either way.
|
|
43
|
+
const extreme =
|
|
44
|
+
contrastRatio( ownSide, background ) >= HEATMAP_HIGH_CONTRAST ||
|
|
45
|
+
contrastRatio( ownSide, background ) >= contrastRatio( otherSide, background )
|
|
46
|
+
? ownSide
|
|
47
|
+
: otherSide;
|
|
48
|
+
const onExtremeSide = ( color: string ) =>
|
|
49
|
+
extreme === '#ffffff'
|
|
50
|
+
? relativeLuminance( color ) > backgroundLuminance
|
|
51
|
+
: relativeLuminance( color ) < backgroundLuminance;
|
|
47
52
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
references.every( reference => contrastRatio( color, reference ) >= MIN_BACKGROUND_CONTRAST );
|
|
51
|
-
return (
|
|
52
|
-
( primaryStandsOut ? firstMeeting( background, primary, meets ) : null ) ??
|
|
53
|
-
firstMeeting( primary, extreme, meets )
|
|
54
|
-
);
|
|
55
|
-
};
|
|
56
|
-
// Only `high` can miss: black or white tops out near 4.58:1 on a mid-tone background.
|
|
53
|
+
const deepening = ramp( primary, extreme );
|
|
54
|
+
// Only `high` can miss its target: black or white tops out near 4.58:1 on a mid-tone background.
|
|
57
55
|
const high =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
extreme,
|
|
61
|
-
color => contrastRatio( color, background ) >= HEATMAP_HIGH_CONTRAST
|
|
62
|
-
) ?? extreme;
|
|
56
|
+
deepening.find( color => contrastRatio( color, background ) >= HEATMAP_HIGH_CONTRAST ) ??
|
|
57
|
+
extreme;
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
// empty
|
|
66
|
-
const
|
|
59
|
+
const path = [ ...ramp( background, primary ), ...deepening ];
|
|
60
|
+
// Never empty: `high` sits at least 4.58:1 out.
|
|
61
|
+
const spread = path
|
|
62
|
+
.slice( 0, path.indexOf( high ) + 1 )
|
|
63
|
+
.filter(
|
|
64
|
+
color =>
|
|
65
|
+
onExtremeSide( color ) &&
|
|
66
|
+
contrastRatio( color, background ) >= HEATMAP_STEP_CONTRAST &&
|
|
67
|
+
contrastRatio( color, high ) >= HEATMAP_STEP_CONTRAST
|
|
68
|
+
);
|
|
69
|
+
const surroundings = emptyCell ? [ background, emptyCell ] : [ background ];
|
|
70
|
+
const weakest = ( color: string ) =>
|
|
71
|
+
Math.min( ...surroundings.map( other => contrastRatio( color, other ) ) );
|
|
72
|
+
// Short of 2.5:1 the low end settles for 2:1, then for the step farthest from its nearest neighbor.
|
|
67
73
|
const low =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: lowAgainst( [ background ] );
|
|
74
|
+
spread.find( color => weakest( color ) >= HEATMAP_LOW_CONTRAST ) ??
|
|
75
|
+
spread.find( color => weakest( color ) >= HEATMAP_STEP_CONTRAST ) ??
|
|
76
|
+
spread.reduce( ( best, color ) => ( weakest( color ) > weakest( best ) ? color : best ) );
|
|
72
77
|
|
|
73
|
-
return { low
|
|
78
|
+
return { low, high };
|
|
74
79
|
};
|
|
@@ -486,8 +486,8 @@ describe( 'HeatmapChart', () => {
|
|
|
486
486
|
);
|
|
487
487
|
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
|
|
488
488
|
|
|
489
|
-
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '#
|
|
490
|
-
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#
|
|
489
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '#7f93f1' );
|
|
490
|
+
expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#3554df' );
|
|
491
491
|
|
|
492
492
|
document.body.removeChild( scope );
|
|
493
493
|
} );
|
|
@@ -586,7 +586,7 @@ const mockRects = () =>
|
|
|
586
586
|
describe( 'HeatmapChart value text contrast', () => {
|
|
587
587
|
// The low end, a mid-tone, and the high end of the scale.
|
|
588
588
|
const scale: HeatmapColumn[] = [
|
|
589
|
-
{ label: 'W1', data: [ { value: 1 }, { value:
|
|
589
|
+
{ label: 'W1', data: [ { value: 1 }, { value: 60 }, { value: 100 } ] },
|
|
590
590
|
];
|
|
591
591
|
|
|
592
592
|
let injectedStyle: HTMLStyleElement | null = null;
|
|
@@ -619,7 +619,7 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
619
619
|
test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
|
|
620
620
|
renderScale();
|
|
621
621
|
|
|
622
|
-
expect( screen.getByText( '
|
|
622
|
+
expect( screen.getByText( '60' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
|
|
623
623
|
} );
|
|
624
624
|
|
|
625
625
|
test( 'reads the label roles once data arrives after an empty first render', () => {
|
|
@@ -691,7 +691,7 @@ describe( 'HeatmapChart value text contrast', () => {
|
|
|
691
691
|
|
|
692
692
|
renderScale( 'pinned-heatmap' );
|
|
693
693
|
|
|
694
|
-
[ '1', '
|
|
694
|
+
[ '1', '60', '100' ].forEach( value =>
|
|
695
695
|
expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
|
|
696
696
|
exact: true,
|
|
697
697
|
} )
|
|
@@ -1222,12 +1222,6 @@ describe( 'HeatmapChart calendar navigation', () => {
|
|
|
1222
1222
|
} );
|
|
1223
1223
|
|
|
1224
1224
|
describe( 'HeatmapChart naming and focus', () => {
|
|
1225
|
-
test( 'names the grid from ariaLabel when given', () => {
|
|
1226
|
-
renderChart( { ariaLabel: 'Monthly posting activity' } );
|
|
1227
|
-
expect( screen.getByRole( 'grid', { name: 'Monthly posting activity' } ) ).toBeInTheDocument();
|
|
1228
|
-
expect( screen.queryByRole( 'grid', { name: /heatmap chart/i } ) ).not.toBeInTheDocument();
|
|
1229
|
-
} );
|
|
1230
|
-
|
|
1231
1225
|
test( 'scrolls the keyboard-selected cell into view', async () => {
|
|
1232
1226
|
// jsdom has no scrollIntoView; the assertion is that the chart asks for it.
|
|
1233
1227
|
const scrollIntoView = jest.fn();
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
|
|
2
1
|
import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
|
|
3
2
|
import { mixHexColors } from '../../../utils/color-utils';
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getHeatmapScale,
|
|
5
|
+
HEATMAP_HIGH_CONTRAST,
|
|
6
|
+
HEATMAP_LOW_CONTRAST,
|
|
7
|
+
HEATMAP_STEP_CONTRAST,
|
|
8
|
+
} from '../private/heatmap-scale';
|
|
5
9
|
|
|
6
|
-
// The wp-admin scheme colors, then the pre-7.0 ones
|
|
10
|
+
// The wp-admin scheme colors, then the pre-7.0 ones.
|
|
7
11
|
const PRIMARIES = [
|
|
8
12
|
'#007cba',
|
|
9
13
|
'#3858e9',
|
|
@@ -22,17 +26,17 @@ describe.each( [
|
|
|
22
26
|
[ 'dark', '#242424', '#272727' ],
|
|
23
27
|
] )( 'getHeatmapScale on a %s background', ( _mode, background, emptyCell ) => {
|
|
24
28
|
it.each( PRIMARIES )(
|
|
25
|
-
'keeps the lowest step of %s at
|
|
29
|
+
'keeps the lowest step of %s at 2.5:1 against the background and empty cell, no further',
|
|
26
30
|
primary => {
|
|
27
31
|
const { low } = getHeatmapScale( primary, background, emptyCell );
|
|
28
32
|
const contrasts = [ contrastRatio( low, background ), contrastRatio( low, emptyCell ) ];
|
|
29
33
|
|
|
30
|
-
expect( Math.min( ...contrasts ) ).toBeGreaterThanOrEqual(
|
|
31
|
-
expect( Math.min( ...contrasts ) ).toBeLessThan(
|
|
34
|
+
expect( Math.min( ...contrasts ) ).toBeGreaterThanOrEqual( HEATMAP_LOW_CONTRAST );
|
|
35
|
+
expect( Math.min( ...contrasts ) ).toBeLessThan( HEATMAP_LOW_CONTRAST + 0.1 );
|
|
32
36
|
}
|
|
33
37
|
);
|
|
34
38
|
|
|
35
|
-
it.each( PRIMARIES )( 'runs the scale of %s out to
|
|
39
|
+
it.each( PRIMARIES )( 'runs the scale of %s out to 6:1', primary => {
|
|
36
40
|
const { high } = getHeatmapScale( primary, background );
|
|
37
41
|
|
|
38
42
|
expect( contrastRatio( high, background ) ).toBeGreaterThanOrEqual( HEATMAP_HIGH_CONTRAST );
|
|
@@ -40,39 +44,63 @@ describe.each( [
|
|
|
40
44
|
} );
|
|
41
45
|
|
|
42
46
|
describe( 'getHeatmapScale', () => {
|
|
43
|
-
it( 'keeps a primary already past
|
|
47
|
+
it( 'keeps a primary already past 6:1 as the high end', () => {
|
|
44
48
|
expect( getHeatmapScale( '#1d2327', '#ffffff' ).high ).toBe( '#1d2327' );
|
|
45
49
|
} );
|
|
46
50
|
|
|
47
51
|
it.each( [
|
|
48
|
-
[ '#ffffff', '#808080' ],
|
|
49
|
-
[ '#ffd000', '#767676' ],
|
|
50
|
-
[ '#3858e9', '#808080' ],
|
|
51
|
-
[ '#1e1e1e', '#999999' ],
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
[ '#ffffff', '#808080', null ],
|
|
53
|
+
[ '#ffd000', '#767676', null ],
|
|
54
|
+
[ '#3858e9', '#808080', null ],
|
|
55
|
+
[ '#1e1e1e', '#999999', null ],
|
|
56
|
+
[ '#ffd000', '#8a8a8a', null ],
|
|
57
|
+
[ '#ffd000', '#8a8a8a', '#cccccc' ],
|
|
58
|
+
[ '#1d2327', '#555555', '#272727' ],
|
|
59
|
+
] )(
|
|
60
|
+
'keeps every step of %s at 2:1 on a mid-gray %s with a %s empty cell, ends apart',
|
|
61
|
+
( primary, background, emptyCell ) => {
|
|
62
|
+
const { low, high } = getHeatmapScale( primary, background, emptyCell );
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
expect( contrastRatio( low, high ) ).toBeGreaterThanOrEqual( HEATMAP_STEP_CONTRAST );
|
|
65
|
+
for ( let step = 0; step <= 20; step++ ) {
|
|
66
|
+
expect(
|
|
67
|
+
contrastRatio( mixHexColors( low, high, step / 20 ), background )
|
|
68
|
+
).toBeGreaterThanOrEqual( HEATMAP_STEP_CONTRAST );
|
|
69
|
+
}
|
|
59
70
|
}
|
|
60
|
-
|
|
71
|
+
);
|
|
61
72
|
|
|
62
73
|
it.each( [
|
|
63
|
-
[ '
|
|
64
|
-
[ '
|
|
65
|
-
[ '
|
|
74
|
+
[ '#ffffff', '#cccccc' ],
|
|
75
|
+
[ '#ffffff', '#959595' ],
|
|
76
|
+
[ '#1e1e1e', '#6b6b6b' ],
|
|
66
77
|
] )(
|
|
67
|
-
'
|
|
68
|
-
(
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
'moves the low end on %s farther from a %s empty cell than the background alone would',
|
|
79
|
+
( background, emptyCell ) => {
|
|
80
|
+
const { low } = getHeatmapScale( '#3858e9', background, emptyCell );
|
|
81
|
+
const backgroundOnly = getHeatmapScale( '#3858e9', background ).low;
|
|
82
|
+
|
|
83
|
+
expect( contrastRatio( low, emptyCell ) ).toBeGreaterThan(
|
|
84
|
+
contrastRatio( backgroundOnly, emptyCell )
|
|
71
85
|
);
|
|
72
86
|
}
|
|
73
87
|
);
|
|
74
88
|
|
|
75
|
-
it(
|
|
89
|
+
it.each( [
|
|
90
|
+
[ '#ffffff', '#959595' ],
|
|
91
|
+
[ '#ffffff', '#cccccc' ],
|
|
92
|
+
[ '#1e1e1e', '#555555' ],
|
|
93
|
+
] )(
|
|
94
|
+
'keeps the ends apart and in order on %s with a %s empty cell',
|
|
95
|
+
( background, emptyCell ) => {
|
|
96
|
+
const { low, high } = getHeatmapScale( '#3858e9', background, emptyCell );
|
|
97
|
+
|
|
98
|
+
expect( contrastRatio( low, high ) ).toBeGreaterThanOrEqual( HEATMAP_STEP_CONTRAST );
|
|
99
|
+
expect( contrastRatio( low, background ) ).toBeLessThan( contrastRatio( high, background ) );
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
it( 'ends on black or white when the background leaves no room for 6:1', () => {
|
|
76
104
|
expect( getHeatmapScale( '#3858e9', '#808080' ).high ).toBe( '#000000' );
|
|
77
105
|
} );
|
|
78
106
|
} );
|