@automattic/charts 1.10.1 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/index.cjs +242 -224
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +29 -0
- package/dist/index.d.cts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +229 -199
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +23 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +78 -13
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +22 -3
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +6 -2
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +34 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +81 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +4 -0
- package/src/charts/heatmap-chart/types.ts +28 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +0 -6
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +0 -6
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +229 -18
- package/src/charts/private/with-responsive/with-responsive.module.scss +20 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +114 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.10.
|
|
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.
|
|
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.
|
|
110
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
111
111
|
"@visx/glyph": "4.0.0",
|
|
112
|
-
"@wordpress/components": "
|
|
113
|
-
"@wordpress/element": "8.
|
|
114
|
-
"@wordpress/private-apis": "1.
|
|
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": "
|
|
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
|
-
|
|
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
|
|
152
|
-
let
|
|
153
|
-
|
|
167
|
+
let stepCol = 0;
|
|
168
|
+
let stepRow = 0;
|
|
154
169
|
if ( event.key === 'ArrowRight' ) {
|
|
155
|
-
|
|
170
|
+
stepCol = 1;
|
|
156
171
|
} else if ( event.key === 'ArrowLeft' ) {
|
|
157
|
-
|
|
172
|
+
stepCol = -1;
|
|
158
173
|
} else if ( event.key === 'ArrowDown' ) {
|
|
159
|
-
|
|
174
|
+
stepRow = 1;
|
|
160
175
|
} else if ( event.key === 'ArrowUp' ) {
|
|
161
|
-
|
|
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
|
-
|
|
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 }, ${
|
|
251
|
-
gridTemplateRows: `auto repeat(${ rows }, ${
|
|
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: {
|
|
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
|
-
|
|
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)
|
|
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]`).
|
|
@@ -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 } /> );
|