@automattic/charts 4.1.1 → 4.3.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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -1,9 +1,10 @@
1
1
  import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
2
- import { useTooltip } from '@visx/tooltip';
2
+ import { defaultStyles as visxTooltipStyles, useTooltip } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
- import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
6
+ import tooltipStyles from '../../components/tooltip/base-tooltip.module.scss';
7
+ import { BoundedTooltip, TOOLTIP_Z_INDEX } from '../../components/tooltip/private/bounded-tooltip';
7
8
  import {
8
9
  GlobalChartsProvider,
9
10
  useChartId,
@@ -33,8 +34,17 @@ import {
33
34
  HeatmapContext,
34
35
  HeatmapLegend,
35
36
  isPresent,
37
+ resolveColumnGroups,
36
38
  } from './private';
39
+ import {
40
+ firstCalendarCell,
41
+ firstGridCell,
42
+ isNavigationKey,
43
+ stepCalendarCell,
44
+ stepGridCell,
45
+ } from './private/keyboard-navigation';
37
46
  import type { HeatmapContextValue } from './private';
47
+ import type { CellBlock, CellPosition } from './private/keyboard-navigation';
38
48
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
39
49
  import type { ResponsiveConfig } from '../private/with-responsive';
40
50
  import type { CSSProperties, FC } from 'react';
@@ -47,6 +57,17 @@ const CELL_MIX_FLOOR = 0.15;
47
57
  // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
48
58
  const NO_ROW_LABELS: string[] = [];
49
59
 
60
+ // The dark variant is painted by the `.surface` class, so its style carries no box.
61
+ const TOOLTIP_BOX_STYLES: Record< 'light' | 'dark', CSSProperties > = {
62
+ light: { ...visxTooltipStyles, zIndex: TOOLTIP_Z_INDEX },
63
+ dark: { zIndex: TOOLTIP_Z_INDEX },
64
+ };
65
+
66
+ // The cell's own label wins; otherwise the group, column and row labels name it.
67
+ const cellName = ( info: HeatmapTooltipData ) =>
68
+ info.cellLabel ||
69
+ [ info.groupLabel, info.columnLabel, info.rowLabel ].filter( Boolean ).join( ' ' );
70
+
50
71
  const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
51
72
  data,
52
73
  chartId: providedChartId,
@@ -60,19 +81,27 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
60
81
  minCellWidth,
61
82
  minCellHeight,
62
83
  rowLabels = NO_ROW_LABELS,
84
+ columnGroups,
85
+ keyboardNavigation = 'grid',
86
+ ariaLabel,
63
87
  primaryColor,
64
88
  gap = 'md',
65
89
  withTooltips = false,
66
90
  renderTooltip,
91
+ tooltipVariant = 'light',
92
+ tooltipStyle,
67
93
  children,
68
94
  } ) => {
69
95
  const chartId = useChartId( providedChartId );
96
+ const tooltipBoxStyle = tooltipStyle
97
+ ? { ...TOOLTIP_BOX_STYLES[ tooltipVariant ], ...tooltipStyle }
98
+ : TOOLTIP_BOX_STYLES[ tooltipVariant ];
70
99
  const { getElementStyles, theme } = useGlobalChartsContext();
71
100
  const scopeElement = useChartScopeElement();
72
101
  const { heatmapChart: heatmapChartSettings } = theme;
73
102
  const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
74
103
 
75
- const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
104
+ const [ selected, setSelected ] = useState< CellPosition | undefined >();
76
105
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
77
106
  useTooltip< HeatmapTooltipData >();
78
107
  const standaloneScopeClass = useStandaloneScopeClass();
@@ -123,27 +152,34 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
123
152
 
124
153
  const columns = data.length;
125
154
  const rows = Math.max( 0, ...data.map( column => column.data.length ) );
155
+ // Line 1 is the row-label track, so the data columns start on line 2.
156
+ const groupLayout = useMemo(
157
+ () => resolveColumnGroups( columnGroups, columns, 2 ),
158
+ [ columnGroups, columns ]
159
+ );
126
160
 
127
- const { compactCellGap, compactCellSize } = heatmapChartSettings;
161
+ const { compactCellGap, compactCellSize, groupGap } = heatmapChartSettings;
128
162
  const drawValues = showValues ?? ! compact;
129
163
 
130
164
  const buildTooltipData = useCallback(
131
165
  ( columnIndex: number, rowIndex: number ): HeatmapTooltipData => {
132
166
  const cell = data[ columnIndex ]?.data[ rowIndex ];
167
+ const group = groupLayout.columns[ columnIndex ]?.group;
133
168
  return {
134
169
  value: cell?.value ?? null,
135
170
  rowLabel: rowLabels[ rowIndex ],
136
171
  columnLabel: data[ columnIndex ]?.label,
172
+ groupLabel: group === undefined ? undefined : groupLayout.groups[ group ]?.label,
137
173
  cellLabel: cell?.label,
138
174
  row: rowIndex,
139
175
  column: columnIndex,
140
176
  };
141
177
  },
142
- [ data, rowLabels ]
178
+ [ data, rowLabels, groupLayout ]
143
179
  );
144
180
 
145
181
  const onChartBlur = useCallback( () => {
146
- setSelectedIndex( undefined );
182
+ setSelected( undefined );
147
183
  hideTooltip();
148
184
  }, [ hideTooltip ] );
149
185
 
@@ -157,65 +193,49 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
157
193
  [ data ]
158
194
  );
159
195
 
196
+ // Calendar navigation reads each column group as a page; ungrouped columns
197
+ // (or the whole grid without groups) make a page of their own.
198
+ const blocks = useMemo< CellBlock[] >( () => {
199
+ const grouped = groupLayout.groups.map( ( group, index ) => {
200
+ const start = groupLayout.columns.findIndex( column => column.group === index );
201
+ return { start, end: start + group.span };
202
+ } );
203
+ const groupedEnd = grouped.length ? grouped[ grouped.length - 1 ].end : 0;
204
+ return groupedEnd < columns ? [ ...grouped, { start: groupedEnd, end: columns } ] : grouped;
205
+ }, [ groupLayout, columns ] );
206
+
160
207
  const onChartKeyDown = useCallback(
161
208
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
162
- if (
163
- ! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape', 'Tab' ].includes(
164
- event.key
165
- )
166
- ) {
167
- return;
168
- }
169
-
170
209
  if ( event.key === 'Tab' || event.key === 'Escape' ) {
171
- setSelectedIndex( undefined );
210
+ setSelected( undefined );
172
211
  hideTooltip();
173
212
  return;
174
213
  }
175
-
176
- event.preventDefault();
177
-
178
- if ( selectedIndex === undefined ) {
179
- // Start at the first navigable cell (a calendar's leading edge
180
- // slots may be hidden).
181
- for ( let index = 0; index < columns * rows; index++ ) {
182
- if ( ! isCellInert( Math.floor( index / rows ), index % rows ) ) {
183
- setSelectedIndex( index );
184
- return;
185
- }
186
- }
214
+ if ( ! isNavigationKey( event.key, keyboardNavigation ) ) {
187
215
  return;
188
216
  }
189
217
 
190
- let stepCol = 0;
191
- let stepRow = 0;
192
- if ( event.key === 'ArrowRight' ) {
193
- stepCol = 1;
194
- } else if ( event.key === 'ArrowLeft' ) {
195
- stepCol = -1;
196
- } else if ( event.key === 'ArrowDown' ) {
197
- stepRow = 1;
198
- } else if ( event.key === 'ArrowUp' ) {
199
- stepRow = -1;
200
- }
218
+ event.preventDefault();
201
219
 
202
- // Step past hidden slots to the next navigable cell in the pressed
203
- // direction; when only hidden slots (or the edge) remain that way,
204
- // the selection stays put.
205
- let col = Math.floor( selectedIndex / rows );
206
- let row = selectedIndex % rows;
207
- do {
208
- col += stepCol;
209
- row += stepRow;
210
- } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellInert( col, row ) );
211
-
212
- if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
220
+ const grid = { columns, rows, isInert: isCellInert };
221
+ if ( selected === undefined ) {
222
+ setSelected(
223
+ keyboardNavigation === 'calendar'
224
+ ? firstCalendarCell( grid, blocks )
225
+ : firstGridCell( grid )
226
+ );
213
227
  return;
214
228
  }
215
229
 
216
- setSelectedIndex( col * rows + row );
230
+ const next =
231
+ keyboardNavigation === 'calendar'
232
+ ? stepCalendarCell( grid, blocks, selected, event.key )
233
+ : stepGridCell( grid, selected, event.key );
234
+ if ( next ) {
235
+ setSelected( next );
236
+ }
217
237
  },
218
- [ rows, columns, selectedIndex, hideTooltip, isCellInert ]
238
+ [ rows, columns, selected, hideTooltip, isCellInert, keyboardNavigation, blocks ]
219
239
  );
220
240
 
221
241
  const handleCellMouseMove = useCallback(
@@ -239,51 +259,62 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
239
259
  [ withTooltips, showTooltip, buildTooltipData, getTooltipOrigin ]
240
260
  );
241
261
 
262
+ const getCellElement = useCallback(
263
+ ( col: number, row: number ) =>
264
+ typeof document !== 'undefined'
265
+ ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
266
+ : null,
267
+ [ chartId ]
268
+ );
269
+
242
270
  const handleCellMouseLeave = useCallback( () => {
243
271
  // Keyboard selection owns the tooltip; don't let a mouse-out clear it.
244
- if ( withTooltips && selectedIndex === undefined ) {
272
+ if ( withTooltips && selected === undefined ) {
245
273
  hideTooltip();
246
274
  }
247
- }, [ withTooltips, selectedIndex, hideTooltip ] );
275
+ }, [ withTooltips, selected, hideTooltip ] );
276
+
277
+ // A data refresh that shrinks the grid would leave the selection on a slot that
278
+ // no longer exists; navigating from there has nothing to step from.
279
+ useEffect( () => {
280
+ if ( selected && ( selected.column >= columns || selected.row >= rows ) ) {
281
+ setSelected( undefined );
282
+ }
283
+ }, [ selected, columns, rows ] );
284
+
285
+ // Focus stays on the grid (aria-activedescendant), so the browser never scrolls
286
+ // the selected cell into a scroll container's view on its own. Keyed on the
287
+ // selection alone: a data refresh must not scroll the user back to it.
288
+ useEffect( () => {
289
+ if ( selected === undefined ) {
290
+ return;
291
+ }
292
+ const cell = getCellElement( selected.column, selected.row );
293
+ cell?.scrollIntoView?.( { block: 'nearest', inline: 'nearest' } );
294
+ }, [ selected, getCellElement ] );
248
295
 
249
296
  // Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape,
250
297
  // not here, so a mouse hover (no selection) isn't affected.
251
298
  useEffect( () => {
252
- if ( ! withTooltips || selectedIndex === undefined ) {
299
+ if ( ! withTooltips || selected === undefined ) {
253
300
  return;
254
301
  }
255
302
  const origin = getTooltipOrigin();
256
303
  if ( ! origin ) {
257
304
  return;
258
305
  }
259
- const col = Math.floor( selectedIndex / rows );
260
- const row = selectedIndex % rows;
261
- const cell =
262
- typeof document !== 'undefined'
263
- ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
264
- : null;
265
- const rect = cell?.getBoundingClientRect();
306
+ const rect = getCellElement( selected.column, selected.row )?.getBoundingClientRect();
266
307
  showTooltip( {
267
308
  tooltipLeft: rect ? rect.left + rect.width / 2 - origin.left : 0,
268
309
  tooltipTop: rect ? rect.top + rect.height / 2 - origin.top : 0,
269
- tooltipData: buildTooltipData( col, row ),
310
+ tooltipData: buildTooltipData( selected.column, selected.row ),
270
311
  } );
271
- }, [
272
- selectedIndex,
273
- withTooltips,
274
- rows,
275
- chartId,
276
- buildTooltipData,
277
- showTooltip,
278
- getTooltipOrigin,
279
- ] );
312
+ }, [ selected, withTooltips, getCellElement, buildTooltipData, showTooltip, getTooltipOrigin ] );
280
313
 
281
314
  const defaultRenderTooltip = useCallback(
282
315
  ( info: HeatmapTooltipData ) => (
283
316
  <div>
284
- <strong>
285
- { info.cellLabel || `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim() }
286
- </strong>
317
+ <strong>{ cellName( info ) }</strong>
287
318
  <div>
288
319
  { info.value === null ? __( 'No data', 'jetpack-charts' ) : formatNumber( info.value ) }
289
320
  </div>
@@ -316,19 +347,35 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
316
347
  const rowTrack = compact
317
348
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
318
349
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
350
+ const hasColumnLabels = data.some( column => Boolean( column.label ) );
351
+ const hasGroups = groupLayout.groups.length > 0;
352
+ // Every item is placed by hand rather than auto-flowed, so the template can
353
+ // carry gap tracks that hold no cell.
354
+ const columnLine = ( columnIndex: number ) => groupLayout.columns[ columnIndex ].line;
355
+ const firstDataRow = hasColumnLabels ? 2 : 1;
319
356
  // A summary column takes a content-sized track: a roll-up is wider than a
320
357
  // cell, and a shared track would stretch every cell to fit it. `max-content`
321
358
  // as the max keeps the leftover width out of it once the data tracks hit
322
359
  // `maxCellWidth`, where a plain `auto` would absorb it.
323
- const columnTracks = data.some( column => column.summary )
324
- ? data
325
- .map( column => ( column.summary ? 'minmax(auto, max-content)' : columnTrack ) )
326
- .join( ' ' )
327
- : `repeat(${ columns }, ${ columnTrack })`;
360
+ const dataTrack = ( column: ( typeof data )[ number ] ) =>
361
+ column.summary ? 'minmax(auto, max-content)' : columnTrack;
362
+ // The group gap is a track of its own: a margin cannot widen a fixed or
363
+ // minmax track the way it widens the summary's auto track. Compact cells
364
+ // are fixed, so there the gaps share the leftover width instead.
365
+ const gapTrack = compact ? `minmax(${ groupGap }px, 1fr)` : `${ groupGap }px`;
366
+ const columnTracks = data
367
+ .map( ( column, columnIndex ) =>
368
+ groupLayout.columns[ columnIndex ].gapBefore
369
+ ? `${ gapTrack } ${ dataTrack( column ) }`
370
+ : dataTrack( column )
371
+ )
372
+ .join( ' ' );
328
373
  const gridStyle: Record< string, string | number > = {
329
374
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
330
375
  gridTemplateColumns: `auto ${ columnTracks }`,
331
- gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
376
+ gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
377
+ hasGroups ? ' auto' : ''
378
+ }`,
332
379
  };
333
380
  if ( compact ) {
334
381
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-gap' ] = `${ compactCellGap }px`;
@@ -350,10 +397,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
350
397
  };
351
398
  };
352
399
 
353
- const activeDescendant =
354
- selectedIndex !== undefined
355
- ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
356
- : undefined;
400
+ const activeDescendant = selected
401
+ ? `${ chartId }-cell-${ selected.column }-${ selected.row }`
402
+ : undefined;
357
403
 
358
404
  // A capped row track makes the chart content-sized vertically: neither the
359
405
  // wrapper nor the grid stretches, or the leftover container height would
@@ -383,7 +429,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
383
429
  <div
384
430
  ref={ containerRef }
385
431
  role="grid"
386
- aria-label={ __( 'Heatmap chart', 'jetpack-charts' ) }
432
+ aria-label={ ariaLabel ?? __( 'Heatmap chart', 'jetpack-charts' ) }
387
433
  aria-rowcount={ rows }
388
434
  aria-colcount={ columns }
389
435
  aria-activedescendant={ activeDescendant }
@@ -392,28 +438,33 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
392
438
  onKeyDown={ onChartKeyDown }
393
439
  className={ clsx( styles[ 'heatmap-chart__grid' ], {
394
440
  [ styles[ 'heatmap-chart__grid--compact' ] ]: compact,
441
+ [ styles[ 'heatmap-chart__grid--flex-gaps' ] ]: compact && hasGroups,
395
442
  [ styles[ 'heatmap-chart__grid--height-capped' ] ]: heightCapped,
396
443
  } ) }
397
444
  style={ gridStyle as CSSProperties }
398
445
  >
399
- { /* Header row preserves the grid structure; cell aria-labels include this text. */ }
400
- <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
401
- <span />
402
- { data.map( ( column, columnIndex ) => (
403
- <span
404
- key={ `col-${ columnIndex }` }
405
- className={ clsx( styles[ 'heatmap-chart__col-label' ], {
406
- [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
407
- ...summaryGaps( columnIndex ),
408
- } ) }
409
- >
410
- { column.label }
411
- </span>
412
- ) ) }
413
- </div>
446
+ { /* Decorative: cell aria-labels already carry the column name. */ }
447
+ { hasColumnLabels && (
448
+ <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
449
+ <span style={ { gridColumn: 1, gridRow: 1 } } />
450
+ { data.map( ( column, columnIndex ) => (
451
+ <span
452
+ key={ `col-${ columnIndex }` }
453
+ style={ { gridColumn: columnLine( columnIndex ), gridRow: 1 } }
454
+ className={ clsx( styles[ 'heatmap-chart__col-label' ], {
455
+ [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
456
+ ...summaryGaps( columnIndex ),
457
+ } ) }
458
+ >
459
+ { column.label }
460
+ </span>
461
+ ) ) }
462
+ </div>
463
+ ) }
414
464
 
415
465
  { Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
416
466
  const labelVisible = ! compact || rowIndex % 2 === 0;
467
+ const gridRow = firstDataRow + rowIndex;
417
468
  return (
418
469
  <div
419
470
  key={ `row-${ rowIndex }` }
@@ -421,11 +472,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
421
472
  aria-rowindex={ rowIndex + 1 }
422
473
  className={ styles[ 'heatmap-chart__row' ] }
423
474
  >
424
- <span aria-hidden="true" className={ styles[ 'heatmap-chart__row-label' ] }>
425
- { labelVisible ? rowLabels[ rowIndex ] ?? '' : '' }
475
+ <span
476
+ aria-hidden="true"
477
+ className={ styles[ 'heatmap-chart__row-label' ] }
478
+ style={ { gridColumn: 1, gridRow } }
479
+ >
480
+ { labelVisible ? ( rowLabels[ rowIndex ] ?? '' ) : '' }
426
481
  </span>
427
482
  { data.map( ( column, columnIndex ) => {
428
483
  const cell = column.data[ rowIndex ];
484
+ const placement = { gridColumn: columnLine( columnIndex ), gridRow };
429
485
 
430
486
  // A hidden cell keeps its grid slot (so the rest of the
431
487
  // column doesn't shift) but paints nothing and takes no
@@ -436,6 +492,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
436
492
  key={ `cell-${ columnIndex }-${ rowIndex }` }
437
493
  data-testid="heatmap-cell-hidden"
438
494
  aria-hidden="true"
495
+ style={ placement }
439
496
  className={ clsx(
440
497
  styles[ 'heatmap-chart__cell' ],
441
498
  styles[ 'heatmap-chart__cell--hidden' ]
@@ -454,6 +511,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
454
511
  key={ `cell-${ columnIndex }-${ rowIndex }` }
455
512
  data-testid="heatmap-cell-placeholder"
456
513
  aria-hidden="true"
514
+ style={ placement }
457
515
  className={ clsx(
458
516
  styles[ 'heatmap-chart__cell' ],
459
517
  styles[ 'heatmap-chart__cell--placeholder' ]
@@ -467,12 +525,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
467
525
  // A summary cell is on another scale, so it takes no fill.
468
526
  const filled = present && ! column.summary;
469
527
  const normalized = filled ? getNormalizedValue( value, extent ) : 0;
470
- const flatIndex = columnIndex * rows + rowIndex;
471
528
  const info = buildTooltipData( columnIndex, rowIndex );
472
- const accessibleName =
473
- info.cellLabel ||
474
- `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim();
475
- const accessibleLabel = `${ accessibleName }: ${
529
+ const accessibleLabel = `${ cellName( info ) }: ${
476
530
  info.value === null
477
531
  ? __( 'No data', 'jetpack-charts' )
478
532
  : formatNumber( info.value )
@@ -498,14 +552,15 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
498
552
  [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
499
553
  ...summaryGaps( columnIndex ),
500
554
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
501
- selectedIndex === flatIndex,
555
+ selected?.column === columnIndex && selected?.row === rowIndex,
502
556
  } ) }
503
557
  style={
504
- filled
505
- ? ( {
506
- '--a8c-charts-heatmap-cell-intensity': normalized,
507
- } as CSSProperties )
508
- : undefined
558
+ {
559
+ ...placement,
560
+ ...( filled
561
+ ? { '--a8c-charts-heatmap-cell-intensity': normalized }
562
+ : {} ),
563
+ } as CSSProperties
509
564
  }
510
565
  onMouseMove={ handleCellMouseMove }
511
566
  onMouseLeave={ handleCellMouseLeave }
@@ -522,9 +577,31 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
522
577
  </div>
523
578
  );
524
579
  } ) }
580
+ { hasGroups && (
581
+ <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
582
+ { groupLayout.groups.map( ( group, groupIndex ) => (
583
+ <span
584
+ key={ `group-${ groupIndex }` }
585
+ data-testid="heatmap-group-label"
586
+ className={ styles[ 'heatmap-chart__group-label' ] }
587
+ style={ {
588
+ gridColumn: `${ group.line } / span ${ group.span }`,
589
+ gridRow: firstDataRow + rows,
590
+ } }
591
+ >
592
+ { group.label }
593
+ </span>
594
+ ) ) }
595
+ </div>
596
+ ) }
525
597
  </div>
526
598
  { withTooltips && tooltipOpen && tooltipData && (
527
- <BoundedTooltip top={ tooltipTop } left={ tooltipLeft }>
599
+ <BoundedTooltip
600
+ top={ tooltipTop }
601
+ left={ tooltipLeft }
602
+ className={ tooltipVariant === 'dark' ? tooltipStyles.surface : undefined }
603
+ style={ tooltipBoxStyle }
604
+ >
528
605
  <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
529
606
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
530
607
  </div>
@@ -1,11 +1,17 @@
1
1
  export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
2
2
  export { buildCalendarHeatmapData } from './build-calendar-data';
3
+ export { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
3
4
  export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
5
+ export { useMonthCalendarHeatmapData } from './use-month-calendar-heatmap-data';
4
6
  export type {
5
7
  CalendarHeatmapOptions,
6
8
  CalendarHeatmapResult,
7
9
  HeatmapChartProps,
8
10
  HeatmapColumn,
11
+ HeatmapColumnGroup,
9
12
  HeatmapCell,
10
13
  HeatmapTooltipData,
14
+ MonthCalendarHeatmapOptions,
15
+ MonthCalendarHeatmapRange,
16
+ MonthCalendarHeatmapResult,
11
17
  } from './types';
@@ -1,4 +1,5 @@
1
1
  import { parseISO } from 'date-fns';
2
+ import { createDateFormatter } from '../../../utils/date-formatting';
2
3
  import { hasTimezone } from '../../../utils/date-parsing';
3
4
  import type { DataPointDate } from '../../../types';
4
5
 
@@ -27,6 +28,31 @@ const KEY_OPTIONS: Intl.DateTimeFormatOptions = {
27
28
 
28
29
  const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
29
30
 
31
+ /**
32
+ * Label formatters for UTC proxies, so both calendar layouts name a day alike.
33
+ *
34
+ * `gregory` keeps a locale whose default calendar is not Gregorian (fa-IR) from
35
+ * splitting one grid month across two names.
36
+ *
37
+ * @param locale - BCP 47 locale.
38
+ * @return Weekday, month and full-day formatters.
39
+ */
40
+ export const civilLabelFormatters = ( locale: string ) => {
41
+ const formatting = { locale, timeZone: 'UTC' };
42
+ return {
43
+ formatWeekday: createDateFormatter( { weekday: 'short', calendar: 'gregory' }, formatting ),
44
+ formatMonth: createDateFormatter( { month: 'short', calendar: 'gregory' }, formatting ),
45
+ formatMonthYear: createDateFormatter(
46
+ { month: 'short', year: 'numeric', calendar: 'gregory' },
47
+ formatting
48
+ ),
49
+ formatDay: createDateFormatter(
50
+ { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', calendar: 'gregory' },
51
+ formatting
52
+ ),
53
+ };
54
+ };
55
+
30
56
  const pad = ( value: number, length: number ) => String( value ).padStart( length, '0' );
31
57
 
32
58
  /**
@@ -0,0 +1,71 @@
1
+ import { warnOnce } from '../../../utils/warn-once';
2
+ import type { HeatmapColumnGroup } from '../types';
3
+
4
+ export type ColumnGroupLayout = {
5
+ /** One per data column: its grid column line, whether a gap track precedes it, and its group's index. */
6
+ columns: { line: number; gapBefore: boolean; group?: number }[];
7
+ /** The groups as given, each with the grid column line its label starts on; none when any was unusable. */
8
+ groups: ( HeatmapColumnGroup & { line: number } )[];
9
+ };
10
+
11
+ const isSpan = ( span: number ) => Number.isInteger( span ) && span > 0;
12
+
13
+ /**
14
+ * Validates column groups and lays them over the columns.
15
+ *
16
+ * @param groups - Groups as passed to the chart.
17
+ * @param columnCount - Number of data columns.
18
+ * @param firstLine - Grid column line of the first data column.
19
+ * @return Grid column lines for every column and group; no groups when the input was unusable.
20
+ */
21
+ export const resolveColumnGroups = (
22
+ groups: HeatmapColumnGroup[] | undefined,
23
+ columnCount: number,
24
+ firstLine: number
25
+ ): ColumnGroupLayout => {
26
+ const ungrouped = (): ColumnGroupLayout => ( {
27
+ columns: Array.from( { length: columnCount }, ( _, index ) => ( {
28
+ line: firstLine + index,
29
+ gapBefore: false,
30
+ } ) ),
31
+ groups: [],
32
+ } );
33
+
34
+ // An empty grid draws nothing, so static groups awaiting data are not a misuse.
35
+ if ( ! groups || groups.length === 0 || columnCount === 0 ) {
36
+ return ungrouped();
37
+ }
38
+
39
+ const spans = groups.map( group => group.span );
40
+ const total = spans.reduce( ( sum, span ) => sum + span, 0 );
41
+ if ( ! spans.every( isSpan ) || total > columnCount ) {
42
+ warnOnce(
43
+ `heatmap:columnGroups:${ JSON.stringify( spans ) }:${ columnCount }`,
44
+ `columnGroups spans ${ JSON.stringify(
45
+ spans
46
+ ) } must be positive integers that fit the ${ columnCount } columns, so no groups are drawn.`
47
+ );
48
+ return ungrouped();
49
+ }
50
+
51
+ const columns: ColumnGroupLayout[ 'columns' ] = [];
52
+ const laidOut: ColumnGroupLayout[ 'groups' ] = [];
53
+ let line = firstLine;
54
+ groups.forEach( ( group, groupIndex ) => {
55
+ if ( groupIndex > 0 ) {
56
+ line += 1;
57
+ }
58
+ laidOut.push( { ...group, line } );
59
+ for ( let offset = 0; offset < group.span; offset++ ) {
60
+ columns.push( { line, gapBefore: groupIndex > 0 && offset === 0, group: groupIndex } );
61
+ line += 1;
62
+ }
63
+ } );
64
+ // An ungrouped tail opens no further gap.
65
+ while ( columns.length < columnCount ) {
66
+ columns.push( { line, gapBefore: false } );
67
+ line += 1;
68
+ }
69
+
70
+ return { columns, groups: laidOut };
71
+ };