@automattic/charts 1.10.0 → 1.10.2

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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.cjs +5917 -5783
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +44 -3
  5. package/dist/index.d.cts +66 -13
  6. package/dist/index.d.ts +67 -14
  7. package/dist/index.js +1159 -1015
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/charts/heatmap-chart/heatmap-chart.module.scss +27 -4
  11. package/src/charts/heatmap-chart/heatmap-chart.tsx +115 -49
  12. package/src/charts/heatmap-chart/private/build-calendar-data.ts +39 -4
  13. package/src/charts/heatmap-chart/private/heatmap-context.ts +10 -0
  14. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -2
  15. package/src/charts/heatmap-chart/private/index.ts +2 -0
  16. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +6 -2
  17. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +57 -1
  18. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -3
  19. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +4 -0
  20. package/src/charts/heatmap-chart/types.ts +28 -0
  21. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +12 -0
  22. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +65 -37
  23. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +46 -6
  24. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +0 -6
  25. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +229 -18
  26. package/src/charts/private/with-responsive/with-responsive.module.scss +20 -1
  27. package/src/charts/private/with-responsive/with-responsive.tsx +114 -65
  28. package/src/hooks/test/use-chart-margin.test.tsx +8 -5
  29. package/src/hooks/use-chart-margin.tsx +11 -1
  30. package/src/providers/chart-context/global-charts-provider.tsx +7 -0
  31. package/src/providers/chart-context/test/chart-context.test.tsx +40 -0
  32. package/src/providers/chart-context/types.ts +3 -0
  33. package/src/types.ts +9 -6
  34. package/src/utils/color-utils.ts +28 -0
  35. package/src/utils/test/color-utils.test.ts +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.5",
66
+ "@automattic/number-formatters": "^1.2.7",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@react-spring/web": "10.1.1",
69
69
  "@visx/annotation": "^4.0.0",
@@ -107,11 +107,11 @@
107
107
  "@types/jest": "^30.0.0",
108
108
  "@types/react": "18.3.28",
109
109
  "@types/react-dom": "18.3.7",
110
- "@typescript/native-preview": "7.0.0-dev.20260225.1",
110
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
111
111
  "@visx/glyph": "4.0.0",
112
- "@wordpress/components": "36.1.0",
113
- "@wordpress/element": "8.2.0",
114
- "@wordpress/private-apis": "1.50.0",
112
+ "@wordpress/components": "37.0.0",
113
+ "@wordpress/element": "8.3.0",
114
+ "@wordpress/private-apis": "1.51.0",
115
115
  "babel-jest": "30.4.1",
116
116
  "identity-obj-proxy": "^3.0.0",
117
117
  "jest": "30.4.2",
@@ -122,7 +122,7 @@
122
122
  "sass-embedded": "1.97.3",
123
123
  "storybook": "10.4.6",
124
124
  "tsdown": "0.22.2",
125
- "typescript": "5.9.3"
125
+ "typescript": "6.0.3"
126
126
  },
127
127
  "peerDependencies": {
128
128
  "react": "^18.0.0 || ^19.0.0",
@@ -5,6 +5,13 @@
5
5
  font-size: var(--wpds-typography-font-size-sm, 12px);
6
6
  }
7
7
 
8
+ // Height-capped grids are content-sized vertically, so the wrapper hugs them
9
+ // instead of stretching — the consumer decides how to place it in leftover
10
+ // space. Width-only caps keep the normal full-height layout.
11
+ .heatmap-chart--height-capped {
12
+ height: max-content;
13
+ }
14
+
8
15
  .heatmap-chart__empty {
9
16
  padding: var(--wpds-dimension-padding-lg, 16px);
10
17
  color: var(--wpds-color-foreground-content-neutral-weak, #707070);
@@ -17,8 +24,8 @@
17
24
  gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
18
25
  flex: 1 1 0;
19
26
  width: 100%;
20
- // Floor so the grid stays usable when the parent has no explicit height.
21
- min-height: 200px;
27
+ // Override flex's auto minimum so short widgets do not scroll.
28
+ min-height: 0;
22
29
 
23
30
  &:focus-visible {
24
31
  outline:
@@ -36,6 +43,15 @@
36
43
  min-height: 0;
37
44
  }
38
45
 
46
+ // A cell-height cap makes the row tracks content-bound, so the grid must not
47
+ // stretch: leftover container height would otherwise be absorbed by the auto
48
+ // label row. A width-only cap does not change vertical flex sizing.
49
+ .heatmap-chart__grid--height-capped {
50
+ flex: 0 0 auto;
51
+ height: max-content;
52
+ min-height: 0;
53
+ }
54
+
39
55
  .heatmap-chart__row {
40
56
  display: contents;
41
57
  }
@@ -71,10 +87,17 @@
71
87
  background: var(--wpds-color-background-track-neutral-weak, #f0f0f0);
72
88
  }
73
89
 
90
+ // An empty grid slot (calendar ragged edges): keeps its track but paints and
91
+ // captures nothing. `visibility` (not `display`) so the slot still sizes.
92
+ .heatmap-chart__cell--hidden {
93
+ visibility: hidden;
94
+ pointer-events: none;
95
+ }
96
+
74
97
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
75
98
  // an empty cell.
76
99
  .heatmap-chart__cell--filled {
77
- background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent);
100
+ background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), var(--heatmap-bg, #fff));
78
101
  }
79
102
 
80
103
  .heatmap-chart__cell--selected {
@@ -99,5 +122,5 @@
99
122
  width: var(--wpds-dimension-size-3xs, 12px);
100
123
  height: var(--wpds-dimension-size-3xs, 12px);
101
124
  border-radius: var(--wpds-border-radius-sm, 2px);
102
- background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent);
125
+ background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), var(--heatmap-bg, #fff));
103
126
  }
@@ -2,15 +2,7 @@ import { formatNumber, formatNumberCompact } from '@automattic/number-formatters
2
2
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
- import {
6
- createContext,
7
- useCallback,
8
- useContext,
9
- useEffect,
10
- useMemo,
11
- useRef,
12
- useState,
13
- } from 'react';
5
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
14
6
  import {
15
7
  GlobalChartsProvider,
16
8
  useChartId,
@@ -20,7 +12,7 @@ import {
20
12
  import { attachSubComponents } from '../../utils';
21
13
  import {
22
14
  isValidHexColor,
23
- lightenHexColor,
15
+ mixHexColors,
24
16
  normalizeColorToHex,
25
17
  prefersLightText,
26
18
  } from '../../utils/color-utils';
@@ -30,19 +22,18 @@ import { ChartLayout } from '../private/chart-layout';
30
22
  import { SingleChartContext } from '../private/single-chart-context';
31
23
  import { withResponsive } from '../private/with-responsive';
32
24
  import styles from './heatmap-chart.module.scss';
33
- import { getValueExtent, getNormalizedValue, HeatmapLegend, isPresent } from './private';
25
+ import {
26
+ getValueExtent,
27
+ getNormalizedValue,
28
+ HeatmapContext,
29
+ HeatmapLegend,
30
+ isPresent,
31
+ } from './private';
32
+ import type { HeatmapContextValue } from './private';
34
33
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
35
34
  import type { ResponsiveConfig } from '../private/with-responsive';
36
35
  import type { CSSProperties, FC } from 'react';
37
36
 
38
- export type HeatmapContextValue = {
39
- extent: [ number, number ];
40
- /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
41
- primaryColorHex: string;
42
- };
43
-
44
- export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
45
-
46
37
  // Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
47
38
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
48
39
  const CELL_MIX_FLOOR = 0.15;
@@ -55,6 +46,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
55
46
  className,
56
47
  compact = false,
57
48
  showValues,
49
+ maxCellWidth,
50
+ maxCellHeight,
51
+ minCellWidth,
52
+ minCellHeight,
58
53
  rowLabels = [],
59
54
  primaryColor,
60
55
  gap = 'md',
@@ -63,7 +58,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
63
58
  children,
64
59
  } ) => {
65
60
  const chartId = useChartId( providedChartId );
66
- const { getElementStyles, theme } = useGlobalChartsContext();
61
+ const { getElementStyles, resolveThemeColor, theme } = useGlobalChartsContext();
67
62
  const { heatmapChart: heatmapChartSettings } = theme;
68
63
  const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
69
64
 
@@ -84,14 +79,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
84
79
  overrideColor: primaryColor || heatmapChartSettings.primaryColor,
85
80
  } );
86
81
 
87
- // Pick the in-cell text color from the cell's actual blended fill luminance (not the data
88
- // value), so light text is only used where it out-contrasts dark text. Falls back to dark
89
- // text when the primary isn't a resolvable hex (e.g. a bare CSS token).
82
+ // Resolve the background in the provider's theme scope so the blended-fill text
83
+ // color tracks a themed (e.g. dark) background.
84
+ const chartBackgroundHex = resolveThemeColor( theme.backgroundColor );
85
+
86
+ // Choose text color from the blended fill, not the raw value.
87
+ // If either color cannot resolve to hex, keep dark text.
90
88
  const primaryHex = normalizeColorToHex( primaryColorHex );
91
89
  const cellHasLightText = ( intensity: number ): boolean =>
92
90
  isValidHexColor( primaryHex ) &&
91
+ isValidHexColor( chartBackgroundHex ) &&
93
92
  prefersLightText(
94
- lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) )
93
+ mixHexColors(
94
+ primaryHex,
95
+ chartBackgroundHex,
96
+ 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity )
97
+ )
95
98
  );
96
99
 
97
100
  const extent = useMemo( () => getValueExtent( data ), [ data ] );
@@ -126,6 +129,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
126
129
  hideTooltip();
127
130
  }, [ hideTooltip ] );
128
131
 
132
+ const isCellHidden = useCallback(
133
+ ( col: number, row: number ) => data[ col ]?.data[ row ]?.hidden === true,
134
+ [ data ]
135
+ );
136
+
129
137
  const onChartKeyDown = useCallback(
130
138
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
131
139
  if (
@@ -145,26 +153,46 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
145
153
  event.preventDefault();
146
154
 
147
155
  if ( selectedIndex === undefined ) {
148
- setSelectedIndex( 0 );
156
+ // Start at the first navigable cell (a calendar's leading edge
157
+ // slots may be hidden).
158
+ for ( let index = 0; index < columns * rows; index++ ) {
159
+ if ( ! isCellHidden( Math.floor( index / rows ), index % rows ) ) {
160
+ setSelectedIndex( index );
161
+ return;
162
+ }
163
+ }
149
164
  return;
150
165
  }
151
166
 
152
- let col = Math.floor( selectedIndex / rows );
153
- let row = selectedIndex % rows;
154
-
167
+ let stepCol = 0;
168
+ let stepRow = 0;
155
169
  if ( event.key === 'ArrowRight' ) {
156
- col = Math.min( col + 1, columns - 1 );
170
+ stepCol = 1;
157
171
  } else if ( event.key === 'ArrowLeft' ) {
158
- col = Math.max( col - 1, 0 );
172
+ stepCol = -1;
159
173
  } else if ( event.key === 'ArrowDown' ) {
160
- row = Math.min( row + 1, rows - 1 );
174
+ stepRow = 1;
161
175
  } else if ( event.key === 'ArrowUp' ) {
162
- row = Math.max( row - 1, 0 );
176
+ stepRow = -1;
177
+ }
178
+
179
+ // Step past hidden slots to the next navigable cell in the pressed
180
+ // direction; when only hidden slots (or the edge) remain that way,
181
+ // the selection stays put.
182
+ let col = Math.floor( selectedIndex / rows );
183
+ let row = selectedIndex % rows;
184
+ do {
185
+ col += stepCol;
186
+ row += stepRow;
187
+ } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellHidden( col, row ) );
188
+
189
+ if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
190
+ return;
163
191
  }
164
192
 
165
193
  setSelectedIndex( col * rows + row );
166
194
  },
167
- [ rows, columns, selectedIndex, hideTooltip ]
195
+ [ rows, columns, selectedIndex, hideTooltip, isCellHidden ]
168
196
  );
169
197
 
170
198
  const handleCellMouseMove = useCallback(
@@ -244,11 +272,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
244
272
  );
245
273
  }
246
274
 
247
- const trackSize = compact ? 'var(--heatmap-cell-size)' : 'minmax(0, 1fr)';
275
+ // Non-compact tracks split the container by default; a max cap makes them
276
+ // stop growing there instead, so sparse ranges keep sensible cell sizes,
277
+ // and a min floor makes the grid overflow (for a scrollable wrapper)
278
+ // rather than crushing cells on long ranges.
279
+ const columnTrack = compact
280
+ ? 'var(--heatmap-cell-size)'
281
+ : `minmax(${ minCellWidth ?? 0 }px, ${ maxCellWidth ? `${ maxCellWidth }px` : '1fr' })`;
282
+ const rowTrack = compact
283
+ ? 'var(--heatmap-cell-size)'
284
+ : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
248
285
  const gridStyle: Record< string, string | number > = {
249
286
  '--heatmap-primary': primaryColorHex,
250
- gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`,
251
- gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`,
287
+ '--heatmap-bg': theme.backgroundColor,
288
+ gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
289
+ gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
252
290
  };
253
291
  if ( compact ) {
254
292
  gridStyle[ '--heatmap-cell-gap' ] = `${ compactCellGap }px`;
@@ -260,6 +298,12 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
260
298
  ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
261
299
  : undefined;
262
300
 
301
+ // A capped row track makes the chart content-sized vertically: neither the
302
+ // wrapper nor the grid stretches, or the leftover container height would
303
+ // land in the auto label row. A width-only cap must keep the normal vertical
304
+ // flex sizing, so it does not opt into this class.
305
+ const heightCapped = ! compact && Boolean( maxCellHeight );
306
+
263
307
  return (
264
308
  <HeatmapContext.Provider value={ heatmapContext }>
265
309
  <SingleChartContext.Provider value={ { chartId } }>
@@ -269,7 +313,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
269
313
  legendChildren={ [] }
270
314
  trailingContent={ nonLegendChildren }
271
315
  gap={ gap }
272
- className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) }
316
+ className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className, {
317
+ [ styles[ 'heatmap-chart--height-capped' ] ]: heightCapped,
318
+ } ) }
273
319
  // Explicit dimensions (the unresponsive export) pin the size; otherwise
274
320
  // width/height are unset and the grid fills its container via CSS. The
275
321
  // responsive export drops the measured pixels so reflow stays fluid.
@@ -289,20 +335,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
289
335
  onKeyDown={ onChartKeyDown }
290
336
  className={ clsx( styles[ 'heatmap-chart__grid' ], {
291
337
  [ styles[ 'heatmap-chart__grid--compact' ] ]: compact,
338
+ [ styles[ 'heatmap-chart__grid--height-capped' ] ]: heightCapped,
292
339
  } ) }
293
340
  style={ gridStyle as CSSProperties }
294
341
  >
295
- { /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ }
296
- <span aria-hidden="true" />
297
- { data.map( ( column, columnIndex ) => (
298
- <span
299
- key={ `col-${ columnIndex }` }
300
- aria-hidden="true"
301
- className={ styles[ 'heatmap-chart__col-label' ] }
302
- >
303
- { column.label }
304
- </span>
305
- ) ) }
342
+ { /* Header row preserves the grid structure; cell aria-labels include this text. */ }
343
+ <div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
344
+ <span />
345
+ { data.map( ( column, columnIndex ) => (
346
+ <span
347
+ key={ `col-${ columnIndex }` }
348
+ className={ styles[ 'heatmap-chart__col-label' ] }
349
+ >
350
+ { column.label }
351
+ </span>
352
+ ) ) }
353
+ </div>
306
354
 
307
355
  { Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
308
356
  const labelVisible = ! compact || rowIndex % 2 === 0;
@@ -318,6 +366,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
318
366
  </span>
319
367
  { data.map( ( column, columnIndex ) => {
320
368
  const cell = column.data[ rowIndex ];
369
+
370
+ // A hidden cell keeps its grid slot (so the rest of the
371
+ // column doesn't shift) but paints nothing and takes no
372
+ // interaction — a calendar's ragged edges.
373
+ if ( cell?.hidden ) {
374
+ return (
375
+ <div
376
+ key={ `cell-${ columnIndex }-${ rowIndex }` }
377
+ data-testid="heatmap-cell-hidden"
378
+ aria-hidden="true"
379
+ className={ clsx(
380
+ styles[ 'heatmap-chart__cell' ],
381
+ styles[ 'heatmap-chart__cell--hidden' ]
382
+ ) }
383
+ />
384
+ );
385
+ }
386
+
321
387
  const value = cell?.value ?? null;
322
388
  const present = isPresent( value );
323
389
  const normalized = present ? getNormalizedValue( value, extent ) : 0;
@@ -360,7 +426,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
360
426
  >
361
427
  { drawValues && present && (
362
428
  <span className={ styles[ 'heatmap-chart__cell-value' ] }>
363
- { /* Compact so large values fit the cell; tooltip + aria-label keep full precision. */ }
429
+ { /* Compact display; tooltip and aria-label keep full precision. */ }
364
430
  { formatNumberCompact( value ) }
365
431
  </span>
366
432
  ) }
@@ -25,9 +25,19 @@ const toDate = ( point: DataPointDate ): Date | null => {
25
25
 
26
26
  export const buildCalendarHeatmapData = (
27
27
  series: DataPointDate[],
28
- options: { weekStartsOn?: 0 | 1 } = {}
28
+ options: {
29
+ weekStartsOn?: 0 | 1;
30
+ /**
31
+ * Mark the days completing the first/last week outside the series'
32
+ * date span as hidden cells (empty grid slots) instead of blank
33
+ * cells, giving the calendar ragged edges. Days inside the span stay
34
+ * blank cells even when the series has no entry for them.
35
+ */
36
+ hideOutOfRangeDays?: boolean;
37
+ } = {}
29
38
  ): CalendarHeatmapResult => {
30
39
  const weekStartsOn = options.weekStartsOn ?? 1;
40
+ const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
31
41
 
32
42
  const entries = series
33
43
  .map( point => ( { date: toDate( point ), value: point.value } ) )
@@ -53,26 +63,51 @@ export const buildCalendarHeatmapData = (
53
63
  const gridStart = startOfWeek( minDate, { weekStartsOn } );
54
64
  const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1;
55
65
 
66
+ // Day-key bounds for the ragged-edge option: calendar-day comparison, so
67
+ // entries carrying a time of day can't shift the span.
68
+ const minDayKey = format( minDate, 'yyyy-MM-dd' );
69
+ const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
70
+
56
71
  const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
57
72
  LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
58
73
  );
59
74
 
75
+ // Hide short partial first-month labels when a later month follows; compact
76
+ // cells make adjacent labels collide. Keep the label for single-month ranges.
77
+ const MIN_FIRST_MONTH_WEEKS = 2;
78
+ const firstMonth = gridStart.getMonth();
79
+ let firstMonthWeeks = 0;
80
+ while (
81
+ firstMonthWeeks < weekCount &&
82
+ addDays( gridStart, firstMonthWeeks * 7 ).getMonth() === firstMonth
83
+ ) {
84
+ firstMonthWeeks++;
85
+ }
86
+ const spansLaterMonth = firstMonthWeeks < weekCount;
87
+ const showFirstMonthLabel = ! spansLaterMonth || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
88
+
60
89
  const data: HeatmapColumn[] = [];
61
90
  let previousMonth = -1;
62
91
  for ( let week = 0; week < weekCount; week++ ) {
63
92
  const columnStart = addDays( gridStart, week * 7 );
64
93
  const month = columnStart.getMonth();
65
- const label = month !== previousMonth ? format( columnStart, 'MMM' ) : '';
94
+ const isNewMonth = month !== previousMonth;
95
+ const label =
96
+ isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? format( columnStart, 'MMM' ) : '';
66
97
  previousMonth = month;
67
98
 
68
99
  const cells: HeatmapCell[] = [];
69
100
  for ( let row = 0; row < 7; row++ ) {
70
101
  const day = addDays( gridStart, week * 7 + row );
71
102
  const key = format( day, 'yyyy-MM-dd' );
72
- cells.push( {
103
+ const cell: HeatmapCell = {
73
104
  label: format( day, 'EEE, MMM d, yyyy' ),
74
105
  value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
75
- } );
106
+ };
107
+ if ( hideOutOfRangeDays && ( key < minDayKey || key > maxDayKey ) ) {
108
+ cell.hidden = true;
109
+ }
110
+ cells.push( cell );
76
111
  }
77
112
  data.push( { label, data: cells } );
78
113
  }
@@ -0,0 +1,10 @@
1
+ import { createContext } from 'react';
2
+
3
+ export type HeatmapContextValue = {
4
+ extent: [ number, number ];
5
+ /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
6
+ primaryColorHex: string;
7
+ };
8
+
9
+ /** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
10
+ export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
@@ -2,8 +2,8 @@ import { __ } from '@wordpress/i18n';
2
2
  import { Stack, Text } from '@wordpress/ui';
3
3
  import { useContext } from 'react';
4
4
  import { useGlobalChartsTheme } from '../../../providers';
5
- import { HeatmapContext } from '../heatmap-chart';
6
5
  import styles from '../heatmap-chart.module.scss';
6
+ import { HeatmapContext } from './heatmap-context';
7
7
  import type { CSSProperties, FC } from 'react';
8
8
 
9
9
  export interface HeatmapLegendProps {
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
15
15
 
16
16
  export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
17
17
  const context = useContext( HeatmapContext );
18
- const { legend } = useGlobalChartsTheme();
18
+ const { legend, backgroundColor } = useGlobalChartsTheme();
19
19
  if ( ! context ) {
20
20
  return null;
21
21
  }
@@ -38,6 +38,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
38
38
  style={
39
39
  {
40
40
  '--heatmap-primary': primaryColorHex,
41
+ '--heatmap-bg': backgroundColor,
41
42
  '--intensity': intensity,
42
43
  } as CSSProperties
43
44
  }
@@ -1,5 +1,7 @@
1
1
  export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
2
2
  export { buildCalendarHeatmapData } from './build-calendar-data';
3
3
  export { HeatmapLegend } from './heatmap-legend';
4
+ export { HeatmapContext } from './heatmap-context';
4
5
  export type { CalendarHeatmapResult } from './build-calendar-data';
5
6
  export type { HeatmapLegendProps } from './heatmap-legend';
7
+ export type { HeatmapContextValue } from './heatmap-context';
@@ -31,7 +31,11 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
31
31
  };
32
32
 
33
33
  /**
34
- * Normalize a value to 0–1 within the extent. A flat extent (min === max) maps to 1.
34
+ * Normalize a value to 0–1 within the extent. A flat extent (min === max)
35
+ * maps to 1 — every cell is equally the "highest" — except an all-zero
36
+ * extent, which maps to 0 so a no-activity grid renders at the scale's
37
+ * bottom instead of full intensity.
38
+ *
35
39
  * @param value - The value to normalize
36
40
  * @param extent - Tuple of [min, max] values for the normalization range
37
41
  * @return Normalized value between 0 and 1
@@ -39,7 +43,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
39
43
  export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
40
44
  const [ min, max ] = extent;
41
45
  if ( min === max ) {
42
- return 1;
46
+ return max === 0 ? 0 : 1;
43
47
  }
44
48
  return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
45
49
  };
@@ -33,9 +33,43 @@ describe( 'buildCalendarHeatmapData', () => {
33
33
  expect( data[ 0 ].data[ 1 ].value ).toBeNull(); // Tue Jan 2 has no datum
34
34
  } );
35
35
 
36
+ test( 'hides the week-completion days outside the span by default', () => {
37
+ const midWeek: DataPointDate[] = [
38
+ { dateString: '2024-01-03', value: 5 }, // Wed
39
+ { dateString: '2024-01-09', value: 2 }, // Tue (2nd week)
40
+ ];
41
+ const { data } = buildCalendarHeatmapData( midWeek, {
42
+ weekStartsOn: 1,
43
+ } );
44
+
45
+ // Mon Jan 1 and Tue Jan 2 precede the span — hidden; Wed Jan 3 opens it.
46
+ expect( data[ 0 ].data[ 0 ].hidden ).toBe( true );
47
+ expect( data[ 0 ].data[ 1 ].hidden ).toBe( true );
48
+ expect( data[ 0 ].data[ 2 ].hidden ).toBeUndefined();
49
+
50
+ // Thu Jan 4 is inside the span with no entry — a blank cell, not hidden.
51
+ expect( data[ 0 ].data[ 3 ].hidden ).toBeUndefined();
52
+ expect( data[ 0 ].data[ 3 ].value ).toBeNull();
53
+
54
+ // Tue Jan 9 closes the span; Wed Jan 10 onward is hidden.
55
+ expect( data[ 1 ].data[ 1 ].hidden ).toBeUndefined();
56
+ expect( data[ 1 ].data[ 2 ].hidden ).toBe( true );
57
+ expect( data[ 1 ].data[ 6 ].hidden ).toBe( true );
58
+ } );
59
+
60
+ test( 'out-of-span days stay blank cells when hideOutOfRangeDays is false', () => {
61
+ const midWeek: DataPointDate[] = [ { dateString: '2024-01-03', value: 5 } ]; // Wed
62
+ const { data } = buildCalendarHeatmapData( midWeek, {
63
+ weekStartsOn: 1,
64
+ hideOutOfRangeDays: false,
65
+ } );
66
+ expect( data[ 0 ].data[ 0 ].hidden ).toBeUndefined();
67
+ expect( data[ 0 ].data[ 0 ].value ).toBeNull();
68
+ } );
69
+
36
70
  test( 'labels only the first column of each month', () => {
37
71
  const multiMonth: DataPointDate[] = [
38
- { dateString: '2024-01-29', value: 1 },
72
+ { dateString: '2024-01-01', value: 1 },
39
73
  { dateString: '2024-02-05', value: 1 },
40
74
  ];
41
75
  const { data } = buildCalendarHeatmapData( multiMonth );
@@ -44,6 +78,28 @@ describe( 'buildCalendarHeatmapData', () => {
44
78
  expect( labels ).toContain( 'Feb' );
45
79
  } );
46
80
 
81
+ test( 'suppresses a partial first month label so it cannot collide with the next', () => {
82
+ // Jan 29 is the only January column; February should be the first visible label.
83
+ const partialFirstMonth: DataPointDate[] = [
84
+ { dateString: '2024-01-29', value: 1 },
85
+ { dateString: '2024-02-05', value: 1 },
86
+ ];
87
+ const { data } = buildCalendarHeatmapData( partialFirstMonth, { weekStartsOn: 1 } );
88
+ expect( data[ 0 ].label ).toBe( '' );
89
+ expect( data.map( c => c.label ).filter( Boolean )[ 0 ] ).toBe( 'Feb' );
90
+ } );
91
+
92
+ test( 'keeps the first month label when the range never reaches a second month', () => {
93
+ // Single-month ranges keep their lone month label.
94
+ const singleMonth: DataPointDate[] = [
95
+ { dateString: '2024-01-01', value: 1 },
96
+ { dateString: '2024-01-03', value: 2 },
97
+ ];
98
+ const { data } = buildCalendarHeatmapData( singleMonth, { weekStartsOn: 1 } );
99
+ expect( data ).toHaveLength( 1 );
100
+ expect( data[ 0 ].label ).toBe( 'Jan' );
101
+ } );
102
+
47
103
  test( 'filters out entries with unparseable or missing dates', () => {
48
104
  const mixed: DataPointDate[] = [
49
105
  { dateString: '2024-01-01', value: 3 },