@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +7 -3
  3. package/dist/index.cjs +496 -774
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +66 -3
  6. package/dist/index.d.cts +65 -60
  7. package/dist/index.d.ts +65 -60
  8. package/dist/index.js +467 -726
  9. package/dist/index.js.map +1 -1
  10. package/dist/visx/group/index.js +1 -2
  11. package/dist/visx/legend/index.js +1 -2
  12. package/dist/visx/text/index.js +1 -2
  13. package/package.json +13 -12
  14. package/src/charts/area-chart/area-chart.tsx +2 -1
  15. package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
  16. package/src/charts/area-chart/types.ts +0 -2
  17. package/src/charts/bar-chart/bar-chart.tsx +7 -2
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
  19. package/src/charts/bar-chart/types.ts +0 -2
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  24. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  25. package/src/charts/geo-chart/geo-chart.module.scss +27 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +85 -45
  27. package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
  28. package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
  29. package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
  30. package/src/charts/geo-chart/types.ts +1 -1
  31. package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
  32. package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
  33. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
  34. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
  35. package/src/charts/heatmap-chart/types.ts +1 -3
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
  38. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
  40. package/src/charts/leaderboard-chart/types.ts +1 -0
  41. package/src/charts/line-chart/line-chart.tsx +3 -0
  42. package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
  43. package/src/charts/line-chart/types.ts +0 -2
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +0 -5
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
  49. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
  50. package/src/charts/test/accessible-name.test.tsx +181 -0
  51. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  52. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  53. package/src/components/legend/private/base-legend.module.scss +5 -0
  54. package/src/components/legend/private/base-legend.tsx +35 -17
  55. package/src/components/legend/test/legend.test.tsx +42 -0
  56. package/src/components/legend/types.ts +2 -0
  57. package/src/components/tooltip/_surface.scss +19 -0
  58. package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
  59. package/src/components/tooltip/tooltip-box.module.scss +3 -17
  60. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  61. package/src/hooks/use-xychart-theme.ts +33 -2
  62. package/src/index.ts +1 -0
  63. package/src/styles/chart-scope.scss +6 -4
  64. package/src/styles/test/chart-scope.test.ts +6 -2
  65. package/src/types.ts +19 -1
  66. package/src/utils/resolve-font-size.ts +9 -10
  67. 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( 'sanitizes HTML tooltip content to prevent XSS', () => {
101
- const testData: [ ( string | object )[], ...[ string, number, string ][] ] = [
102
- [ 'Country', 'Value', { type: 'string', role: 'tooltip', p: { html: true } } ],
103
- [ 'US', 100, '<b>United States</b><script>alert("xss")</script>' ],
104
- [ 'CA', 50, '<b>Canada</b><img src=x onerror="alert(1)">' ],
105
- ];
106
- renderWithTheme( { data: testData } );
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
- const chartData = screen.getByTestId( 'chart-data' );
109
- const data = JSON.parse( chartData.textContent || '[]' );
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
- // Script tags and img (not in allowlist) should be stripped
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 false by default', () => {
174
- renderWithTheme();
175
-
176
- const chartOptions = screen.getByTestId( 'chart-options' );
177
- const options = JSON.parse( chartOptions.textContent || '{}' );
178
-
179
- expect( options.tooltip ).toEqual( { trigger: 'focus', isHtml: false } );
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: testData } );
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: false } );
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( 'does not pass chart events when onError is omitted', () => {
300
+ test( "hides Google's own SVG label from screen readers once the map is ready", () => {
285
301
  renderWithTheme();
286
302
 
287
- const chartEvents = screen.getByTestId( 'chart-events' );
288
- const events = JSON.parse( chartEvents.textContent || '[]' );
303
+ const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
304
+ screen.getByTestId( 'google-chart-mock' ).appendChild( svg );
289
305
 
290
- expect( events ).toEqual( [] );
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): 3:1 against background and empty cell at
9
- // the low end. Unresolved, the floor is 15% of the primary.
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: 2px;
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 = 9;
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
- // Measured on the rounded hex the browser paints, so an endpoint never lands under its threshold.
16
- const firstMeeting = ( from: string, to: string, meets: ( color: string ) => boolean ) => {
17
- for ( let step = 0; step <= STEPS; step++ ) {
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: 3:1 against the background and the empty cell at the
28
- * low end, toward 9:1 against the background at the high 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 primaryStandsOut = contrastRatio( primary, background ) >= MIN_BACKGROUND_CONTRAST;
41
- // The scale deepens to black or white on the primary's own side of the background, so it never
42
- // crosses it. A primary too close to the background takes whichever contrasts more.
43
- const towardWhite = primaryStandsOut
44
- ? relativeLuminance( primary ) > relativeLuminance( background )
45
- : contrastRatio( '#ffffff', background ) > contrastRatio( '#000000', background );
46
- const extreme = towardWhite ? '#ffffff' : '#000000';
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 lowAgainst = ( references: string[] ) => {
49
- const meets = ( color: string ) =>
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
- firstMeeting(
59
- primary,
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
- // The lowest step sits beside empty cells, so it stands apart from them too, unless an
65
- // empty-cell color far from the background would push it past the high end.
66
- const againstEmptyCell = emptyCell ? lowAgainst( [ background, emptyCell ] ) : null;
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
- againstEmptyCell &&
69
- contrastRatio( againstEmptyCell, background ) <= contrastRatio( high, background )
70
- ? againstEmptyCell
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: low ?? extreme, high };
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( '#6c83ef' );
490
- expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#283ea5' );
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: 30 }, { value: 100 } ] },
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( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
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', '30', '100' ].forEach( value =>
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 { getHeatmapScale, HEATMAP_HIGH_CONTRAST } from '../private/heatmap-scale';
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, which include a sunrise below 3:1 on white.
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 3:1 against the background and empty cell, no further',
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( MIN_BACKGROUND_CONTRAST );
31
- expect( Math.min( ...contrasts ) ).toBeLessThan( MIN_BACKGROUND_CONTRAST + 0.1 );
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 9:1', primary => {
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 9:1 as the high end', () => {
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
- ] )( 'keeps every step of %s at 3:1 on a mid-gray %s', ( primary, background ) => {
53
- const { low, high } = getHeatmapScale( primary, background );
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
- for ( let step = 0; step <= 20; step++ ) {
56
- expect(
57
- contrastRatio( mixHexColors( low, high, step / 20 ), background )
58
- ).toBeGreaterThanOrEqual( MIN_BACKGROUND_CONTRAST );
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
- [ 'no step clears it', '#ffffff', '#555555' ],
64
- [ 'clearing it would pass the high end', '#ffffff', '#767676' ],
65
- [ 'clearing it would pass the high end', '#1e1e1e', '#6b6b6b' ],
74
+ [ '#ffffff', '#cccccc' ],
75
+ [ '#ffffff', '#959595' ],
76
+ [ '#1e1e1e', '#6b6b6b' ],
66
77
  ] )(
67
- 'measures against the background alone when %s (%s, %s)',
68
- ( _case, background, emptyCell ) => {
69
- expect( getHeatmapScale( '#3858e9', background, emptyCell ) ).toEqual(
70
- getHeatmapScale( '#3858e9', background )
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( 'ends on black or white when the background leaves no room for 9:1', () => {
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
  } );