@automattic/charts 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
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.6",
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);
@@ -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,6 +87,13 @@
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 {
@@ -46,6 +46,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
46
46
  className,
47
47
  compact = false,
48
48
  showValues,
49
+ maxCellWidth,
50
+ maxCellHeight,
51
+ minCellWidth,
52
+ minCellHeight,
49
53
  rowLabels = [],
50
54
  primaryColor,
51
55
  gap = 'md',
@@ -125,6 +129,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
125
129
  hideTooltip();
126
130
  }, [ hideTooltip ] );
127
131
 
132
+ const isCellHidden = useCallback(
133
+ ( col: number, row: number ) => data[ col ]?.data[ row ]?.hidden === true,
134
+ [ data ]
135
+ );
136
+
128
137
  const onChartKeyDown = useCallback(
129
138
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
130
139
  if (
@@ -144,26 +153,46 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
144
153
  event.preventDefault();
145
154
 
146
155
  if ( selectedIndex === undefined ) {
147
- 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
+ }
148
164
  return;
149
165
  }
150
166
 
151
- let col = Math.floor( selectedIndex / rows );
152
- let row = selectedIndex % rows;
153
-
167
+ let stepCol = 0;
168
+ let stepRow = 0;
154
169
  if ( event.key === 'ArrowRight' ) {
155
- col = Math.min( col + 1, columns - 1 );
170
+ stepCol = 1;
156
171
  } else if ( event.key === 'ArrowLeft' ) {
157
- col = Math.max( col - 1, 0 );
172
+ stepCol = -1;
158
173
  } else if ( event.key === 'ArrowDown' ) {
159
- row = Math.min( row + 1, rows - 1 );
174
+ stepRow = 1;
160
175
  } else if ( event.key === 'ArrowUp' ) {
161
- 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;
162
191
  }
163
192
 
164
193
  setSelectedIndex( col * rows + row );
165
194
  },
166
- [ rows, columns, selectedIndex, hideTooltip ]
195
+ [ rows, columns, selectedIndex, hideTooltip, isCellHidden ]
167
196
  );
168
197
 
169
198
  const handleCellMouseMove = useCallback(
@@ -243,12 +272,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
243
272
  );
244
273
  }
245
274
 
246
- 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' })`;
247
285
  const gridStyle: Record< string, string | number > = {
248
286
  '--heatmap-primary': primaryColorHex,
249
287
  '--heatmap-bg': theme.backgroundColor,
250
- gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`,
251
- gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`,
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,6 +335,7 @@ 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
  >
@@ -319,6 +366,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
319
366
  </span>
320
367
  { data.map( ( column, columnIndex ) => {
321
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
+
322
387
  const value = cell?.value ?? null;
323
388
  const present = isPresent( value );
324
389
  const normalized = present ? getNormalizedValue( value, extent ) : 0;
@@ -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,6 +63,11 @@ 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
  );
@@ -85,10 +100,14 @@ export const buildCalendarHeatmapData = (
85
100
  for ( let row = 0; row < 7; row++ ) {
86
101
  const day = addDays( gridStart, week * 7 + row );
87
102
  const key = format( day, 'yyyy-MM-dd' );
88
- cells.push( {
103
+ const cell: HeatmapCell = {
89
104
  label: format( day, 'EEE, MMM d, yyyy' ),
90
105
  value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
91
- } );
106
+ };
107
+ if ( hideOutOfRangeDays && ( key < minDayKey || key > maxDayKey ) ) {
108
+ cell.hidden = true;
109
+ }
110
+ cells.push( cell );
92
111
  }
93
112
  data.push( { label, data: cells } );
94
113
  }
@@ -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,6 +33,40 @@ 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
72
  { dateString: '2024-01-01', value: 1 },
@@ -213,6 +213,57 @@ describe( 'HeatmapChart', () => {
213
213
  expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
214
214
  } );
215
215
 
216
+ // Calendar ragged edges: hidden cells keep their grid slot but paint
217
+ // nothing and take no interaction.
218
+ const raggedData: HeatmapColumn[] = [
219
+ { label: 'W1', data: [ { value: null, hidden: true }, { value: 1 }, { value: 2 } ] },
220
+ { label: 'W2', data: [ { value: 3 }, { value: null, hidden: true }, { value: 4 } ] },
221
+ { label: 'W3', data: [ { value: 5 }, { value: 6 }, { value: null, hidden: true } ] },
222
+ ];
223
+
224
+ test( 'hidden cells render as empty slots outside the accessibility tree', () => {
225
+ renderChart( { data: raggedData } );
226
+ expect( screen.getAllByTestId( 'heatmap-cell' ) ).toHaveLength( 6 );
227
+ expect( screen.getAllByTestId( 'heatmap-cell-hidden' ) ).toHaveLength( 3 );
228
+ expect( screen.getAllByRole( 'gridcell' ) ).toHaveLength( 6 );
229
+ } );
230
+
231
+ test( 'keyboard navigation skips hidden cells and never lands on them', async () => {
232
+ renderChart( { data: raggedData, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
233
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
234
+ const user = userEvent.setup();
235
+
236
+ // The initial selection starts past the hidden leading slot (0,0).
237
+ grid.focus();
238
+ await user.keyboard( '{ArrowDown}' );
239
+ expect( grid ).toHaveAttribute(
240
+ 'aria-activedescendant',
241
+ expect.stringMatching( /-cell-0-1$/ )
242
+ );
243
+
244
+ // ArrowRight steps over the hidden (1,1) to (2,1).
245
+ await user.keyboard( '{ArrowRight}' );
246
+ expect( grid ).toHaveAttribute(
247
+ 'aria-activedescendant',
248
+ expect.stringMatching( /-cell-2-1$/ )
249
+ );
250
+
251
+ // Only a hidden slot (2,2) lies below — the selection stays put.
252
+ await user.keyboard( '{ArrowDown}' );
253
+ expect( grid ).toHaveAttribute(
254
+ 'aria-activedescendant',
255
+ expect.stringMatching( /-cell-2-1$/ )
256
+ );
257
+
258
+ // ArrowLeft steps back over the hidden (1,1) to (0,1); ArrowUp has
259
+ // only the hidden (0,0) above, so the selection stays again.
260
+ await user.keyboard( '{ArrowLeft}{ArrowUp}' );
261
+ expect( grid ).toHaveAttribute(
262
+ 'aria-activedescendant',
263
+ expect.stringMatching( /-cell-0-1$/ )
264
+ );
265
+ } );
266
+
216
267
  test( 'rows contain gridcell children in the ARIA hierarchy', () => {
217
268
  renderChart();
218
269
  const rows = screen.getAllByRole( 'row' );
@@ -259,6 +310,36 @@ describe( 'HeatmapChart', () => {
259
310
  expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' );
260
311
  } );
261
312
 
313
+ test( 'caps cell width without changing the normal vertical layout', () => {
314
+ renderChart( { maxCellWidth: 64 } );
315
+ const chart = screen.getByTestId( 'heatmap-chart' );
316
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
317
+
318
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(0px, 64px)' );
319
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(0px, 1fr)' );
320
+ expect( chart ).not.toHaveClass( 'heatmap-chart--height-capped' );
321
+ expect( grid ).not.toHaveClass( 'heatmap-chart__grid--height-capped' );
322
+ } );
323
+
324
+ test( 'content-sizes the vertical layout when cell height is capped', () => {
325
+ renderChart( { maxCellHeight: 42 } );
326
+ const chart = screen.getByTestId( 'heatmap-chart' );
327
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
328
+
329
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(0px, 1fr)' );
330
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(0px, 42px)' );
331
+ expect( chart ).toHaveClass( 'heatmap-chart--height-capped' );
332
+ expect( grid ).toHaveClass( 'heatmap-chart__grid--height-capped' );
333
+ } );
334
+
335
+ test( 'applies independent minimum cell width and height floors', () => {
336
+ renderChart( { minCellWidth: 44, minCellHeight: 32 } );
337
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
338
+
339
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(44px, 1fr)' );
340
+ expect( grid.style.gridTemplateRows ).toContain( 'minmax(32px, 1fr)' );
341
+ } );
342
+
262
343
  test( 'applies the primaryColor prop as the cell-scale color', () => {
263
344
  renderChart( { primaryColor: '#abcdef' } );
264
345
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -31,4 +31,8 @@ describe( 'getNormalizedValue', () => {
31
31
  test( 'returns 1 when min === max', () => {
32
32
  expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
33
33
  } );
34
+
35
+ test( 'returns 0 for an all-zero extent so a no-activity grid stays at the scale bottom', () => {
36
+ expect( getNormalizedValue( 0, [ 0, 0 ] ) ).toBe( 0 );
37
+ } );
34
38
  } );
@@ -6,6 +6,13 @@ export type HeatmapCell = {
6
6
  /** Per-cell label used in the tooltip / accessible name. */
7
7
  label?: string;
8
8
  value: number | null;
9
+ /**
10
+ * Leave the cell's grid slot empty: nothing is painted and the cell is
11
+ * skipped by hover and keyboard navigation, while the slot keeps its
12
+ * place so the rest of the grid doesn't shift. For calendar edges, where
13
+ * days completing the first/last week fall outside the covered range.
14
+ */
15
+ hidden?: boolean;
9
16
  };
10
17
 
11
18
  /** A heatmap column (rendered left→right); its cells render top→bottom. */
@@ -32,6 +39,27 @@ export interface HeatmapChartProps
32
39
  compact?: boolean;
33
40
  /** Render the numeric value inside each cell. Default `! compact`. */
34
41
  showValues?: boolean;
42
+ /**
43
+ * Cap a cell's width (px) in non-compact mode. Cells grow up to the cap
44
+ * and stop instead of splitting the whole container width, so sparse
45
+ * ranges don't produce oversized cells; narrow containers still shrink
46
+ * them. Ignored in compact mode, which uses a fixed cell size.
47
+ */
48
+ maxCellWidth?: number;
49
+ /**
50
+ * Cap a cell's height (px) in non-compact mode. Applying this cap
51
+ * content-sizes the chart vertically so rows do not absorb unused height.
52
+ * Ignored in compact mode, which uses a fixed cell size.
53
+ */
54
+ maxCellHeight?: number;
55
+ /**
56
+ * Floor a cell's width (px) in non-compact mode. Below it the grid stops
57
+ * shrinking and overflows its container instead, so a scrollable wrapper
58
+ * can take over for long ranges. Default 0 (cells shrink freely).
59
+ */
60
+ minCellWidth?: number;
61
+ /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
62
+ minCellHeight?: number;
35
63
  /**
36
64
  * Color the cell scale interpolates toward at the highest value
37
65
  * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
@@ -100,23 +100,24 @@
100
100
  font-style: italic;
101
101
  }
102
102
 
103
- .interactiveRow {
103
+ .row {
104
104
  --focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
105
105
 
106
106
  display: grid;
107
107
  grid-column: 1 / -1;
108
108
  grid-template-columns: subgrid;
109
109
  align-items: center;
110
+ // Every row reserves the same space for the interactive focus ring. Keeping
111
+ // this on the shared wrapper ensures that adding or removing an action never
112
+ // changes the row height or subgrid column edges.
113
+ padding: var(--focus-ring-width);
114
+ }
115
+
116
+ .interactiveRow {
110
117
  position: relative;
111
118
 
112
119
  // Native button reset.
113
- box-sizing: border-box;
114
120
  appearance: none;
115
- width: 100%;
116
- margin: 0;
117
- // Reserve a ring's worth of space on every edge so the inset focus outline
118
- // hugs the row without the bar painting over it (see :focus-visible below).
119
- padding: var(--focus-ring-width);
120
121
  border: 0;
121
122
  background: none;
122
123
  color: inherit;
@@ -179,9 +180,8 @@
179
180
  var(--focus-ring-width) solid
180
181
  var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
181
182
  // Inset by its own width so the outer edge hugs the row boundary: the
182
- // outline then sits in the row's padding (also a ring width), clear of the
183
- // bar, and stays inside the overflow:auto container that would clip an
184
- // outset ring.
183
+ // outline then sits in the shared row padding, clear of the bar, and stays
184
+ // inside the overflow:auto container that would clip an outset ring.
185
185
  outline-offset: calc(-1 * var(--focus-ring-width));
186
186
  }
187
187
  }
@@ -1,6 +1,5 @@
1
1
  /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
2
  import { __experimentalGrid as Grid, VisuallyHidden } from '@wordpress/components';
3
- import { Fragment } from '@wordpress/element';
4
3
  import { __ } from '@wordpress/i18n';
5
4
  import { Icon, chevronRight } from '@wordpress/icons';
6
5
  import { Stack, Text } from '@wordpress/ui';
@@ -404,7 +403,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
404
403
  <button
405
404
  key={ entry.id }
406
405
  type="button"
407
- className={ styles.interactiveRow }
406
+ className={ clsx( styles.row, styles.interactiveRow ) }
408
407
  onClick={ entry.onClick }
409
408
  aria-label={ entry.ariaLabel }
410
409
  >
@@ -414,7 +413,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
414
413
  );
415
414
  }
416
415
 
417
- return <Fragment key={ entry.id }>{ rowCells }</Fragment>;
416
+ return (
417
+ <div key={ entry.id } className={ styles.row }>
418
+ { rowCells }
419
+ </div>
420
+ );
418
421
  } ) }
419
422
  </Grid>
420
423
  ) }
@@ -372,12 +372,6 @@ describe( 'LeaderboardChart', () => {
372
372
  } );
373
373
 
374
374
  describe( 'Responsive wrapper', () => {
375
- it( 'fills parent container (height:100%) by default', () => {
376
- render( <LeaderboardChart data={ mockData } /> );
377
- const wrapper = screen.getByTestId( 'responsive-wrapper' );
378
- expect( wrapper ).toHaveStyle( { height: '100%' } );
379
- } );
380
-
381
375
  it( 'applies explicit width and height to chart container', () => {
382
376
  const { useParentSize } = jest.requireMock( '@visx/responsive' );
383
377
  useParentSize.mockReturnValue( {
@@ -226,12 +226,6 @@ describe( 'PieSemiCircleChart', () => {
226
226
  } );
227
227
 
228
228
  describe( 'Responsive wrapper', () => {
229
- it( 'fills parent container (height:100%) by default', () => {
230
- render( <PieSemiCircleChart data={ mockData } /> );
231
- const wrapper = screen.getByTestId( 'responsive-wrapper' );
232
- expect( wrapper ).toHaveStyle( { height: '100%' } );
233
- } );
234
-
235
229
  it( 'constrains chart to 2:1 ratio from measured dimensions', () => {
236
230
  // Mock returns width:400, height:200, so chart renders at 400×200 (2:1 ratio)
237
231
  render( <PieSemiCircleChart data={ mockData } /> );