@automattic/charts 1.12.0 → 2.0.1

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/TOKENS.md +8 -1
  3. package/dist/index.cjs +2487 -3465
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +15 -18
  6. package/dist/index.d.cts +15 -1
  7. package/dist/index.d.ts +15 -1
  8. package/dist/index.js +452 -1429
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -5
  11. package/src/charts/area-chart/area-chart.tsx +6 -7
  12. package/src/charts/bar-chart/bar-chart.tsx +24 -15
  13. package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
  15. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  16. package/src/charts/line-chart/line-chart.module.scss +3 -3
  17. package/src/charts/line-chart/line-chart.tsx +6 -7
  18. package/src/charts/line-chart/test/line-chart.test.tsx +44 -4
  19. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
  20. package/src/charts/private/test/time-axis.test.ts +427 -0
  21. package/src/charts/private/time-axis.ts +291 -23
  22. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +52 -7
  23. package/src/charts/private/x-zoom/x-zoom.module.scss +7 -15
  24. package/src/charts/private/x-zoom/x-zoom.tsx +29 -40
  25. package/src/components/legend/private/base-legend.module.scss +1 -1
  26. package/src/components/legend/test/legend.test.tsx +2 -1
  27. package/src/components/tooltip/base-tooltip.module.scss +1 -1
  28. package/src/components/trend-indicator/test/trend-indicator.test.tsx +2 -1
  29. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  30. package/src/hooks/test/use-chart-margin.test.tsx +60 -0
  31. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +83 -0
  32. package/src/hooks/use-chart-margin.tsx +3 -4
  33. package/src/hooks/use-xychart-theme.ts +17 -11
  34. package/src/index.ts +1 -0
  35. package/src/types.ts +15 -0
  36. package/src/utils/index.ts +1 -1
  37. package/src/utils/resolve-css-var.ts +69 -28
  38. package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
@@ -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.
@@ -32,38 +33,302 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
32
33
  }
33
34
  };
34
35
 
35
- const formatYearTick = ( timestamp: number ) => {
36
- const date = new Date( timestamp );
37
- return date.toLocaleDateString( undefined, { year: 'numeric' } );
36
+ // Built once and reused: these run per bucket over the whole band domain when
37
+ // the axis picks its tick values, and `toLocaleDateString` rebuilds its
38
+ // formatter on every call once an options object is passed.
39
+ const dateTimeFormat = ( options: Intl.DateTimeFormatOptions ) => {
40
+ let formatter: Intl.DateTimeFormat;
41
+ return ( timestamp: number ) => {
42
+ // `Intl.DateTimeFormat` throws on an invalid date where the `Date`
43
+ // methods it replaces return "Invalid Date", and charts are expected to
44
+ // render past a bad point rather than fail.
45
+ // visx hands its tick formatter a `Date`, so coerce before the check.
46
+ if ( ! Number.isFinite( Number( timestamp ) ) ) {
47
+ return new Date( timestamp ).toLocaleDateString();
48
+ }
49
+ formatter = formatter ?? new Intl.DateTimeFormat( undefined, options );
50
+ return formatter.format( timestamp );
51
+ };
38
52
  };
39
53
 
40
- const formatDateTick = ( timestamp: number ) => {
41
- const date = new Date( timestamp );
42
- return date.toLocaleDateString( undefined, { month: 'short', day: 'numeric' } );
54
+ const formatYearTick = dateTimeFormat( { year: 'numeric' } );
55
+
56
+ const formatDateTick = dateTimeFormat( { month: 'short', day: 'numeric' } );
57
+
58
+ const formatHourTick = dateTimeFormat( { hour: 'numeric', hour12: true } );
59
+
60
+ const formatMonthTick = dateTimeFormat( { month: 'short' } );
61
+
62
+ /**
63
+ * A tick format, carrying the boundary test that decides its coarser label.
64
+ *
65
+ * A band scale samples by index, so `getBandTickValues` has to steer ticks onto
66
+ * the boundaries a format prints the date or the year at. Hanging the test off
67
+ * the format that branches on it keeps the two from drifting apart.
68
+ */
69
+ export type TickFormat = ( ( timestamp: number ) => string ) & {
70
+ isAnchor?: ( date: Date ) => boolean;
71
+ };
72
+
73
+ const boundaryFormat = (
74
+ isAnchor: ( date: Date ) => boolean,
75
+ atBoundary: ( timestamp: number ) => string,
76
+ between: ( timestamp: number ) => string
77
+ ): TickFormat => {
78
+ const format: TickFormat = ( timestamp: number ) =>
79
+ isAnchor( new Date( timestamp ) ) ? atBoundary( timestamp ) : between( timestamp );
80
+ format.isAnchor = isAnchor;
81
+ return format;
82
+ };
83
+
84
+ // Hour ticks with the date at midnight boundaries, so multi-day spans of
85
+ // sub-daily data keep their days identifiable.
86
+ const formatDateOrHourTick = boundaryFormat(
87
+ date => date.getHours() === 0 && date.getMinutes() === 0,
88
+ formatDateTick,
89
+ formatHourTick
90
+ );
91
+
92
+ // Month ticks with the year at January boundaries, for month-or-coarser
93
+ // buckets where a full "Sep 1" date would misread as a daily point.
94
+ const formatMonthOrYearTick = boundaryFormat(
95
+ date => date.getMonth() === 0,
96
+ formatYearTick,
97
+ formatMonthTick
98
+ );
99
+
100
+ // Overall time span of the data. Series with no dated points are dropped rather
101
+ // than folded in: an empty comparison series is legitimate, and one undefined
102
+ // bound would turn the whole span into NaN. Null when nothing is dated.
103
+ const getSpan = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
104
+ const bounds = sortedData
105
+ .map( datom => [ datom.data.at( 0 )?.date, datom.data.at( -1 )?.date ] )
106
+ .filter( ( [ first, last ] ) => first !== undefined && last !== undefined );
107
+
108
+ if ( ! bounds.length ) {
109
+ return null;
110
+ }
111
+
112
+ return {
113
+ minX: Math.min( ...bounds.map( ( [ first ] ) => Number( first ) ) ),
114
+ maxX: Math.max( ...bounds.map( ( [ , last ] ) => Number( last ) ) ),
115
+ };
43
116
  };
44
117
 
45
- const formatHourTick = ( timestamp: number ) => {
46
- const date = new Date( timestamp );
47
- return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
118
+ // Smallest interval between consecutive points across all series, in hours.
119
+ // Infinity when no series has two points.
120
+ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
121
+ return sortedData.reduce(
122
+ ( spacing, datom ) =>
123
+ datom.data.reduce( ( seriesSpacing, point, index ) => {
124
+ const previous = datom.data[ index - 1 ];
125
+ if ( previous?.date === undefined || point?.date === undefined ) {
126
+ return seriesSpacing;
127
+ }
128
+ return Math.min(
129
+ seriesSpacing,
130
+ Math.abs( differenceInHours( point.date, previous.date ) )
131
+ );
132
+ }, spacing ),
133
+ Number.POSITIVE_INFINITY
134
+ );
48
135
  };
49
136
 
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 > ) => {
53
- const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
54
- const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
137
+ // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
138
+ // spring-forward DST transition.
139
+ const SUB_DAILY_SPACING_HOURS = 23;
140
+
141
+ // The shortest month, so monthly buckets clear it but weekly ones don't.
142
+ const MONTHLY_SPACING_HOURS = 28 * 24;
143
+
144
+ // The bucket size behind the data, as the resolution label the tick formats are
145
+ // keyed on. Exported so consumers that label a single point — bar chart
146
+ // tooltips — read the same classification the axis does instead of inferring
147
+ // the resolution a second way. Weeks report as 'day': both are calendar-date
148
+ // buckets as far as labelling goes.
149
+ export const getBucketResolution = (
150
+ sortedData: ReturnType< typeof useChartDataTransform >,
151
+ tickResolution?: TickResolution
152
+ ): Exclude< TickResolution, 'week' > => {
153
+ if ( tickResolution ) {
154
+ return tickResolution === 'week' ? 'day' : tickResolution;
155
+ }
55
156
 
56
- const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
57
- if ( diffInHours <= 24 ) {
58
- return formatHourTick;
157
+ const spacingInHours = getPointSpacingInHours( sortedData );
158
+ if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
159
+ return 'hour';
59
160
  }
161
+ // Fewer than two points leaves the spacing unknowable, so fall back to
162
+ // calendar dates rather than reading Infinity as a very coarse bucket.
163
+ if ( ! Number.isFinite( spacingInHours ) || spacingInHours < MONTHLY_SPACING_HOURS ) {
164
+ return 'day';
165
+ }
166
+ // Twelve shortest months: above any monthly gap (31 days at most) and below
167
+ // any yearly one (365 days at least).
168
+ return spacingInHours < 12 * MONTHLY_SPACING_HOURS ? 'month' : 'year';
169
+ };
170
+
171
+ // Pick the most informative tick formatter for the data's bucket resolution and
172
+ // time span. Month-or-coarser buckets are formatted from the resolution alone —
173
+ // they carry no day to print at any span — while finer buckets narrow with the
174
+ // span: hours within a day, hours with day boundaries for sub-daily data
175
+ // spanning up to a week, calendar dates within a year, otherwise years.
176
+ export const getFormatter = (
177
+ sortedData: ReturnType< typeof useChartDataTransform >,
178
+ tickResolution?: TickResolution
179
+ ): TickFormat => {
180
+ const resolution = getBucketResolution( sortedData, tickResolution );
60
181
 
61
- const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
62
- if ( diffInYears <= 1 ) {
182
+ // The month regime only prints the year at January boundaries, so yearly
183
+ // buckets starting mid-year would render as bare month names.
184
+ if ( resolution === 'year' ) {
185
+ return formatYearTick;
186
+ }
187
+
188
+ if ( resolution === 'month' ) {
189
+ return formatMonthOrYearTick;
190
+ }
191
+
192
+ const span = getSpan( sortedData );
193
+ if ( ! span ) {
63
194
  return formatDateTick;
64
195
  }
65
196
 
66
- return formatYearTick;
197
+ const diffInHours = Math.abs( differenceInHours( span.maxX, span.minX ) );
198
+ if ( resolution === 'hour' ) {
199
+ if ( diffInHours <= 24 ) {
200
+ return formatHourTick;
201
+ }
202
+ if ( diffInHours <= 24 * 7 ) {
203
+ return formatDateOrHourTick;
204
+ }
205
+ }
206
+
207
+ // Beyond a year, dates repeat often enough under tick sampling that the year
208
+ // is the only part worth printing.
209
+ return Math.abs( differenceInYears( span.maxX, span.minX ) ) <= 1
210
+ ? formatDateTick
211
+ : formatYearTick;
212
+ };
213
+
214
+ // Indices of the buckets whose label carries the coarser unit.
215
+ const getAnchorIndices = ( domain: Date[], isAnchor: ( date: Date ) => boolean ) =>
216
+ domain.reduce< number[] >( ( indices, date, index ) => {
217
+ if ( isAnchor( date ) ) {
218
+ indices.push( index );
219
+ }
220
+ return indices;
221
+ }, [] );
222
+
223
+ // Index strides worth sampling the domain at. Both roundings of each tick count
224
+ // are needed: flooring alone skips the stride that fits a mid-series anchor on
225
+ // short domains. Anchors add the strides that can land on more than one of them
226
+ // — those dividing their spacing, and the smallest whole multiple that still
227
+ // fits, which carries spans too long for any divisor to reach.
228
+ const getCandidateSteps = ( length: number, maxTicks: number, anchorSpacing: number | null ) => {
229
+ const steps = new Set< number >();
230
+ for ( let count = maxTicks; count > 1; count-- ) {
231
+ steps.add( Math.max( 1, Math.floor( ( length - 1 ) / ( count - 1 ) ) ) );
232
+ steps.add( Math.max( 1, Math.ceil( ( length - 1 ) / ( count - 1 ) ) ) );
233
+ }
234
+
235
+ if ( anchorSpacing ) {
236
+ for ( let divisor = 1; divisor <= anchorSpacing; divisor++ ) {
237
+ if ( anchorSpacing % divisor === 0 ) {
238
+ steps.add( anchorSpacing / divisor );
239
+ }
240
+ }
241
+ const minStep = Math.floor( ( length - 1 ) / Math.max( 1, maxTicks ) ) + 1;
242
+ steps.add( Math.ceil( minStep / anchorSpacing ) * anchorSpacing );
243
+ }
244
+
245
+ return steps;
246
+ };
247
+
248
+ /**
249
+ * Tick values for a band time axis.
250
+ *
251
+ * visx samples a band domain by index from offset zero, blind to which labels
252
+ * carry a boundary and without collapsing repeats — so the tick that prints the
253
+ * year or the date often isn't sampled at all, and a long series can show the
254
+ * same label twice. Choose the values instead: sweep the evenly spaced
255
+ * candidates, including those that step from anchor to anchor, and keep the one
256
+ * that reaches the most anchors without thinning the axis or putting two
257
+ * identical labels side by side.
258
+ *
259
+ * @param domain - Band domain, in axis order.
260
+ * @param tickFormatter - Formatter the axis will render these values with.
261
+ * @param maxTicks - Most ticks the axis should carry.
262
+ * @return Values to hand the axis as `tickValues`.
263
+ */
264
+ export const getBandTickValues = (
265
+ domain: Date[],
266
+ tickFormatter: TickFormat,
267
+ maxTicks: number
268
+ ): Date[] => {
269
+ if ( ! domain.length ) {
270
+ return [];
271
+ }
272
+
273
+ const { isAnchor } = tickFormatter;
274
+ const anchorIndices = isAnchor ? getAnchorIndices( domain, isAnchor ) : [];
275
+
276
+ // Once per bucket rather than once per bucket per candidate: the sweep reads
277
+ // the same labels back for every step and offset.
278
+ const domainLabels = domain.map( date => tickFormatter( date.getTime() ) );
279
+
280
+ const candidates: number[][] = [];
281
+ const consider = ( indices: number[] ) => {
282
+ if ( ! indices.length || indices.length > maxTicks ) {
283
+ return;
284
+ }
285
+ const repeats = indices.some(
286
+ ( index, position ) =>
287
+ position > 0 && domainLabels[ index ] === domainLabels[ indices[ position - 1 ] ]
288
+ );
289
+ if ( ! repeats ) {
290
+ candidates.push( indices );
291
+ }
292
+ };
293
+
294
+ const anchorSpacing = anchorIndices.length > 1 ? anchorIndices[ 1 ] - anchorIndices[ 0 ] : null;
295
+ for ( const step of getCandidateSteps( domain.length, maxTicks, anchorSpacing ) ) {
296
+ for ( let offset = 0; offset < step; offset++ ) {
297
+ const indices: number[] = [];
298
+ for ( let index = offset; index < domain.length; index += step ) {
299
+ indices.push( index );
300
+ }
301
+ consider( indices );
302
+ }
303
+ }
304
+
305
+ // Stepping the anchors themselves rather than the domain, so that anchors an
306
+ // uneven number of buckets apart are still all reachable — a wall-clock day
307
+ // is 23 buckets of hourly data across a spring-forward DST transition, and a
308
+ // fixed stride drifts off midnight for the rest of the span.
309
+ for ( let stride = 1; stride <= anchorIndices.length; stride++ ) {
310
+ consider( anchorIndices.filter( ( _, position ) => position % stride === 0 ) );
311
+ }
312
+
313
+ if ( ! candidates.length ) {
314
+ return [ domain[ 0 ] ];
315
+ }
316
+
317
+ // An anchor is worth at most one tick. Ranking anchors above density outright
318
+ // collapsed ordinary monthly axes to two ticks — worse than sampling by index,
319
+ // which at least stayed dense — while ignoring anchors leaves the year unnamed.
320
+ const densest = candidates.reduce( ( most, indices ) => Math.max( most, indices.length ), 0 );
321
+ const anchorsIn = ( indices: number[] ) =>
322
+ isAnchor ? indices.filter( index => isAnchor( domain[ index ] ) ).length : 0;
323
+
324
+ const best = candidates
325
+ .filter( indices => indices.length >= densest - 1 )
326
+ .reduce( ( chosen, indices ) => {
327
+ const gain = anchorsIn( indices ) - anchorsIn( chosen );
328
+ return gain > 0 || ( gain === 0 && indices.length > chosen.length ) ? indices : chosen;
329
+ } );
330
+
331
+ return best.map( index => domain[ index ] );
67
332
  };
68
333
 
69
334
  // Estimate the largest number of x-axis ticks that fit without producing
@@ -74,9 +339,12 @@ export const guessOptimalNumTicks = (
74
339
  chartWidth: number,
75
340
  tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
76
341
  ) => {
77
- const minX = Math.min( ...data.map( datom => datom.data.at( 0 )?.date ) );
78
- const maxX = Math.max( ...data.map( datom => datom.data.at( -1 )?.date ) );
79
- const xScale = scaleTime( { domain: [ minX, maxX ] } );
342
+ const span = getSpan( data );
343
+ if ( ! span ) {
344
+ return 1;
345
+ }
346
+
347
+ const xScale = scaleTime( { domain: [ span.minX, span.maxX ] } );
80
348
 
81
349
  const upperBound = Math.min(
82
350
  data[ 0 ]?.data.length || 3,
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { __ } from '@wordpress/i18n';
3
- import { Button } from '@wordpress/ui';
3
+ import { IconButton } from '@wordpress/ui';
4
4
  import { useCallback, useContext, useMemo, useState } from 'react';
5
5
  import styles from './x-zoom.module.scss';
6
6
  import type { SingleChartRef } from '../single-chart-context';
@@ -172,14 +172,8 @@ export function ZoomClip( {
172
172
 
173
173
  /**
174
174
  * Visible icon-only reset control rendered as an HTML overlay on top of the
175
- * chart container, using the WPDS `Button`. The host should wrap its SVG in a
176
- * `position: relative` container so the button anchors correctly.
177
- *
178
- * `IconButton` would be the natural fit, but it renders a Base UI tooltip whose
179
- * CommonJS `use-sync-external-store` dependency makes Rolldown emit a dynamic
180
- * `require()` into `dist`, which throws on evaluation in WordPress Script
181
- * Module consumers. `Button` gives the same treatment without that dependency;
182
- * the tooltip is replaced by `aria-label` + `title`.
175
+ * chart container, using the WPDS `IconButton`. The host should wrap its SVG in
176
+ * a `position: relative` container so the button anchors correctly.
183
177
  *
184
178
  * @param props - Props.
185
179
  * @param props.onClick - Click handler. Typically the `reset` from `useXZoom`.
@@ -194,44 +188,39 @@ export function ZoomResetButton( { onClick }: { onClick: () => void } ) {
194
188
  event.stopPropagation();
195
189
  }
196
190
  }, [] );
197
- const label = __( 'Reset zoom', 'jetpack-charts' );
198
191
  return (
199
- <Button
192
+ <IconButton
200
193
  className={ styles[ 'x-zoom__reset' ] }
201
194
  onKeyDown={ stopActivationKeys }
202
- aria-label={ label }
203
- title={ label }
195
+ label={ __( 'Reset zoom', 'jetpack-charts' ) }
204
196
  variant="outline"
205
197
  tone="neutral"
206
198
  size="small"
207
199
  onClick={ onClick }
208
200
  data-testid="chart-zoom-reset"
209
- >
210
- <Button.Icon
211
- icon={
212
- <svg
213
- viewBox="0 0 24 24"
214
- fill="none"
215
- stroke="currentColor"
216
- strokeWidth="2"
217
- strokeLinecap="round"
218
- strokeLinejoin="round"
219
- aria-hidden="true"
220
- focusable="false"
221
- >
222
- { /*
223
- Icons render edge-to-edge at 24px, so inset the glyph the
224
- way @wordpress/icons glyphs do (drawn within roughly 4-20
225
- of the viewBox, ~1.5px effective stroke).
226
- */ }
227
- <g transform="translate(2.4 2.4) scale(0.8)">
228
- <circle cx="10" cy="10" r="6" />
229
- <line x1="15" y1="15" x2="20" y2="20" />
230
- <line x1="7" y1="10" x2="13" y2="10" />
231
- </g>
232
- </svg>
233
- }
234
- />
235
- </Button>
201
+ icon={
202
+ <svg
203
+ viewBox="0 0 24 24"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ aria-hidden="true"
210
+ focusable="false"
211
+ >
212
+ { /*
213
+ Icons render edge-to-edge at 24px, so inset the glyph the
214
+ way @wordpress/icons glyphs do (drawn within roughly 4-20
215
+ of the viewBox, ~1.5px effective stroke).
216
+ */ }
217
+ <g transform="translate(2.4 2.4) scale(0.8)">
218
+ <circle cx="10" cy="10" r="6" />
219
+ <line x1="15" y1="15" x2="20" y2="20" />
220
+ <line x1="7" y1="10" x2="13" y2="10" />
221
+ </g>
222
+ </svg>
223
+ }
224
+ />
236
225
  );
237
226
  }
@@ -54,6 +54,6 @@
54
54
  }
55
55
 
56
56
  .legend-item-value {
57
- font-weight: var(--wpds-typography-font-weight-medium, 499);
57
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
58
58
  flex-shrink: 0; // Prevent value from shrinking when text is ellipsized
59
59
  }
@@ -188,7 +188,8 @@ describe( 'BaseLegend', () => {
188
188
  );
189
189
  const labels = screen.getAllByText( /Long Label/ );
190
190
  labels.forEach( label => {
191
- expect( label ).toHaveStyle( { maxWidth: '10rem' } );
191
+ // Note getComputedStyle converts lengths to px.
192
+ expect( label ).toHaveStyle( { maxWidth: '160px' } );
192
193
  } );
193
194
  } );
194
195
 
@@ -8,7 +8,7 @@
8
8
  color: #fff;
9
9
  border-radius: var(--wpds-border-radius-md, 4px);
10
10
  font-size: var(--wpds-typography-font-size-md, 13px);
11
- 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);
11
+ 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);
12
12
  position: absolute;
13
13
  pointer-events: none;
14
14
  transform: translate(-50%, -100%);
@@ -74,8 +74,9 @@ describe( 'TrendIndicator', () => {
74
74
  <TrendIndicator direction="up" value="+10%" style={ { fontSize: '2rem' } } />
75
75
  );
76
76
 
77
+ // Note getComputedStyle converts lengths to px.
77
78
  // eslint-disable-next-line testing-library/no-node-access
78
- expect( container.firstChild ).toHaveStyle( { fontSize: '2rem' } );
79
+ expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
79
80
  } );
80
81
 
81
82
  describe( 'accessibility', () => {
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
5
  font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-medium, 499);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
7
7
  line-height: 1;
8
8
 
9
9
  // Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back