@automattic/charts 4.1.0 → 4.2.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.
- package/CHANGELOG.md +28 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +1115 -471
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +39 -1
- package/dist/index.d.cts +116 -16
- package/dist/index.d.ts +116 -16
- package/dist/index.js +1115 -473
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/bar-chart/bar-chart.tsx +67 -17
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +15 -45
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +15 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -2
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +37 -1
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +88 -3
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +3 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
- package/src/charts/line-chart/types.ts +2 -2
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/private/base-legend.tsx +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +146 -37
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +160 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +29 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -11
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +36 -2
- package/src/types.ts +6 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
|
@@ -9,11 +9,21 @@ import type { CSSProperties, FC } from 'react';
|
|
|
9
9
|
export interface HeatmapLegendProps {
|
|
10
10
|
/** Number of swatches in the scale. Default 5. */
|
|
11
11
|
steps?: number;
|
|
12
|
+
/**
|
|
13
|
+
* `swatches` spaces the steps out as cell-sized squares; `bar` joins them
|
|
14
|
+
* into one continuous band with rounded ends. Default `swatches`.
|
|
15
|
+
*/
|
|
16
|
+
variant?: 'swatches' | 'bar';
|
|
12
17
|
lessLabel?: string;
|
|
13
18
|
moreLabel?: string;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
21
|
+
export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
|
|
22
|
+
steps = 5,
|
|
23
|
+
variant = 'swatches',
|
|
24
|
+
lessLabel,
|
|
25
|
+
moreLabel,
|
|
26
|
+
} ) => {
|
|
17
27
|
const context = useContext( HeatmapContext );
|
|
18
28
|
const { legend } = useGlobalChartsTheme();
|
|
19
29
|
if ( ! context ) {
|
|
@@ -31,13 +41,19 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
|
|
|
31
41
|
>
|
|
32
42
|
{ lessLabel ?? __( 'Less', 'jetpack-charts' ) }
|
|
33
43
|
</Text>
|
|
34
|
-
<Stack
|
|
44
|
+
<Stack
|
|
45
|
+
direction="row"
|
|
46
|
+
gap={ variant === 'bar' ? undefined : 'xs' }
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
data-testid="heatmap-legend-scale"
|
|
49
|
+
className={ variant === 'bar' ? styles[ 'heatmap-chart__legend-scale--bar' ] : undefined }
|
|
50
|
+
>
|
|
35
51
|
{ Array.from( { length: steps }, ( _, index ) => {
|
|
36
52
|
const intensity = steps <= 1 ? 1 : index / ( steps - 1 );
|
|
37
53
|
return (
|
|
38
54
|
<span
|
|
39
55
|
key={ index }
|
|
40
|
-
|
|
56
|
+
data-testid="heatmap-legend-swatch"
|
|
41
57
|
className={ styles[ 'heatmap-chart__legend-swatch' ] }
|
|
42
58
|
style={
|
|
43
59
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
|
|
2
|
-
export { buildCalendarHeatmapData } from './build-calendar-data';
|
|
3
2
|
export { HeatmapLegend } from './heatmap-legend';
|
|
4
3
|
export { HeatmapContext } from './heatmap-context';
|
|
5
|
-
export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './build-calendar-data';
|
|
6
4
|
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
7
5
|
export type { HeatmapContextValue } from './heatmap-context';
|
|
6
|
+
export { resolveColumnGroups } from './column-groups';
|
|
7
|
+
export type { ColumnGroupLayout } from './column-groups';
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
export type CellPosition = { column: number; row: number };
|
|
2
|
+
|
|
3
|
+
export type NavigationKey =
|
|
4
|
+
'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'PageUp' | 'PageDown';
|
|
5
|
+
|
|
6
|
+
/** A run of columns read as one page in calendar navigation; `end` is exclusive. */
|
|
7
|
+
export type CellBlock = { start: number; end: number };
|
|
8
|
+
|
|
9
|
+
export type NavigableGrid = {
|
|
10
|
+
columns: number;
|
|
11
|
+
rows: number;
|
|
12
|
+
/** Whether the slot holds nothing to select (hidden or placeholder). */
|
|
13
|
+
isInert: ( column: number, row: number ) => boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ARROW_KEYS = [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown' ];
|
|
17
|
+
const PAGE_KEYS = [ 'PageUp', 'PageDown' ];
|
|
18
|
+
|
|
19
|
+
export const isNavigationKey = ( key: string, mode: 'grid' | 'calendar' ): key is NavigationKey =>
|
|
20
|
+
ARROW_KEYS.includes( key ) || ( mode === 'calendar' && PAGE_KEYS.includes( key ) );
|
|
21
|
+
|
|
22
|
+
const inGrid = ( grid: NavigableGrid, { column, row }: CellPosition ) =>
|
|
23
|
+
column >= 0 && column < grid.columns && row >= 0 && row < grid.rows;
|
|
24
|
+
|
|
25
|
+
const firstSelectable = ( grid: NavigableGrid, cells: CellPosition[] ) =>
|
|
26
|
+
cells.find( cell => inGrid( grid, cell ) && ! grid.isInert( cell.column, cell.row ) );
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Column-major scan for the first selectable cell.
|
|
30
|
+
*
|
|
31
|
+
* @param grid - The grid.
|
|
32
|
+
* @return The cell, or undefined when every slot is inert.
|
|
33
|
+
*/
|
|
34
|
+
export const firstGridCell = ( grid: NavigableGrid ): CellPosition | undefined => {
|
|
35
|
+
for ( let column = 0; column < grid.columns; column++ ) {
|
|
36
|
+
for ( let row = 0; row < grid.rows; row++ ) {
|
|
37
|
+
if ( ! grid.isInert( column, row ) ) {
|
|
38
|
+
return { column, row };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Steps across the grid in the key's direction, past inert slots, staying put at the edge.
|
|
47
|
+
*
|
|
48
|
+
* @param grid - The grid.
|
|
49
|
+
* @param from - The selected cell.
|
|
50
|
+
* @param key - The arrow key.
|
|
51
|
+
* @return The next cell, or undefined to keep the selection.
|
|
52
|
+
*/
|
|
53
|
+
export const stepGridCell = (
|
|
54
|
+
grid: NavigableGrid,
|
|
55
|
+
from: CellPosition,
|
|
56
|
+
key: NavigationKey
|
|
57
|
+
): CellPosition | undefined => {
|
|
58
|
+
const steps: Partial< Record< NavigationKey, [ number, number ] > > = {
|
|
59
|
+
ArrowRight: [ 1, 0 ],
|
|
60
|
+
ArrowLeft: [ -1, 0 ],
|
|
61
|
+
ArrowDown: [ 0, 1 ],
|
|
62
|
+
ArrowUp: [ 0, -1 ],
|
|
63
|
+
};
|
|
64
|
+
const step = steps[ key ];
|
|
65
|
+
if ( ! step ) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const [ stepColumn, stepRow ] = step;
|
|
69
|
+
|
|
70
|
+
let cell = from;
|
|
71
|
+
do {
|
|
72
|
+
cell = { column: cell.column + stepColumn, row: cell.row + stepRow };
|
|
73
|
+
} while ( inGrid( grid, cell ) && grid.isInert( cell.column, cell.row ) );
|
|
74
|
+
|
|
75
|
+
return inGrid( grid, cell ) ? cell : undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every slot of the blocks in reading order: block by block, each a row at a time.
|
|
80
|
+
*
|
|
81
|
+
* @param grid - The grid.
|
|
82
|
+
* @param blocks - The blocks, left to right.
|
|
83
|
+
* @return The slots in order.
|
|
84
|
+
*/
|
|
85
|
+
const readingOrder = ( grid: NavigableGrid, blocks: CellBlock[] ): CellPosition[] =>
|
|
86
|
+
blocks.flatMap( block =>
|
|
87
|
+
Array.from( { length: grid.rows }, ( _row, row ) =>
|
|
88
|
+
Array.from( { length: block.end - block.start }, ( _offset, offset ) => ( {
|
|
89
|
+
column: block.start + offset,
|
|
90
|
+
row,
|
|
91
|
+
} ) )
|
|
92
|
+
).flat()
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const blockOf = ( blocks: CellBlock[], column: number ) =>
|
|
96
|
+
blocks.findIndex( block => column >= block.start && column < block.end );
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The slots after a cell down its column (or before it, going up), continuing at the
|
|
100
|
+
* same column offset of each following block.
|
|
101
|
+
*
|
|
102
|
+
* @param grid - The grid.
|
|
103
|
+
* @param blocks - The blocks, left to right.
|
|
104
|
+
* @param from - The selected cell.
|
|
105
|
+
* @param direction - 1 downwards, -1 upwards.
|
|
106
|
+
* @return The slots in order.
|
|
107
|
+
*/
|
|
108
|
+
const columnOrderFrom = (
|
|
109
|
+
grid: NavigableGrid,
|
|
110
|
+
blocks: CellBlock[],
|
|
111
|
+
from: CellPosition,
|
|
112
|
+
direction: 1 | -1
|
|
113
|
+
): CellPosition[] => {
|
|
114
|
+
const blockIndex = blockOf( blocks, from.column );
|
|
115
|
+
if ( blockIndex < 0 ) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const offset = from.column - blocks[ blockIndex ].start;
|
|
119
|
+
const cells: CellPosition[] = [];
|
|
120
|
+
const pushRows = ( column: number, startRow: number ) => {
|
|
121
|
+
for ( let row = startRow; row >= 0 && row < grid.rows; row += direction ) {
|
|
122
|
+
cells.push( { column, row } );
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
pushRows( from.column, from.row + direction );
|
|
127
|
+
for (
|
|
128
|
+
let index = blockIndex + direction;
|
|
129
|
+
index >= 0 && index < blocks.length;
|
|
130
|
+
index += direction
|
|
131
|
+
) {
|
|
132
|
+
const column = blocks[ index ].start + offset;
|
|
133
|
+
if ( column < blocks[ index ].end ) {
|
|
134
|
+
pushRows( column, direction === 1 ? 0 : grid.rows - 1 );
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return cells;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The first selectable cell in calendar reading order.
|
|
142
|
+
*
|
|
143
|
+
* @param grid - The grid.
|
|
144
|
+
* @param blocks - The blocks, left to right.
|
|
145
|
+
* @return The cell, or undefined when every slot is inert.
|
|
146
|
+
*/
|
|
147
|
+
export const firstCalendarCell = ( grid: NavigableGrid, blocks: CellBlock[] ) =>
|
|
148
|
+
firstSelectable( grid, readingOrder( grid, blocks ) );
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Steps through the blocks as pages of a calendar: Left/Right by one slot in reading
|
|
152
|
+
* order, Up/Down by one row (into the neighboring block past the edge), Page Up/Down
|
|
153
|
+
* to the same slot of the neighboring block or its nearest selectable cell.
|
|
154
|
+
*
|
|
155
|
+
* @param grid - The grid.
|
|
156
|
+
* @param blocks - The blocks, left to right.
|
|
157
|
+
* @param from - The selected cell.
|
|
158
|
+
* @param key - The navigation key.
|
|
159
|
+
* @return The next cell, or undefined to keep the selection.
|
|
160
|
+
*/
|
|
161
|
+
export const stepCalendarCell = (
|
|
162
|
+
grid: NavigableGrid,
|
|
163
|
+
blocks: CellBlock[],
|
|
164
|
+
from: CellPosition,
|
|
165
|
+
key: NavigationKey
|
|
166
|
+
): CellPosition | undefined => {
|
|
167
|
+
if ( key === 'ArrowRight' || key === 'ArrowLeft' ) {
|
|
168
|
+
const order = readingOrder( grid, blocks );
|
|
169
|
+
const index = order.findIndex( cell => cell.column === from.column && cell.row === from.row );
|
|
170
|
+
if ( index < 0 ) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const ahead =
|
|
174
|
+
key === 'ArrowRight' ? order.slice( index + 1 ) : order.slice( 0, index ).reverse();
|
|
175
|
+
return firstSelectable( grid, ahead );
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if ( key === 'ArrowDown' || key === 'ArrowUp' ) {
|
|
179
|
+
return firstSelectable(
|
|
180
|
+
grid,
|
|
181
|
+
columnOrderFrom( grid, blocks, from, key === 'ArrowDown' ? 1 : -1 )
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const blockIndex = blockOf( blocks, from.column );
|
|
186
|
+
const target = blocks[ blockIndex + ( key === 'PageDown' ? 1 : -1 ) ];
|
|
187
|
+
if ( blockIndex < 0 || ! target ) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
const same = {
|
|
191
|
+
column: target.start + ( from.column - blocks[ blockIndex ].start ),
|
|
192
|
+
row: from.row,
|
|
193
|
+
};
|
|
194
|
+
const page = readingOrder( grid, [ target ] );
|
|
195
|
+
const at = page.findIndex(
|
|
196
|
+
cell => cell.row > same.row || ( cell.row === same.row && cell.column >= same.column )
|
|
197
|
+
);
|
|
198
|
+
const after = at === -1 ? [] : page.slice( at );
|
|
199
|
+
const before = page.slice( 0, at === -1 ? page.length : at ).reverse();
|
|
200
|
+
return firstSelectable( grid, after ) ?? firstSelectable( grid, before );
|
|
201
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildCalendarHeatmapData } from '../
|
|
1
|
+
import { buildCalendarHeatmapData } from '../build-calendar-data';
|
|
2
2
|
import type { DataPointDate } from '../../../types';
|
|
3
3
|
|
|
4
4
|
const series: DataPointDate[] = [
|
|
@@ -269,4 +269,40 @@ describe( 'buildCalendarHeatmapData with a grid wider than the series', () => {
|
|
|
269
269
|
expect( data[ 0 ].data[ 0 ].hidden ).toBe( true );
|
|
270
270
|
expect( data[ 0 ].data[ 0 ].placeholder ).toBeUndefined();
|
|
271
271
|
} );
|
|
272
|
+
|
|
273
|
+
test( 'labels Gregorian months under a locale whose default calendar is not Gregorian', () => {
|
|
274
|
+
// fa-IR defaults to the Persian calendar, under which Sep 1 and Sep 30 fall in different months.
|
|
275
|
+
const september: DataPointDate[] = [
|
|
276
|
+
{ dateString: '2026-09-01', value: 1 },
|
|
277
|
+
{ dateString: '2026-09-30', value: 1 },
|
|
278
|
+
];
|
|
279
|
+
const { data } = buildCalendarHeatmapData( september, { weekStartsOn: 1, locale: 'fa-IR' } );
|
|
280
|
+
|
|
281
|
+
const gregorianMonth = new Intl.DateTimeFormat( 'fa-IR', {
|
|
282
|
+
month: 'short',
|
|
283
|
+
calendar: 'gregory',
|
|
284
|
+
timeZone: 'UTC',
|
|
285
|
+
} );
|
|
286
|
+
const gregorianDay = new Intl.DateTimeFormat( 'fa-IR', {
|
|
287
|
+
weekday: 'short',
|
|
288
|
+
month: 'short',
|
|
289
|
+
day: 'numeric',
|
|
290
|
+
year: 'numeric',
|
|
291
|
+
calendar: 'gregory',
|
|
292
|
+
timeZone: 'UTC',
|
|
293
|
+
} );
|
|
294
|
+
|
|
295
|
+
// The grid opens on Mon Aug 31, whose one-week month goes unlabeled, so the
|
|
296
|
+
// month label sits on the first September-starting column.
|
|
297
|
+
expect( data.map( column => column.label ).filter( Boolean ) ).toEqual( [
|
|
298
|
+
gregorianMonth.format( new Date( '2026-09-01T00:00:00Z' ) ),
|
|
299
|
+
] );
|
|
300
|
+
// Tue Sep 1 is column 0 row 1; Wed Sep 30 is column 4 row 2.
|
|
301
|
+
expect( data[ 0 ].data[ 1 ].label ).toBe(
|
|
302
|
+
gregorianDay.format( new Date( '2026-09-01T00:00:00Z' ) )
|
|
303
|
+
);
|
|
304
|
+
expect( data[ 4 ].data[ 2 ].label ).toBe(
|
|
305
|
+
gregorianDay.format( new Date( '2026-09-30T00:00:00Z' ) )
|
|
306
|
+
);
|
|
307
|
+
} );
|
|
272
308
|
} );
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { buildMonthCalendarHeatmapData } from '../build-month-calendar-data';
|
|
2
|
+
|
|
3
|
+
const range = { start: '2026-08-01', end: '2026-09-14' };
|
|
4
|
+
const cellAt = (
|
|
5
|
+
result: ReturnType< typeof buildMonthCalendarHeatmapData >,
|
|
6
|
+
month: number,
|
|
7
|
+
weekday: number,
|
|
8
|
+
week: number
|
|
9
|
+
) => result.data[ month * 7 + weekday ].data[ week ];
|
|
10
|
+
|
|
11
|
+
describe( 'buildMonthCalendarHeatmapData', () => {
|
|
12
|
+
test( 'draws one 7 × 6 block per month between the range ends', () => {
|
|
13
|
+
const { data, columnGroups } = buildMonthCalendarHeatmapData( {}, range, { locale: 'en-US' } );
|
|
14
|
+
expect( data ).toHaveLength( 14 );
|
|
15
|
+
data.forEach( column => {
|
|
16
|
+
expect( column.data ).toHaveLength( 6 );
|
|
17
|
+
expect( column.label ).toBeUndefined();
|
|
18
|
+
} );
|
|
19
|
+
expect( columnGroups ).toEqual( [
|
|
20
|
+
{ label: 'Aug', span: 7 },
|
|
21
|
+
{ label: 'Sep', span: 7 },
|
|
22
|
+
] );
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
test( 'orders weekdays from Monday by default and from Sunday on request', () => {
|
|
26
|
+
// Aug 1 2026 is a Saturday.
|
|
27
|
+
const monday = buildMonthCalendarHeatmapData( { '2026-08-01': 1 }, range );
|
|
28
|
+
expect( cellAt( monday, 0, 5, 0 ).value ).toBe( 1 );
|
|
29
|
+
expect( cellAt( monday, 0, 0, 0 ).hidden ).toBe( true );
|
|
30
|
+
|
|
31
|
+
const sunday = buildMonthCalendarHeatmapData( { '2026-08-01': 1 }, range, { weekStartsOn: 0 } );
|
|
32
|
+
expect( cellAt( sunday, 0, 6, 0 ).value ).toBe( 1 );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
test( 'hides days outside the month and the rows past its last week', () => {
|
|
36
|
+
const result = buildMonthCalendarHeatmapData( {}, range );
|
|
37
|
+
// Aug 2026 runs Sat 1 → Mon 31 and needs all six rows from Monday Jul 27.
|
|
38
|
+
expect( cellAt( result, 0, 4, 0 ).hidden ).toBe( true ); // Fri Jul 31
|
|
39
|
+
expect( cellAt( result, 0, 0, 5 ).hidden ).toBeUndefined(); // Mon Aug 31
|
|
40
|
+
expect( cellAt( result, 0, 1, 5 ).hidden ).toBe( true ); // Tue Sep 1
|
|
41
|
+
// Sep 2026 fits in five rows from Monday Aug 31, so its sixth row is overflow.
|
|
42
|
+
expect( cellAt( result, 1, 0, 0 ).hidden ).toBe( true ); // Mon Aug 31
|
|
43
|
+
expect( cellAt( result, 1, 2, 4 ).hidden ).toBeUndefined(); // Wed Sep 30
|
|
44
|
+
expect( cellAt( result, 1, 0, 5 ).hidden ).toBe( true ); // Mon Oct 5
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
test( 'fades days inside a drawn month but outside the range as placeholders', () => {
|
|
48
|
+
const result = buildMonthCalendarHeatmapData( {}, range );
|
|
49
|
+
// Sep 14 2026 is a Monday: row 2 col 0. Tue Sep 15 is the first day past the range.
|
|
50
|
+
expect( cellAt( result, 1, 0, 2 ).placeholder ).toBeUndefined();
|
|
51
|
+
expect( cellAt( result, 1, 1, 2 ).placeholder ).toBe( true );
|
|
52
|
+
expect( cellAt( result, 1, 1, 2 ).hidden ).toBeUndefined();
|
|
53
|
+
expect( cellAt( result, 1, 2, 4 ).placeholder ).toBe( true ); // Wed Sep 30
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
test( 'fades days before a mid-month start', () => {
|
|
57
|
+
const result = buildMonthCalendarHeatmapData( {}, { start: '2026-08-10', end: '2026-08-20' } );
|
|
58
|
+
expect( cellAt( result, 0, 5, 0 ).placeholder ).toBe( true ); // Sat Aug 1
|
|
59
|
+
expect( cellAt( result, 0, 0, 1 ).placeholder ).toBe( true ); // Mon Aug 3
|
|
60
|
+
expect( cellAt( result, 0, 0, 2 ).placeholder ).toBeUndefined(); // Mon Aug 10
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
test( 'reads values by day and leaves missing days null', () => {
|
|
64
|
+
const result = buildMonthCalendarHeatmapData(
|
|
65
|
+
{ '2026-08-03': 4, '2026-08-04': NaN, '2026-08-05': null },
|
|
66
|
+
range
|
|
67
|
+
);
|
|
68
|
+
expect( cellAt( result, 0, 0, 1 ).value ).toBe( 4 );
|
|
69
|
+
expect( cellAt( result, 0, 1, 1 ).value ).toBeNull();
|
|
70
|
+
expect( cellAt( result, 0, 2, 1 ).value ).toBeNull();
|
|
71
|
+
expect( cellAt( result, 0, 3, 1 ).value ).toBeNull();
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
test( 'labels every day with its full date in the locale', () => {
|
|
75
|
+
// Read from Intl rather than spelled out: short month names drift between ICU builds.
|
|
76
|
+
const monthLabels = ( locale: string ) => {
|
|
77
|
+
const format = new Intl.DateTimeFormat( locale, {
|
|
78
|
+
month: 'short',
|
|
79
|
+
calendar: 'gregory',
|
|
80
|
+
timeZone: 'UTC',
|
|
81
|
+
} );
|
|
82
|
+
return [ '2026-08-01', '2026-09-01' ].map( day =>
|
|
83
|
+
format.format( new Date( `${ day }T00:00:00Z` ) )
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
const result = buildMonthCalendarHeatmapData( {}, range, { locale: 'en-US' } );
|
|
87
|
+
expect( cellAt( result, 0, 0, 1 ).label ).toBe( 'Mon, Aug 3, 2026' );
|
|
88
|
+
for ( const locale of [ 'fr-FR', 'ja-JP' ] ) {
|
|
89
|
+
const { columnGroups } = buildMonthCalendarHeatmapData( {}, range, { locale } );
|
|
90
|
+
expect( columnGroups.map( group => group.label ) ).toEqual( monthLabels( locale ) );
|
|
91
|
+
}
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
test( 'labels Gregorian months under a locale whose default calendar is not Gregorian', () => {
|
|
95
|
+
const result = buildMonthCalendarHeatmapData(
|
|
96
|
+
{},
|
|
97
|
+
{ start: '2026-09-01', end: '2026-09-30' },
|
|
98
|
+
{ locale: 'fa-IR' }
|
|
99
|
+
);
|
|
100
|
+
const gregorian = new Intl.DateTimeFormat( 'fa-IR', {
|
|
101
|
+
month: 'short',
|
|
102
|
+
calendar: 'gregory',
|
|
103
|
+
timeZone: 'UTC',
|
|
104
|
+
} );
|
|
105
|
+
expect( result.columnGroups ).toEqual( [
|
|
106
|
+
{ label: gregorian.format( new Date( '2026-09-01T00:00:00Z' ) ), span: 7 },
|
|
107
|
+
] );
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
test( 'handles a leap February and a range inside one month', () => {
|
|
111
|
+
const leap = buildMonthCalendarHeatmapData( {}, { start: '2028-02-01', end: '2028-02-29' } );
|
|
112
|
+
expect( leap.data ).toHaveLength( 7 );
|
|
113
|
+
// Feb 29 2028 is a Tuesday in the fifth week row.
|
|
114
|
+
expect( cellAt( leap, 0, 1, 4 ).hidden ).toBeUndefined();
|
|
115
|
+
expect( cellAt( leap, 0, 2, 4 ).hidden ).toBe( true );
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
test( 'spans a year boundary', () => {
|
|
119
|
+
const { columnGroups } = buildMonthCalendarHeatmapData(
|
|
120
|
+
{},
|
|
121
|
+
{ start: '2025-10-01', end: '2026-09-14' },
|
|
122
|
+
{ locale: 'en-US' }
|
|
123
|
+
);
|
|
124
|
+
expect( columnGroups.map( group => group.label ) ).toEqual( [
|
|
125
|
+
'Oct',
|
|
126
|
+
'Nov',
|
|
127
|
+
'Dec',
|
|
128
|
+
'Jan',
|
|
129
|
+
'Feb',
|
|
130
|
+
'Mar',
|
|
131
|
+
'Apr',
|
|
132
|
+
'May',
|
|
133
|
+
'Jun',
|
|
134
|
+
'Jul',
|
|
135
|
+
'Aug',
|
|
136
|
+
'Sep',
|
|
137
|
+
] );
|
|
138
|
+
} );
|
|
139
|
+
|
|
140
|
+
test( 'adds the year to the month labels once a range passes twelve months', () => {
|
|
141
|
+
const thirteen = buildMonthCalendarHeatmapData(
|
|
142
|
+
{},
|
|
143
|
+
{ start: '2025-09-15', end: '2026-09-14' },
|
|
144
|
+
{ locale: 'en-US' }
|
|
145
|
+
);
|
|
146
|
+
const labels = thirteen.columnGroups.map( group => group.label );
|
|
147
|
+
expect( labels ).toHaveLength( 13 );
|
|
148
|
+
expect( labels[ 0 ] ).toBe( 'Sep 2025' );
|
|
149
|
+
expect( labels[ 12 ] ).toBe( 'Sep 2026' );
|
|
150
|
+
|
|
151
|
+
const twelve = buildMonthCalendarHeatmapData(
|
|
152
|
+
{},
|
|
153
|
+
{ start: '2025-10-01', end: '2026-09-14' },
|
|
154
|
+
{ locale: 'en-US' }
|
|
155
|
+
);
|
|
156
|
+
expect( twelve.columnGroups[ 0 ].label ).toBe( 'Oct' );
|
|
157
|
+
} );
|
|
158
|
+
|
|
159
|
+
test.each( [
|
|
160
|
+
[ '2026-08-29', 5, 4 ], // Sat
|
|
161
|
+
[ '2026-08-30', 6, 4 ], // Sun
|
|
162
|
+
[ '2026-08-31', 0, 5 ], // Mon
|
|
163
|
+
] )( 'measures from a start on %s and fades the month before it', ( start, weekday, week ) => {
|
|
164
|
+
const result = buildMonthCalendarHeatmapData( { [ start ]: 2 }, { start, end: '2026-09-14' } );
|
|
165
|
+
expect( cellAt( result, 0, weekday, week ) ).toMatchObject( { value: 2 } );
|
|
166
|
+
expect( cellAt( result, 0, weekday, week ).placeholder ).toBeUndefined();
|
|
167
|
+
expect( cellAt( result, 0, 0, 1 ).placeholder ).toBe( true ); // Mon Aug 3
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
test( 'keeps a placeholder empty even when the map holds a value for that day', () => {
|
|
171
|
+
const result = buildMonthCalendarHeatmapData( { '2026-09-20': 7 }, range );
|
|
172
|
+
// Sun Sep 20 2026 sits past the range end in row 2.
|
|
173
|
+
expect( cellAt( result, 1, 6, 2 ) ).toMatchObject( { value: null, placeholder: true } );
|
|
174
|
+
} );
|
|
175
|
+
|
|
176
|
+
test.each( [
|
|
177
|
+
[ 'an unparseable start', { start: '2026-8-1', end: '2026-09-14' } ],
|
|
178
|
+
[ 'an unparseable end', { start: '2026-08-01', end: 'today' } ],
|
|
179
|
+
[ 'an impossible day', { start: '2026-02-30', end: '2026-03-01' } ],
|
|
180
|
+
[ 'a reversed range', { start: '2026-09-14', end: '2026-08-01' } ],
|
|
181
|
+
] )( 'returns an empty grid and warns once on %s', ( _, badRange ) => {
|
|
182
|
+
expect( buildMonthCalendarHeatmapData( {}, badRange ) ).toEqual( {
|
|
183
|
+
data: [],
|
|
184
|
+
columnGroups: [],
|
|
185
|
+
} );
|
|
186
|
+
expect( console ).toHaveWarned();
|
|
187
|
+
} );
|
|
188
|
+
|
|
189
|
+
test( 'skips keys that are not yyyy-MM-dd with one warning', () => {
|
|
190
|
+
const result = buildMonthCalendarHeatmapData( { 'Aug 3 2026': 9, '2026-08-03': 4 }, range );
|
|
191
|
+
expect( cellAt( result, 0, 0, 1 ).value ).toBe( 4 );
|
|
192
|
+
expect( console ).toHaveWarned();
|
|
193
|
+
} );
|
|
194
|
+
} );
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { resolveColumnGroups } from '../private/column-groups';
|
|
2
|
+
|
|
3
|
+
const lines = ( columns: { line: number }[] ) => columns.map( column => column.line );
|
|
4
|
+
const gaps = ( columns: { gapBefore: boolean }[] ) => columns.map( column => column.gapBefore );
|
|
5
|
+
|
|
6
|
+
describe( 'resolveColumnGroups', () => {
|
|
7
|
+
test( 'no groups: consecutive lines from the first, no gaps', () => {
|
|
8
|
+
const layout = resolveColumnGroups( undefined, 3, 2 );
|
|
9
|
+
expect( layout.groups ).toEqual( [] );
|
|
10
|
+
expect( lines( layout.columns ) ).toEqual( [ 2, 3, 4 ] );
|
|
11
|
+
expect( gaps( layout.columns ) ).toEqual( [ false, false, false ] );
|
|
12
|
+
expect( resolveColumnGroups( [], 2, 2 ).columns ).toHaveLength( 2 );
|
|
13
|
+
} );
|
|
14
|
+
|
|
15
|
+
test( 'no columns: no groups and no warning, whatever the spans', () => {
|
|
16
|
+
expect( resolveColumnGroups( [ { label: 'A', span: 3 } ], 0, 2 ) ).toEqual( {
|
|
17
|
+
columns: [],
|
|
18
|
+
groups: [],
|
|
19
|
+
} );
|
|
20
|
+
expect( console ).not.toHaveWarned();
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
test( 'skips one line before the first column of every group but the first', () => {
|
|
24
|
+
const groups = [
|
|
25
|
+
{ label: 'A', span: 2 },
|
|
26
|
+
{ label: 'B', span: 1 },
|
|
27
|
+
{ label: 'C', span: 2 },
|
|
28
|
+
];
|
|
29
|
+
const layout = resolveColumnGroups( groups, 5, 2 );
|
|
30
|
+
expect( lines( layout.columns ) ).toEqual( [ 2, 3, 5, 7, 8 ] );
|
|
31
|
+
expect( gaps( layout.columns ) ).toEqual( [ false, false, true, true, false ] );
|
|
32
|
+
expect( layout.groups ).toEqual( [
|
|
33
|
+
{ label: 'A', span: 2, line: 2 },
|
|
34
|
+
{ label: 'B', span: 1, line: 5 },
|
|
35
|
+
{ label: 'C', span: 2, line: 7 },
|
|
36
|
+
] );
|
|
37
|
+
expect( layout.columns.map( column => column.group ) ).toEqual( [ 0, 0, 1, 2, 2 ] );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
test( 'leaves columns past the last group ungrouped and ungapped', () => {
|
|
41
|
+
const layout = resolveColumnGroups( [ { label: 'A', span: 2 } ], 4, 2 );
|
|
42
|
+
expect( lines( layout.columns ) ).toEqual( [ 2, 3, 4, 5 ] );
|
|
43
|
+
expect( gaps( layout.columns ) ).toEqual( [ false, false, false, false ] );
|
|
44
|
+
expect( layout.columns.map( column => column.group ) ).toEqual( [
|
|
45
|
+
0,
|
|
46
|
+
0,
|
|
47
|
+
undefined,
|
|
48
|
+
undefined,
|
|
49
|
+
] );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
test.each( [
|
|
53
|
+
[ 'zero', 0 ],
|
|
54
|
+
[ 'negative', -1 ],
|
|
55
|
+
[ 'fractional', 1.5 ],
|
|
56
|
+
[ 'NaN', NaN ],
|
|
57
|
+
] )( 'warns once and drops every group on a %s span', ( _, span ) => {
|
|
58
|
+
const layout = resolveColumnGroups(
|
|
59
|
+
[
|
|
60
|
+
{ label: 'A', span: 1 },
|
|
61
|
+
{ label: 'B', span },
|
|
62
|
+
],
|
|
63
|
+
4,
|
|
64
|
+
2
|
|
65
|
+
);
|
|
66
|
+
expect( layout.groups ).toEqual( [] );
|
|
67
|
+
expect( lines( layout.columns ) ).toEqual( [ 2, 3, 4, 5 ] );
|
|
68
|
+
expect( console ).toHaveWarned();
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
test( 'warns once and drops every group when the spans reach past the last column', () => {
|
|
72
|
+
const layout = resolveColumnGroups(
|
|
73
|
+
[
|
|
74
|
+
{ label: 'A', span: 3 },
|
|
75
|
+
{ label: 'B', span: 2 },
|
|
76
|
+
],
|
|
77
|
+
4,
|
|
78
|
+
2
|
|
79
|
+
);
|
|
80
|
+
expect( layout.groups ).toEqual( [] );
|
|
81
|
+
expect( console ).toHaveWarned();
|
|
82
|
+
} );
|
|
83
|
+
} );
|