@automattic/charts 1.12.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.12.0",
3
+ "version": "2.0.0",
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": {
@@ -84,8 +84,8 @@
84
84
  "@visx/xychart": "^4.0.0",
85
85
  "@wordpress/i18n": "^6.0.0",
86
86
  "@wordpress/icons": "^15.0.0",
87
- "@wordpress/theme": "0.17.0",
88
- "@wordpress/ui": "0.17.0",
87
+ "@wordpress/theme": "1.1.0",
88
+ "@wordpress/ui": "0.19.0",
89
89
  "clsx": "2.1.1",
90
90
  "date-fns": "^4.1.0",
91
91
  "deepmerge": "4.3.1",
@@ -112,7 +112,7 @@
112
112
  "@visx/glyph": "4.0.0",
113
113
  "@wordpress/components": "37.0.0",
114
114
  "@wordpress/element": "8.3.0",
115
- "@wordpress/private-apis": "1.51.0",
115
+ "@wordpress/private-apis": "1.52.0",
116
116
  "babel-jest": "30.4.1",
117
117
  "identity-obj-proxy": "^3.0.0",
118
118
  "jest": "30.4.2",
@@ -202,7 +202,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
202
202
  }, [ dataSorted, stacked, stackOffset, legendInteractive, rescaleYOnVisibility ] );
203
203
 
204
204
  const chartOptions = useMemo( () => {
205
- const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
205
+ const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
206
+ const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
206
207
 
207
208
  return {
208
209
  axis: {
@@ -211,7 +212,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
211
212
  numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
212
213
  tickFormat: formatter,
213
214
  display: true,
214
- ...options?.axis?.x,
215
+ ...xAxisOptions,
215
216
  },
216
217
  y: {
217
218
  orientation: 'left' as const,
@@ -16,7 +16,7 @@
16
16
  text-overflow: ellipsis;
17
17
  font-size: var(--wpds-typography-font-size-xl, 18px);
18
18
  font-style: normal;
19
- font-weight: var(--wpds-typography-font-weight-medium, 499);
19
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
20
20
  line-height: var(--wpds-typography-line-height-sm, 20px);
21
21
  margin: 0;
22
22
  }
@@ -26,7 +26,7 @@
26
26
  text-overflow: ellipsis;
27
27
  font-size: var(--wpds-typography-font-size-md, 13px);
28
28
  font-style: normal;
29
- font-weight: var(--wpds-typography-font-weight-medium, 499);
29
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
30
30
  line-height: var(--wpds-typography-line-height-sm, 20px);
31
31
  margin: 0;
32
32
  }
@@ -56,7 +56,7 @@
56
56
  .step-label {
57
57
  color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
58
58
  font-size: var(--wpds-typography-font-size-sm, 12px);
59
- font-weight: var(--wpds-typography-font-weight-regular, 400);
59
+ font-weight: var(--wpds-typography-font-weight-default, 400);
60
60
  line-height: var(--wpds-typography-line-height-xs, 16px);
61
61
  white-space: nowrap;
62
62
  overflow: hidden;
@@ -66,7 +66,7 @@
66
66
  .step-rate {
67
67
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
68
68
  font-size: var(--wpds-typography-font-size-md, 13px);
69
- font-weight: var(--wpds-typography-font-weight-medium, 499);
69
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
70
70
  line-height: var(--wpds-typography-line-height-xs, 16px);
71
71
  }
72
72
 
@@ -105,7 +105,7 @@
105
105
  // Override .visx-tooltip inline styles.
106
106
  border-radius: var(--wpds-border-radius-md, 4px) !important;
107
107
  padding: var(--wpds-dimension-padding-md, 12px) !important;
108
- box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
108
+ box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
109
109
 
110
110
  }
111
111
 
@@ -113,7 +113,7 @@
113
113
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
114
114
  font-size: var(--wpds-typography-font-size-sm, 12px);
115
115
  font-style: normal;
116
- font-weight: var(--wpds-typography-font-weight-regular, 400);
116
+ font-weight: var(--wpds-typography-font-weight-default, 400);
117
117
  line-height: var(--wpds-typography-line-height-xs, 16px);
118
118
  }
119
119
 
@@ -121,7 +121,7 @@
121
121
  color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
122
122
  font-size: var(--wpds-typography-font-size-md, 13px);
123
123
  font-style: normal;
124
- font-weight: var(--wpds-typography-font-weight-medium, 499);
124
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
125
125
  line-height: var(--wpds-typography-line-height-sm, 20px);
126
126
  }
127
127
 
@@ -24,7 +24,7 @@
24
24
  }
25
25
 
26
26
  &__tooltip-date {
27
- font-weight: var(--wpds-typography-font-weight-medium, 499);
27
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
28
28
  padding-bottom: var(--wpds-dimension-padding-md, 12px);
29
29
  }
30
30
 
@@ -33,7 +33,7 @@
33
33
  }
34
34
 
35
35
  &__tooltip-label {
36
- font-weight: var(--wpds-typography-font-weight-medium, 499);
36
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
37
37
  padding-right: var(--wpds-dimension-padding-lg, 16px);
38
38
  }
39
39
 
@@ -65,7 +65,7 @@
65
65
  border: none;
66
66
  border-radius: var(--wpds-border-radius-md, 4px);
67
67
  font-size: var(--wpds-typography-font-size-md, 13px);
68
- box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
68
+ box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
69
69
  position: fixed;
70
70
  // Without margin set, the popover has margin:auto which upsets the
71
71
  // positioning relative to the trigger button. A gap token is appropriate
@@ -287,7 +287,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
287
287
  } );
288
288
 
289
289
  const chartOptions = useMemo( () => {
290
- const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
290
+ const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
291
+ const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
291
292
 
292
293
  return {
293
294
  axis: {
@@ -296,7 +297,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
296
297
  numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
297
298
  tickFormat: formatter,
298
299
  display: true,
299
- ...options?.axis?.x,
300
+ ...xAxisOptions,
300
301
  },
301
302
  y: {
302
303
  orientation: 'left' as const,
@@ -436,7 +436,48 @@ describe( 'LineChart', () => {
436
436
  expect( ticks.length ).toBeGreaterThan( 1 );
437
437
  } );
438
438
 
439
+ test( 'renders hour ticks when tickResolution declares the buckets sub-daily.', () => {
440
+ renderWithTheme( {
441
+ width: 800,
442
+ options: { axis: { x: { tickResolution: 'hour' } } },
443
+ data: [
444
+ {
445
+ label: 'Series A',
446
+ // A day apart: spacing inference would read this as daily buckets.
447
+ data: [
448
+ { date: new Date( '2024-01-01T00:00:00' ), value: 10 },
449
+ { date: new Date( '2024-01-02T00:00:00' ), value: 20 },
450
+ ],
451
+ },
452
+ ],
453
+ } );
454
+
455
+ const ticks = screen.getAllByText( /\d+\s(AM|PM)/ );
456
+ expect( ticks.length ).toBeGreaterThan( 1 );
457
+ } );
458
+
439
459
  test( 'renders ticks in short date format.', () => {
460
+ renderWithTheme( {
461
+ width: 800,
462
+ data: [
463
+ {
464
+ label: 'Series A',
465
+ // Weekly resolution: fine enough to keep full dates on the ticks.
466
+ data: Array.from( { length: 16 }, ( _, i ) => ( {
467
+ date: new Date( Date.UTC( 2024, 0, 1 + i * 7 ) ),
468
+ value: 10 + i,
469
+ } ) ),
470
+ },
471
+ ],
472
+ } );
473
+
474
+ const ticks = screen.getAllByText(
475
+ /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
476
+ );
477
+ expect( ticks.length ).toBeGreaterThan( 1 );
478
+ } );
479
+
480
+ test( 'renders month ticks for month-or-coarser buckets within a year.', () => {
440
481
  renderWithTheme( {
441
482
  width: 800,
442
483
  data: [
@@ -453,9 +494,7 @@ describe( 'LineChart', () => {
453
494
  ],
454
495
  } );
455
496
 
456
- const ticks = screen.getAllByText(
457
- /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
458
- );
497
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
459
498
  expect( ticks.length ).toBeGreaterThan( 1 );
460
499
  } );
461
500
 
@@ -502,7 +541,8 @@ describe( 'LineChart', () => {
502
541
  ],
503
542
  } );
504
543
 
505
- const ticks = screen.getAllByText( /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct) \d+/ );
544
+ // Roughly monthly buckets render month ticks under the resolution-aware formatter.
545
+ const ticks = screen.getAllByText( /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct)$/ );
506
546
  expect( ticks.length ).toBeLessThan( 6 ); // Not much space
507
547
  } );
508
548
 
@@ -6,7 +6,7 @@
6
6
  }
7
7
 
8
8
  .label {
9
- font-weight: var(--wpds-typography-font-weight-medium, 499);
9
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
10
10
  font-size: var(--wpds-typography-font-size-lg, 16px);
11
11
  }
12
12
 
@@ -0,0 +1,182 @@
1
+ import { getFormatter } from '../time-axis';
2
+ import type { useChartDataTransform } from '../../../hooks';
3
+
4
+ type SortedData = ReturnType< typeof useChartDataTransform >;
5
+
6
+ const toSeries = ( dates: Date[] ): SortedData =>
7
+ [
8
+ {
9
+ label: 'series',
10
+ data: dates.map( date => ( { date, value: 1 } ) ),
11
+ },
12
+ ] as unknown as SortedData;
13
+
14
+ const hourlyDates = ( start: Date, hours: number ): Date[] =>
15
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
16
+
17
+ const dailyDates = ( start: Date, days: number ): Date[] =>
18
+ Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
19
+
20
+ // TZ is pinned to UTC by the test script, so local formatting is deterministic.
21
+ describe( 'getFormatter', () => {
22
+ it( 'uses hour ticks within a single day', () => {
23
+ const formatter = getFormatter(
24
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 ) )
25
+ );
26
+
27
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
28
+ } );
29
+
30
+ it( 'uses hour ticks with dates at midnight for sub-daily data spanning multiple days', () => {
31
+ const formatter = getFormatter(
32
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 48 ) )
33
+ );
34
+
35
+ expect( formatter( new Date( '2026-08-03T00:00:00' ).getTime() ) ).toMatch( /Aug 3/ );
36
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
37
+ } );
38
+
39
+ it( 'keeps hour ticks up to a full week of sub-daily data', () => {
40
+ const formatter = getFormatter(
41
+ toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 * 7 + 1 ) )
42
+ );
43
+
44
+ expect( formatter( new Date( '2026-08-05T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
45
+ } );
46
+
47
+ it( 'keeps date ticks for a two-point daily series spanning exactly a day', () => {
48
+ const formatter = getFormatter(
49
+ toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 2 ) )
50
+ );
51
+
52
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
53
+ } );
54
+
55
+ it( 'keeps date ticks for daily data over the same multi-day span', () => {
56
+ const formatter = getFormatter(
57
+ toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 3 ) )
58
+ );
59
+
60
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
61
+ expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).not.toMatch( /PM/ );
62
+ } );
63
+
64
+ it( 'keeps date ticks for daily buckets across a spring-forward transition', () => {
65
+ // A 23-hour first gap, as a daily series straddling spring-forward
66
+ // produces, must not demote the series to the sub-daily regime.
67
+ const start = new Date( '2026-03-08T00:00:00' );
68
+ const dstDates = [ 0, 23, 47, 71 ].map(
69
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
70
+ );
71
+ const formatter = getFormatter( toSeries( dstDates ) );
72
+
73
+ expect( formatter( dstDates[ 2 ].getTime() ) ).not.toMatch( /AM|PM/ );
74
+ expect( formatter( dstDates[ 2 ].getTime() ) ).toMatch( /Mar/ );
75
+ } );
76
+
77
+ it( 'keeps date ticks when no series has two points', () => {
78
+ const formatter = getFormatter( [
79
+ ...toSeries( [ new Date( '2026-03-01T00:00:00' ) ] ),
80
+ ...toSeries( [ new Date( '2026-03-04T00:00:00' ) ] ),
81
+ ] );
82
+
83
+ expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
84
+ } );
85
+
86
+ it( 'uses month ticks for buckets exactly a shortest-month apart', () => {
87
+ const formatter = getFormatter(
88
+ toSeries( [
89
+ new Date( '2026-02-01T00:00:00' ),
90
+ new Date( '2026-03-01T00:00:00' ),
91
+ new Date( '2026-04-01T00:00:00' ),
92
+ ] )
93
+ );
94
+
95
+ expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
96
+ } );
97
+
98
+ it( 'uses month ticks with the year at January for monthly buckets', () => {
99
+ const monthlyDates = Array.from(
100
+ { length: 13 },
101
+ ( _, i ) => new Date( Date.UTC( 2025, 7 + i, 1 ) )
102
+ );
103
+ const formatter = getFormatter( toSeries( monthlyDates ) );
104
+
105
+ expect( formatter( new Date( '2025-09-01T00:00:00' ).getTime() ) ).toBe( 'Sep' );
106
+ expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
107
+ } );
108
+
109
+ it( 'keeps date ticks for weekly buckets within a year', () => {
110
+ const weeklyDates = Array.from(
111
+ { length: 20 },
112
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
113
+ );
114
+ const formatter = getFormatter( toSeries( weeklyDates ) );
115
+
116
+ expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
117
+ } );
118
+
119
+ it( 'uses year ticks beyond a year', () => {
120
+ const yearly = getFormatter(
121
+ toSeries( [ new Date( '2020-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] )
122
+ );
123
+
124
+ expect( yearly( new Date( '2023-01-01T00:00:00' ).getTime() ) ).toBe( '2023' );
125
+ } );
126
+
127
+ describe( 'with an explicit tickResolution', () => {
128
+ it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
129
+ const formatter = getFormatter(
130
+ toSeries( [ new Date( '2026-08-01T00:00:00' ), new Date( '2026-08-02T00:00:00' ) ] ),
131
+ 'hour'
132
+ );
133
+
134
+ expect( formatter( new Date( '2026-08-01T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
135
+ } );
136
+
137
+ it( 'uses date ticks for a lone daily point, where spacing is unknowable', () => {
138
+ const formatter = getFormatter( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ), 'day' );
139
+
140
+ expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
141
+ } );
142
+
143
+ it( 'uses month ticks for monthly buckets regardless of their measured gaps', () => {
144
+ const formatter = getFormatter(
145
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] ),
146
+ 'month'
147
+ );
148
+
149
+ expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
150
+ } );
151
+
152
+ it( 'uses date ticks for weekly buckets', () => {
153
+ const weeklyDates = Array.from(
154
+ { length: 8 },
155
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
156
+ );
157
+ const formatter = getFormatter( toSeries( weeklyDates ), 'week' );
158
+
159
+ expect( formatter( new Date( '2026-01-12T00:00:00' ).getTime() ) ).toMatch( /Jan 12/ );
160
+ } );
161
+
162
+ it( 'uses year ticks for a pair of yearly buckets', () => {
163
+ const formatter = getFormatter(
164
+ toSeries( [ new Date( '2025-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] ),
165
+ 'year'
166
+ );
167
+
168
+ expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
169
+ expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
170
+ } );
171
+
172
+ it( 'uses year ticks for yearly buckets that do not start in January', () => {
173
+ const formatter = getFormatter(
174
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] ),
175
+ 'year'
176
+ );
177
+
178
+ expect( formatter( new Date( '2025-06-01T00:00:00' ).getTime() ) ).toBe( '2025' );
179
+ expect( formatter( new Date( '2026-06-01T00:00:00' ).getTime() ) ).toBe( '2026' );
180
+ } );
181
+ } );
182
+ } );
@@ -2,6 +2,7 @@ import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
2
2
  import { scaleTime } from '@visx/scale';
3
3
  import { differenceInHours, differenceInYears } from 'date-fns';
4
4
  import type { useChartDataTransform } from '../../hooks';
5
+ import type { TickResolution } from '../../types';
5
6
  import type { CurveType } from '../line-chart/types';
6
7
 
7
8
  // Approximate min pixel width for an x-axis tick label.
@@ -47,20 +48,98 @@ const formatHourTick = ( timestamp: number ) => {
47
48
  return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
48
49
  };
49
50
 
50
- // Pick the most informative tick formatter for the data's time span: hours
51
- // within a day, calendar dates within a year, otherwise just years.
52
- export const getFormatter = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
51
+ // Hour ticks with the date at midnight boundaries, so multi-day spans of
52
+ // sub-daily data keep their days identifiable.
53
+ const formatDateOrHourTick = ( timestamp: number ) => {
54
+ const date = new Date( timestamp );
55
+ return date.getHours() === 0 && date.getMinutes() === 0
56
+ ? formatDateTick( timestamp )
57
+ : formatHourTick( timestamp );
58
+ };
59
+
60
+ // Month ticks with the year at January boundaries, for month-or-coarser
61
+ // buckets where a full "Sep 1" date would misread as a daily point.
62
+ const formatMonthOrYearTick = ( timestamp: number ) => {
63
+ const date = new Date( timestamp );
64
+ return date.getMonth() === 0
65
+ ? formatYearTick( timestamp )
66
+ : date.toLocaleDateString( undefined, { month: 'short' } );
67
+ };
68
+
69
+ // Smallest interval between consecutive points across all series, in hours.
70
+ // Infinity when no series has two points.
71
+ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
72
+ return sortedData.reduce(
73
+ ( spacing, datom ) =>
74
+ datom.data.reduce( ( seriesSpacing, point, index ) => {
75
+ const previous = datom.data[ index - 1 ];
76
+ if ( previous?.date === undefined || point?.date === undefined ) {
77
+ return seriesSpacing;
78
+ }
79
+ return Math.min(
80
+ seriesSpacing,
81
+ Math.abs( differenceInHours( point.date, previous.date ) )
82
+ );
83
+ }, spacing ),
84
+ Number.POSITIVE_INFINITY
85
+ );
86
+ };
87
+
88
+ // Nominal point spacing for a caller-declared bucket resolution. A month maps
89
+ // to the shortest month so the month-or-coarser regime engages (see
90
+ // getFormatter). Year is absent: it short-circuits to year ticks before the
91
+ // spacing regimes run.
92
+ const SPACING_BY_RESOLUTION: Record< Exclude< TickResolution, 'year' >, number > = {
93
+ hour: 1,
94
+ day: 24,
95
+ week: 24 * 7,
96
+ month: 28 * 24,
97
+ };
98
+
99
+ // Pick the most informative tick formatter for the data's resolution and time
100
+ // span: hours within a day, hours with day boundaries for sub-daily data
101
+ // spanning up to a week, calendar dates for daily-or-finer buckets within a
102
+ // year, months (with the year at January) for month-or-coarser buckets,
103
+ // otherwise just years. The resolution comes from `tickResolution` when the
104
+ // caller knows it, and is inferred from point spacing otherwise.
105
+ export const getFormatter = (
106
+ sortedData: ReturnType< typeof useChartDataTransform >,
107
+ tickResolution?: TickResolution
108
+ ) => {
109
+ // The month regime only prints the year at January boundaries, so yearly
110
+ // buckets starting mid-year would render as month names; year ticks are
111
+ // correct for yearly buckets at any span.
112
+ if ( tickResolution === 'year' ) {
113
+ return formatYearTick;
114
+ }
115
+
53
116
  const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
54
117
  const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
55
118
 
119
+ const spacingInHours = tickResolution
120
+ ? SPACING_BY_RESOLUTION[ tickResolution ]
121
+ : getPointSpacingInHours( sortedData );
122
+ // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
123
+ // spring-forward DST transition.
124
+ const isSubDaily = spacingInHours < 23;
125
+
56
126
  const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
57
- if ( diffInHours <= 24 ) {
127
+ if ( diffInHours <= 24 && isSubDaily ) {
58
128
  return formatHourTick;
59
129
  }
60
130
 
131
+ if ( diffInHours <= 24 * 7 && isSubDaily ) {
132
+ return formatDateOrHourTick;
133
+ }
134
+
61
135
  const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
62
136
  if ( diffInYears <= 1 ) {
63
- return formatDateTick;
137
+ // 28 days: the shortest month, so monthly buckets qualify but weekly
138
+ // don't. The finiteness check keeps all-single-point series — whose
139
+ // resolution is unknowable — on date ticks.
140
+ return Number.isFinite( spacingInHours ) && spacingInHours >= 28 * 24
141
+ ? formatMonthOrYearTick
142
+ : formatDateTick;
64
143
  }
65
144
 
66
145
  return formatYearTick;
@@ -1,6 +1,6 @@
1
- import { act, render, renderHook, screen } from '@testing-library/react';
1
+ import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { useRef } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
4
  import { useXZoom, ZoomResetButton } from '../index';
5
5
  import type { SingleChartRef } from '../../single-chart-context';
6
6
  import type { EventHandlerParams } from '@visx/xychart';
@@ -147,17 +147,35 @@ const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
147
147
  event.preventDefault();
148
148
 
149
149
  describe( 'ZoomResetButton', () => {
150
- test( 'renders a labelled button with a hover tooltip', () => {
150
+ test( 'renders a labelled button without a native title tooltip', () => {
151
151
  const noop = jest.fn();
152
152
  render( <ZoomResetButton onClick={ noop } /> );
153
153
  const button = screen.getByTestId( 'chart-zoom-reset' );
154
154
  expect( button.tagName ).toBe( 'BUTTON' );
155
155
  expect( button ).toHaveClass( 'x-zoom__reset' );
156
156
  expect( button ).toHaveAccessibleName( 'Reset zoom' );
157
- // WPDS `IconButton` would supply a tooltip of its own, but it pulls in a
158
- // CommonJS dependency that breaks Script Module consumers (see
159
- // ZoomResetButton). `title` restores the hover hint on plain `Button`.
160
- expect( button ).toHaveAttribute( 'title', 'Reset zoom' );
157
+ // IconButton renders a real tooltip, so the `title` fallback is gone —
158
+ // `title` is invisible to keyboard users and cannot be dismissed.
159
+ expect( button ).not.toHaveAttribute( 'title' );
160
+ } );
161
+
162
+ test( 'shows a tooltip on keyboard focus', async () => {
163
+ const noop = jest.fn();
164
+ render( <ZoomResetButton onClick={ noop } /> );
165
+ await userEvent.tab();
166
+ expect( screen.getByTestId( 'chart-zoom-reset' ) ).toHaveFocus();
167
+ // The button's only text is the tooltip's — its own label is an
168
+ // `aria-label` attribute, so this cannot match the trigger.
169
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
170
+ } );
171
+
172
+ test( 'dismisses the tooltip on Escape', async () => {
173
+ const noop = jest.fn();
174
+ render( <ZoomResetButton onClick={ noop } /> );
175
+ await userEvent.tab();
176
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
177
+ await userEvent.keyboard( '{Escape}' );
178
+ await waitFor( () => expect( screen.queryByText( 'Reset zoom' ) ).not.toBeInTheDocument() );
161
179
  } );
162
180
 
163
181
  test( 'fires onClick when activated', async () => {
@@ -167,6 +185,33 @@ describe( 'ZoomResetButton', () => {
167
185
  expect( onClick ).toHaveBeenCalledTimes( 1 );
168
186
  } );
169
187
 
188
+ test( 'leaves no orphaned tooltip when activation unmounts the button', async () => {
189
+ // Resetting the zoom unmounts this control while its tooltip is open.
190
+ // The tooltip renders in a portal outside the container, so a missed
191
+ // cleanup would strand it on the page rather than remove it with the
192
+ // button.
193
+ /**
194
+ * Mirrors the host charts: the reset control exists only while zoomed.
195
+ *
196
+ * @return JSX element or null.
197
+ */
198
+ function Host() {
199
+ const [ zoomed, setZoomed ] = useState( true );
200
+ const unzoom = useCallback( () => setZoomed( false ), [] );
201
+ return zoomed ? <ZoomResetButton onClick={ unzoom } /> : null;
202
+ }
203
+ render( <Host /> );
204
+ await userEvent.tab();
205
+ await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
206
+
207
+ await userEvent.keyboard( '{Enter}' );
208
+
209
+ await waitFor( () =>
210
+ expect( screen.queryByTestId( 'chart-zoom-reset' ) ).not.toBeInTheDocument()
211
+ );
212
+ expect( document.body ).not.toHaveTextContent( 'Reset zoom' );
213
+ } );
214
+
170
215
  test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
171
216
  const onClick = jest.fn();
172
217
  // Mirrors the chart's grid wrapper, whose keyboard-navigation handler
@@ -11,26 +11,18 @@
11
11
  pointer-events: none;
12
12
  }
13
13
 
14
- // Overlay placement and icon-only geometry — the visual treatment (border,
15
- // hover/focus, sizing) comes from the WPDS Button. The elevation shadow is
16
- // deliberately kept for separation from chart content (CHARTS-237
17
- // design call, carrying over CHARTS-200's tokenization).
14
+ // The elevation shadow is deliberately kept for separation
15
+ // from chart content (CHARTS-237 design call, carrying
16
+ // over CHARTS-200's tokenization). `@wordpress/theme` 1.0.0
17
+ // dropped the `--wpds-elevation-*` group, so this role carries
18
+ // the former token's spec value directly rather than nesting
19
+ // a `--wpds-*` var.
18
20
  &__reset {
19
21
  position: absolute;
20
22
  top: var(--wpds-dimension-gap-sm, 8px);
21
23
  right: var(--wpds-dimension-gap-sm, 8px);
22
24
  z-index: 2;
23
- box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
24
-
25
- // `Button` is sized for a text label. These are the same three custom
26
- // properties @wordpress/ui's own IconButton composition sets to make a
27
- // square icon-only control; we set them directly because IconButton
28
- // itself cannot be used here — it renders a Base UI tooltip, whose
29
- // CommonJS `use-sync-external-store` dependency makes Rolldown emit a
30
- // dynamic `require()` that throws in Script Module consumers.
31
- --wp-ui-button-aspect-ratio: 1;
32
- --wp-ui-button-padding-inline: 0;
33
- --wp-ui-button-min-width: unset;
25
+ box-shadow: var(--a8c-charts-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
34
26
 
35
27
  // WPDS outline-neutral is transparent-bodied at rest; give this
36
28
  // floating control an opaque body so chart content doesn't show