@automattic/charts 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +107 -48
  3. package/dist/index.cjs +584 -209
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +97 -43
  6. package/dist/index.d.cts +46 -41
  7. package/dist/index.d.ts +46 -41
  8. package/dist/index.js +582 -210
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +3 -5
  13. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +24 -15
  15. package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  19. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  20. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  21. package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
  22. package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
  23. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  24. package/src/charts/leaderboard-chart/types.ts +0 -2
  25. package/src/charts/line-chart/line-chart.module.scss +10 -5
  26. package/src/charts/line-chart/line-chart.tsx +3 -5
  27. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  28. package/src/charts/pie-chart/pie-chart.tsx +4 -1
  29. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
  30. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  31. package/src/charts/private/test/time-axis.test.ts +247 -2
  32. package/src/charts/private/time-axis.ts +252 -63
  33. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  34. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  35. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  36. package/src/components/legend/private/base-legend.tsx +3 -1
  37. package/src/components/legend/test/legend.test.tsx +2 -1
  38. package/src/components/tooltip/accessible-tooltip.tsx +4 -2
  39. package/src/components/tooltip/base-tooltip.tsx +8 -1
  40. package/src/components/trend-indicator/test/trend-indicator.test.tsx +22 -1
  41. package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
  42. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +60 -0
  44. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  45. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +89 -0
  46. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  47. package/src/hooks/use-chart-margin.tsx +3 -4
  48. package/src/hooks/use-xychart-theme.ts +19 -11
  49. package/src/index.ts +9 -2
  50. package/src/providers/chart-context/global-charts-provider.tsx +35 -12
  51. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  52. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  53. package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
  54. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  55. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  56. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  57. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  58. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  59. package/src/providers/chart-context/themes.ts +18 -27
  60. package/src/providers/chart-context/types.ts +0 -3
  61. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  62. package/src/providers/chart-scope/index.ts +3 -0
  63. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  64. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  65. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  66. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  67. package/src/providers/index.ts +1 -0
  68. package/src/styles/chart-scope-class.ts +6 -0
  69. package/src/styles/chart-scope.scss +83 -0
  70. package/src/styles/test/chart-scope.test.ts +165 -0
  71. package/src/types.ts +6 -40
  72. package/src/utils/index.ts +1 -1
  73. package/src/utils/resolve-css-var.ts +69 -28
  74. package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
  75. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  76. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  77. package/src/charts/private/grid-control/index.ts +0 -1
  78. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
@@ -29,7 +29,7 @@ import {
29
29
  useGlobalChartsContext,
30
30
  useGlobalChartsTheme,
31
31
  } from '../../providers';
32
- import { attachSubComponents, resolveCssVariable } from '../../utils';
32
+ import { attachSubComponents } from '../../utils';
33
33
  import { useChartChildren } from '../private/chart-composition';
34
34
  import { ChartLayout } from '../private/chart-layout';
35
35
  import { DefaultGlyph } from '../private/default-glyph';
@@ -192,11 +192,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
192
192
  const legendPosition = legend.position ?? 'bottom';
193
193
 
194
194
  const providerTheme = useGlobalChartsTheme();
195
- // Gradient stops apply this as an SVG attribute, where CSS var() cannot
196
- // resolve, so resolve the WPDS token to a concrete value first.
197
- const resolvedBackgroundColor =
198
- resolveCssVariable( providerTheme.backgroundColor ) ?? providerTheme.backgroundColor;
199
195
  const theme = useXYChartTheme( data );
196
+ // Gradient stops apply this as an SVG attribute, where CSS var() cannot resolve. useXYChartTheme has already resolved the same role inside its memo, against the chart's scope element, so read it back rather than paying another getComputedStyle on every render.
197
+ const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
200
198
  const chartId = useChartId( providedChartId );
201
199
  const chartRef = useRef< HTMLDivElement >( null );
202
200
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
@@ -9,7 +9,7 @@ import {
9
9
  import { DataContext } from '@visx/xychart';
10
10
  import merge from 'deepmerge';
11
11
  import { useContext, useRef, useEffect, useState, useMemo } from 'react';
12
- import { useGlobalChartsTheme } from '../../../providers';
12
+ import { useChartScopeElement, useGlobalChartsTheme } from '../../../providers';
13
13
  import { isSafari, resolveCssVariable } from '../../../utils';
14
14
  import LineChartAnnotationLabelWithPopover, {
15
15
  POPOVER_BUTTON_SIZE,
@@ -152,6 +152,7 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
152
152
  renderLabelPopover,
153
153
  } ) => {
154
154
  const providerTheme = useGlobalChartsTheme();
155
+ const scopeElement = useChartScopeElement();
155
156
  const { xScale, yScale } = useContext( DataContext ) || {};
156
157
  const labelRef = useRef< SVGGElement >( null );
157
158
  const [ height, setHeight ] = useState< number | null >( null );
@@ -159,10 +160,9 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
159
160
  // Deep merge styles to preserve nested object properties
160
161
  const styles = merge( providerTheme.annotationStyles ?? {}, datumStyles ?? {} );
161
162
 
162
- // visx annotation parts apply these colors as SVG presentation attributes,
163
- // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values first.
163
+ // visx annotation parts apply these colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve against the chart's scope element, never :root.
164
164
  const resolveColor = ( value?: string ): string | undefined =>
165
- value ? resolveCssVariable( value ) ?? value : value;
165
+ value ? resolveCssVariable( value, scopeElement ) ?? value : value;
166
166
 
167
167
  // Measure the label height once after initial render
168
168
  useEffect( () => {
@@ -19,6 +19,7 @@ import {
19
19
  useGlobalChartsTheme,
20
20
  GlobalChartsContext,
21
21
  } from '../../providers';
22
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
23
  import { attachSubComponents, resolveFontSize } from '../../utils';
23
24
  import { getStringWidth } from '../../visx/text';
24
25
  import { Center } from '../private/center';
@@ -328,7 +329,9 @@ const PieChartInternal = ( {
328
329
  <>
329
330
  { withTooltips && tooltipOpen && tooltipData && (
330
331
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
331
- <div role="tooltip">{ renderTooltip( { tooltipData } ) }</div>
332
+ <div className={ CHART_SCOPE_CLASS } role="tooltip">
333
+ { renderTooltip( { tooltipData } ) }
334
+ </div>
332
335
  </TooltipInPortal>
333
336
  ) }
334
337
  { htmlChildren }
@@ -19,6 +19,7 @@ import {
19
19
  useGlobalChartsContext,
20
20
  GlobalChartsContext,
21
21
  } from '../../providers';
22
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
22
23
  import { attachSubComponents } from '../../utils';
23
24
  import { Center } from '../private/center';
24
25
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
@@ -375,7 +376,9 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
375
376
  <>
376
377
  { withTooltips && tooltipOpen && tooltipData && (
377
378
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
378
- <div role="tooltip">{ renderTooltip( { tooltipData } ) }</div>
379
+ <div className={ CHART_SCOPE_CLASS } role="tooltip">
380
+ { renderTooltip( { tooltipData } ) }
381
+ </div>
379
382
  </TooltipInPortal>
380
383
  ) }
381
384
  { htmlChildren }
@@ -1,5 +1,5 @@
1
1
  .svg-empty-state {
2
2
  text-align: center;
3
3
  font-size: var(--wpds-typography-font-size-md, 13px);
4
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
4
+ color: var(--a8c-charts-color-label-secondary);
5
5
  }
@@ -1,4 +1,4 @@
1
- import { getFormatter } from '../time-axis';
1
+ import { getBandTickValues, getBucketResolution, getFormatter } from '../time-axis';
2
2
  import type { useChartDataTransform } from '../../../hooks';
3
3
 
4
4
  type SortedData = ReturnType< typeof useChartDataTransform >;
@@ -17,7 +17,7 @@ const hourlyDates = ( start: Date, hours: number ): Date[] =>
17
17
  const dailyDates = ( start: Date, days: number ): Date[] =>
18
18
  Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
19
19
 
20
- // TZ is pinned to UTC by the test script, so local formatting is deterministic.
20
+ // The test script pins TZ and locale, so local formatting is deterministic.
21
21
  describe( 'getFormatter', () => {
22
22
  it( 'uses hour ticks within a single day', () => {
23
23
  const formatter = getFormatter(
@@ -124,6 +124,46 @@ describe( 'getFormatter', () => {
124
124
  expect( yearly( new Date( '2023-01-01T00:00:00' ).getTime() ) ).toBe( '2023' );
125
125
  } );
126
126
 
127
+ it( 'keeps month ticks for monthly buckets spanning several years', () => {
128
+ const monthlyDates = Array.from(
129
+ { length: 36 },
130
+ ( _, i ) => new Date( Date.UTC( 2024, i, 1 ) )
131
+ );
132
+ const formatter = getFormatter( toSeries( monthlyDates ) );
133
+
134
+ // Year ticks here would render the same year for every tick the band scale
135
+ // samples out of a calendar year.
136
+ expect( formatter( new Date( '2024-10-01T00:00:00' ).getTime() ) ).toBe( 'Oct' );
137
+ expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
138
+ } );
139
+
140
+ it( 'uses year ticks for inferred yearly buckets that do not start in January', () => {
141
+ const formatter = getFormatter(
142
+ toSeries( [ new Date( '2023-07-01T00:00:00' ), new Date( '2024-07-01T00:00:00' ) ] )
143
+ );
144
+
145
+ // The month regime would print a bare "Jul" for both.
146
+ expect( formatter( new Date( '2023-07-01T00:00:00' ).getTime() ) ).toBe( '2023' );
147
+ expect( formatter( new Date( '2024-07-01T00:00:00' ).getTime() ) ).toBe( '2024' );
148
+ } );
149
+
150
+ it( 'ignores series with no dated points when measuring the span', () => {
151
+ const formatter = getFormatter( [
152
+ ...toSeries( dailyDates( new Date( '2024-01-01T00:00:00' ), 14 ) ),
153
+ ...toSeries( [] ),
154
+ ] );
155
+
156
+ // An empty comparison series must not turn the span into NaN, which would
157
+ // fall through every span check to year ticks.
158
+ expect( formatter( new Date( '2024-01-03T00:00:00' ).getTime() ) ).toMatch( /Jan 3/ );
159
+ } );
160
+
161
+ it( 'falls back to date ticks when nothing is dated', () => {
162
+ const formatter = getFormatter( toSeries( [] ) );
163
+
164
+ expect( formatter( new Date( '2024-01-03T00:00:00' ).getTime() ) ).toMatch( /Jan 3/ );
165
+ } );
166
+
127
167
  describe( 'with an explicit tickResolution', () => {
128
168
  it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
129
169
  const formatter = getFormatter(
@@ -180,3 +220,208 @@ describe( 'getFormatter', () => {
180
220
  } );
181
221
  } );
182
222
  } );
223
+
224
+ describe( 'getBucketResolution', () => {
225
+ it( 'infers hourly buckets from sub-daily spacing', () => {
226
+ expect(
227
+ getBucketResolution( toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) ) )
228
+ ).toBe( 'hour' );
229
+ } );
230
+
231
+ it( 'infers daily buckets from daily spacing', () => {
232
+ expect(
233
+ getBucketResolution( toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 5 ) ) )
234
+ ).toBe( 'day' );
235
+ } );
236
+
237
+ it( 'infers daily buckets from weekly spacing, which labels the same way', () => {
238
+ const weeklyDates = Array.from(
239
+ { length: 5 },
240
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
241
+ );
242
+
243
+ expect( getBucketResolution( toSeries( weeklyDates ) ) ).toBe( 'day' );
244
+ } );
245
+
246
+ it( 'infers monthly buckets from a shortest-month gap', () => {
247
+ expect(
248
+ getBucketResolution(
249
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] )
250
+ )
251
+ ).toBe( 'month' );
252
+ } );
253
+
254
+ it( 'infers yearly buckets from a yearly gap', () => {
255
+ expect(
256
+ getBucketResolution(
257
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] )
258
+ )
259
+ ).toBe( 'year' );
260
+ } );
261
+
262
+ it( 'keeps a daily gap across spring-forward out of the hourly bucket', () => {
263
+ const start = new Date( '2026-03-08T00:00:00' );
264
+ const dstDates = [ 0, 23, 47 ].map(
265
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
266
+ );
267
+
268
+ expect( getBucketResolution( toSeries( dstDates ) ) ).toBe( 'day' );
269
+ } );
270
+
271
+ it( 'reports daily buckets when no series has two points', () => {
272
+ // Unmeasurable spacing reads as Infinity, which must not be mistaken for a
273
+ // very coarse bucket.
274
+ expect( getBucketResolution( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ) ) ).toBe(
275
+ 'day'
276
+ );
277
+ } );
278
+
279
+ it( 'reports a declared resolution over the measured spacing', () => {
280
+ const hourly = toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) );
281
+
282
+ expect( getBucketResolution( hourly, 'month' ) ).toBe( 'month' );
283
+ } );
284
+
285
+ it( 'reports a declared weekly resolution as daily', () => {
286
+ // Weeks and days are both calendar-date buckets as far as labelling goes,
287
+ // and 'week' is not a label format of its own.
288
+ expect( getBucketResolution( toSeries( [] ), 'week' ) ).toBe( 'day' );
289
+ } );
290
+ } );
291
+
292
+ describe( 'tick format anchors', () => {
293
+ // The band axis steers ticks onto boundaries using the predicate the format
294
+ // hangs off itself. If the two ever disagree, the axis aims at buckets that
295
+ // do not actually print the coarser label.
296
+ it( 'marks exactly the buckets whose label carries the coarser unit', () => {
297
+ const hourly = Array.from( { length: 48 }, ( _, i ) => new Date( 2026, 7, 2, i ) );
298
+ const hourFormat = getFormatter( toSeries( hourly ) );
299
+
300
+ expect( hourFormat.isAnchor ).toBeDefined();
301
+ hourly.forEach( date => {
302
+ const isDateLabel = ! /(AM|PM)/.test( hourFormat( date.getTime() ) );
303
+ expect( hourFormat.isAnchor( date ) ).toBe( isDateLabel );
304
+ } );
305
+
306
+ const monthly = Array.from( { length: 36 }, ( _, i ) => new Date( 2023, 6 + i, 1 ) );
307
+ const monthFormat = getFormatter( toSeries( monthly ) );
308
+
309
+ expect( monthFormat.isAnchor ).toBeDefined();
310
+ monthly.forEach( date => {
311
+ const isYearLabel = /^\d{4}$/.test( monthFormat( date.getTime() ) );
312
+ expect( monthFormat.isAnchor( date ) ).toBe( isYearLabel );
313
+ } );
314
+ } );
315
+ } );
316
+
317
+ describe( 'getBandTickValues', () => {
318
+ const monthlyDomain = ( year: number, month: number, months: number ) =>
319
+ Array.from( { length: months }, ( _, i ) => new Date( year, month + i, 1 ) );
320
+
321
+ const hourlyDomain = ( start: Date, hours: number ) =>
322
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
323
+
324
+ const labelsFor = ( domain: Date[], maxTicks = 4 ) => {
325
+ const formatter = getFormatter( toSeries( domain ) );
326
+ return getBandTickValues( domain, formatter, maxTicks ).map( date =>
327
+ formatter( date.getTime() )
328
+ );
329
+ };
330
+
331
+ it( 'shifts the sampling offset onto January for a monthly series starting mid-year', () => {
332
+ // Sampling from index 0 gives Aug, Nov, Feb, May, Aug — no year at all on a
333
+ // series that crosses one, and "Aug" twice.
334
+ expect( labelsFor( monthlyDomain( 2025, 7, 13 ) ) ).toContain( '2026' );
335
+ } );
336
+
337
+ it( 'lands on every January of a multi-year monthly series that starts mid-year', () => {
338
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ) ) ).toEqual( [ '2024', '2025', '2026' ] );
339
+ } );
340
+
341
+ it( 'keeps the dated midnight ticks for sub-daily data spanning days', () => {
342
+ // Index sampling already lands here; this pins that the steering keeps it.
343
+ expect( labelsFor( hourlyDomain( new Date( 2026, 7, 2 ), 48 ) ) ).toEqual( [
344
+ 'Aug 2',
345
+ '12 PM',
346
+ 'Aug 3',
347
+ '12 PM',
348
+ ] );
349
+ } );
350
+
351
+ it( 'steps whole days once a sub-daily span is too long to date any other way', () => {
352
+ // No step inside a day fits four ticks across a week, so the offset sweep
353
+ // alone would leave every tick but the first a bare hour on an unnamed day.
354
+ expect( labelsFor( hourlyDomain( new Date( 2026, 7, 2 ), 168 ) ) ).toEqual( [
355
+ 'Aug 2',
356
+ 'Aug 4',
357
+ 'Aug 6',
358
+ 'Aug 8',
359
+ ] );
360
+ } );
361
+
362
+ it( 'steps whole years once a monthly span is too long to reach January any other way', () => {
363
+ expect( labelsFor( monthlyDomain( 2023, 6, 120 ) ) ).toEqual( [
364
+ '2024',
365
+ '2027',
366
+ '2030',
367
+ '2033',
368
+ ] );
369
+ } );
370
+
371
+ it( 'still names a year at the lowest tick counts a caller can ask for', () => {
372
+ // An anchor outranks a denser axis, so without a step that reaches two of
373
+ // them a two-tick axis would settle for a single January.
374
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ), 2 ) ).toEqual( [ '2024', '2026' ] );
375
+ expect( labelsFor( monthlyDomain( 2023, 6, 36 ), 3 ) ).toEqual( [ '2024', '2025', '2026' ] );
376
+ } );
377
+
378
+ it( 'never repeats a label on adjacent ticks', () => {
379
+ // 49 buckets put every fourth tick a whole year apart, so sampling by index
380
+ // spells the same month five times over: Feb, Feb, Feb, Feb, Feb.
381
+ const labels = labelsFor( monthlyDomain( 2023, 1, 49 ) );
382
+
383
+ expect( labels.some( ( label, i ) => i > 0 && label === labels[ i - 1 ] ) ).toBe( false );
384
+ } );
385
+
386
+ it( 'keeps the axis dense when only a sparser one could reach an anchor', () => {
387
+ // Ranking anchors above everything picked the two ticks that happened to be
388
+ // Januaries and dropped the rest of the axis with them.
389
+ expect( labelsFor( monthlyDomain( 2023, 3, 30 ) ) ).toEqual( [ 'Apr', '2024', 'Oct', 'Jul' ] );
390
+ } );
391
+
392
+ it( 'names the single year of a short series without thinning the axis to it', () => {
393
+ expect( labelsFor( monthlyDomain( 2023, 6, 9 ) ) ).toEqual( [ 'Jul', 'Oct', '2024' ] );
394
+ } );
395
+
396
+ it( 'reaches anchors that sit an uneven number of buckets apart', () => {
397
+ // A spring-forward day is 23 hourly buckets, not 24, so a fixed stride
398
+ // slides off midnight and leaves the rest of the week on bare hours.
399
+ const domain: Date[] = [];
400
+ for ( let day = 0; day < 7; day++ ) {
401
+ for ( let hour = 0; hour < 24; hour++ ) {
402
+ if ( day !== 2 || hour !== 2 ) {
403
+ domain.push( new Date( 2026, 2, 6 + day, hour ) );
404
+ }
405
+ }
406
+ }
407
+
408
+ expect( labelsFor( domain ) ).toEqual( [ 'Mar 6', 'Mar 8', 'Mar 10', 'Mar 12' ] );
409
+ } );
410
+
411
+ it( 'keeps the whole domain when it already fits', () => {
412
+ const domain = monthlyDomain( 2023, 0, 4 );
413
+
414
+ expect( getBandTickValues( domain, String, 4 ) ).toEqual( domain );
415
+ } );
416
+
417
+ it( 'never exceeds the requested tick count', () => {
418
+ const domain = monthlyDomain( 2023, 6, 36 );
419
+
420
+ expect( getBandTickValues( domain, String, 4 ).length ).toBeLessThanOrEqual( 4 );
421
+ expect( getBandTickValues( domain, String, 7 ).length ).toBeLessThanOrEqual( 7 );
422
+ } );
423
+
424
+ it( 'returns nothing for an empty domain', () => {
425
+ expect( getBandTickValues( [], String, 4 ) ).toEqual( [] );
426
+ } );
427
+ } );