@automattic/charts 4.7.0 → 4.9.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +834 -321
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +68 -17
  6. package/dist/index.d.cts +102 -28
  7. package/dist/index.d.ts +102 -28
  8. package/dist/index.js +838 -327
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +7 -4
  12. package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  15. package/src/charts/area-chart/types.ts +2 -0
  16. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  17. package/src/charts/bar-chart/bar-chart.tsx +25 -16
  18. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  19. package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
  20. package/src/charts/bar-chart/types.ts +2 -0
  21. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
  26. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  27. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  28. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  29. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
  30. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  31. package/src/charts/heatmap-chart/private/index.ts +6 -3
  32. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  33. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  34. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  35. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +231 -8
  36. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  37. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  38. package/src/charts/heatmap-chart/types.ts +6 -3
  39. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  40. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  41. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  42. package/src/charts/leaderboard-chart/types.ts +6 -1
  43. package/src/charts/line-chart/line-chart.tsx +7 -4
  44. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  46. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  47. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  48. package/src/charts/line-chart/types.ts +5 -3
  49. package/src/charts/pie-chart/pie-chart.tsx +153 -143
  50. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
  53. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  55. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  57. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  58. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  59. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  60. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  61. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  62. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  63. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  64. package/src/charts/private/test/label-text-color.test.ts +6 -8
  65. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  66. package/src/components/tooltip/base-tooltip.tsx +31 -32
  67. package/src/components/tooltip/index.ts +3 -1
  68. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  69. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  70. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  71. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  72. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  74. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
  75. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  76. package/src/components/tooltip/tooltip-box.tsx +43 -0
  77. package/src/components/tooltip/types.ts +7 -1
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  79. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  80. package/src/hooks/use-chart-margin.tsx +21 -18
  81. package/src/index.ts +8 -3
  82. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  83. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  84. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  85. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  86. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  87. package/src/styles/chart-scope.scss +1 -0
  88. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  89. package/src/types.ts +18 -2
  90. package/src/utils/test/date-formatting.test.ts +0 -10
  91. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  92. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -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 { primaryColorHex } = 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 (
@@ -57,7 +69,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
57
69
  className={ styles[ 'heatmap-chart__legend-swatch' ] }
58
70
  style={
59
71
  {
60
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
72
+ ...fillVars,
61
73
  '--a8c-charts-heatmap-cell-intensity': intensity,
62
74
  } as CSSProperties
63
75
  }
@@ -0,0 +1,74 @@
1
+ import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
3
+ import { mixHexColors, relativeLuminance } from '../../../utils/color-utils';
4
+
5
+ /** Contrast the highest step aims for against the background, so the steps stay apart. */
6
+ export const HEATMAP_HIGH_CONTRAST = 9;
7
+
8
+ const STEPS = 200;
9
+
10
+ export type HeatmapScale = {
11
+ low: string;
12
+ high: string;
13
+ };
14
+
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
+ };
25
+
26
+ /**
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.
29
+ *
30
+ * @param primary - The resolved primary color, as six-digit hex.
31
+ * @param background - The resolved chart background, as six-digit hex.
32
+ * @param emptyCell - The resolved empty-cell color, as six-digit hex, when it is opaque.
33
+ * @return The scale ends, as six-digit hex.
34
+ */
35
+ export const getHeatmapScale = (
36
+ primary: string,
37
+ background: string,
38
+ emptyCell?: string | null
39
+ ): 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';
47
+
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.
57
+ const high =
58
+ firstMeeting(
59
+ primary,
60
+ extreme,
61
+ color => contrastRatio( color, background ) >= HEATMAP_HIGH_CONTRAST
62
+ ) ?? extreme;
63
+
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;
67
+ const low =
68
+ againstEmptyCell &&
69
+ contrastRatio( againstEmptyCell, background ) <= contrastRatio( high, background )
70
+ ? againstEmptyCell
71
+ : lowAgainst( [ background ] );
72
+
73
+ return { low: low ?? extreme, high };
74
+ };
@@ -1,7 +1,10 @@
1
- export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
1
+ export { getValueExtent, getNormalizedValue, isEmptyValue, isPresent } from './use-heatmap-colors';
2
+ export { getHeatmapScale } from './heatmap-scale';
2
3
  export { HeatmapLegend } from './heatmap-legend';
3
4
  export { HeatmapContext } from './heatmap-context';
4
5
  export type { HeatmapLegendProps } from './heatmap-legend';
5
6
  export type { HeatmapContextValue } from './heatmap-context';
6
- export { resolveColumnGroups } from './column-groups';
7
- 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';
@@ -4,14 +4,16 @@ export const isPresent = ( value: number | null | undefined ): value is number =
4
4
  value !== null && value !== undefined && ! isNaN( value );
5
5
 
6
6
  /**
7
- * Get the min and max values from heatmap data, ignoring null/NaN. Summary
8
- * columns stay out: a roll-up on the scale would flatten every real cell.
7
+ * Get the min and max values from heatmap data, ignoring null/NaN and the zeros
8
+ * `isEmptyValue` paints as empty. Summary columns stay out: a roll-up on the
9
+ * scale would flatten every real cell.
9
10
  * @param data - The heatmap columns
10
11
  * @return Tuple of [min, max] values
11
12
  */
12
13
  export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
13
14
  let min = Infinity;
14
15
  let max = -Infinity;
16
+ let hasZero = false;
15
17
  for ( const column of data ) {
16
18
  if ( column.summary ) {
17
19
  continue;
@@ -20,6 +22,10 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
20
22
  if ( ! isPresent( cell.value ) ) {
21
23
  continue;
22
24
  }
25
+ if ( cell.value === 0 ) {
26
+ hasZero = true;
27
+ continue;
28
+ }
23
29
  if ( cell.value < min ) {
24
30
  min = cell.value;
25
31
  }
@@ -28,17 +34,29 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
28
34
  }
29
35
  }
30
36
  }
37
+ if ( hasZero && min < 0 ) {
38
+ max = Math.max( max, 0 );
39
+ }
31
40
  if ( min === Infinity ) {
32
41
  return [ 0, 0 ];
33
42
  }
34
43
  return [ min, max ];
35
44
  };
36
45
 
46
+ /**
47
+ * Whether a value paints as an empty cell: a zero in data with no negatives
48
+ * counts nothing, so it must not look like the lowest step of activity.
49
+ *
50
+ * @param value - The cell value
51
+ * @param extent - The extent from `getValueExtent`
52
+ * @return True when the cell takes the empty-cell color
53
+ */
54
+ export const isEmptyValue = ( value: number, extent: [ number, number ] ): boolean =>
55
+ value === 0 && extent[ 0 ] >= 0;
56
+
37
57
  /**
38
58
  * Normalize a value to 0–1 within the extent. A flat extent (min === max)
39
- * maps to 1 — every cell is equally the "highest" — except an all-zero
40
- * extent, which maps to 0 so a no-activity grid renders at the scale's
41
- * bottom instead of full intensity.
59
+ * maps to 1, since every cell is equally the "highest".
42
60
  *
43
61
  * @param value - The value to normalize
44
62
  * @param extent - Tuple of [min, max] values for the normalization range
@@ -47,7 +65,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
47
65
  export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
48
66
  const [ min, max ] = extent;
49
67
  if ( min === max ) {
50
- return max === 0 ? 0 : 1;
68
+ return 1;
51
69
  }
52
70
  return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
53
71
  };
@@ -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,49 @@ 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
+
198
+ test( 'paints the legend on the same scale as the cells', () => {
199
+ render(
200
+ <GlobalChartsProvider>
201
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9">
202
+ <HeatmapChart.Legend />
203
+ </HeatmapChart>
204
+ </GlobalChartsProvider>
205
+ );
206
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
207
+
208
+ for ( const swatch of screen.getAllByTestId( 'heatmap-legend-swatch' ) ) {
209
+ for ( const end of [ 'primary', 'low', 'high' ] ) {
210
+ const property = `--a8c-charts-color-heatmap-${ end }`;
211
+ expect( swatch.style.getPropertyValue( property ) ).toBe(
212
+ grid.style.getPropertyValue( property )
213
+ );
214
+ }
215
+ }
216
+ } );
217
+
175
218
  test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
176
219
  renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
177
220
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -409,6 +452,78 @@ describe( 'HeatmapChart', () => {
409
452
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
410
453
  } );
411
454
 
455
+ test( 'leaves the fill to the stylesheet when the primary color cannot resolve to hex', () => {
456
+ render(
457
+ <GlobalChartsProvider>
458
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="oklch(0.6 0.2 260)">
459
+ <HeatmapChart.Legend />
460
+ </HeatmapChart>
461
+ </GlobalChartsProvider>
462
+ );
463
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
464
+ const swatches = screen.getAllByTestId( 'heatmap-legend-swatch' );
465
+
466
+ for ( const element of [ grid, ...swatches ] ) {
467
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe(
468
+ 'oklch(0.6 0.2 260)'
469
+ );
470
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '' );
471
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '' );
472
+ }
473
+ expect( screen.getByText( '4' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
474
+ } );
475
+
476
+ test( 'builds the scale for white on a see-through background, as the palette does', () => {
477
+ const scope = document.createElement( 'div' );
478
+ scope.style.setProperty( '--a8c-charts-color-background', 'transparent' );
479
+ document.body.appendChild( scope );
480
+
481
+ render(
482
+ <GlobalChartsProvider>
483
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9" />
484
+ </GlobalChartsProvider>,
485
+ { container: scope }
486
+ );
487
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
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' );
491
+
492
+ document.body.removeChild( scope );
493
+ } );
494
+
495
+ test( 'reads the computed style once per re-render for the fill scale', () => {
496
+ const { rerender } = renderChart();
497
+ const spy = jest.spyOn( window, 'getComputedStyle' );
498
+
499
+ rerender(
500
+ <GlobalChartsProvider>
501
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } />
502
+ </GlobalChartsProvider>
503
+ );
504
+
505
+ expect( spy ).toHaveBeenCalledTimes( 1 );
506
+ spy.mockRestore();
507
+ } );
508
+
509
+ test( 'paints a zero as an empty cell, still announcing its value', () => {
510
+ renderChart();
511
+ const zero = screen.getByRole( 'gridcell', { name: 'W2: 0' } );
512
+
513
+ expect( zero ).not.toHaveClass( 'heatmap-chart__cell--filled' );
514
+ expect( zero.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '' );
515
+ expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).toHaveClass(
516
+ 'heatmap-chart__cell--filled'
517
+ );
518
+ } );
519
+
520
+ test( 'puts the lowest non-zero value on the lowest step', () => {
521
+ renderChart();
522
+ const lowest = screen.getByRole( 'gridcell', { name: 'W1: 1' } );
523
+
524
+ expect( lowest.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '0' );
525
+ } );
526
+
412
527
  test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
413
528
  const scope = document.createElement( 'div' );
414
529
  scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
@@ -469,9 +584,9 @@ const mockRects = () =>
469
584
  } );
470
585
 
471
586
  describe( 'HeatmapChart value text contrast', () => {
472
- // Extent 0..100, so each value is its own intensity in hundredths.
587
+ // The low end, a mid-tone, and the high end of the scale.
473
588
  const scale: HeatmapColumn[] = [
474
- { label: 'W1', data: [ { value: 0 }, { value: 85 }, { value: 100 } ] },
589
+ { label: 'W1', data: [ { value: 1 }, { value: 30 }, { value: 100 } ] },
475
590
  ];
476
591
 
477
592
  let injectedStyle: HTMLStyleElement | null = null;
@@ -497,14 +612,14 @@ describe( 'HeatmapChart value text contrast', () => {
497
612
  test( 'picks the label role that reaches AA on light and dark cells', () => {
498
613
  renderScale();
499
614
 
500
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
615
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
501
616
  expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
502
617
  } );
503
618
 
504
619
  test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
505
620
  renderScale();
506
621
 
507
- expect( screen.getByText( '85' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
622
+ expect( screen.getByText( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
508
623
  } );
509
624
 
510
625
  test( 'reads the label roles once data arrives after an empty first render', () => {
@@ -542,7 +657,7 @@ describe( 'HeatmapChart value text contrast', () => {
542
657
 
543
658
  renderScale( 'translucent-heatmap' );
544
659
 
545
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
660
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
546
661
  expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
547
662
  } );
548
663
 
@@ -565,7 +680,7 @@ describe( 'HeatmapChart value text contrast', () => {
565
680
  );
566
681
 
567
682
  expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
568
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
683
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
569
684
  } );
570
685
 
571
686
  test( "keeps one color when both label roles are set to it on the chart's own class", () => {
@@ -576,7 +691,7 @@ describe( 'HeatmapChart value text contrast', () => {
576
691
 
577
692
  renderScale( 'pinned-heatmap' );
578
693
 
579
- [ '0', '85', '100' ].forEach( value =>
694
+ [ '1', '30', '100' ].forEach( value =>
580
695
  expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
581
696
  exact: true,
582
697
  } )
@@ -851,6 +966,114 @@ describe( 'HeatmapChart column groups', () => {
851
966
  } );
852
967
  } );
853
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
+
854
1077
  describe( 'HeatmapChart calendar navigation', () => {
855
1078
  const calendar = ( range: { start: string; end: string }, days: string[] ) =>
856
1079
  buildMonthCalendarHeatmapData(