@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
@@ -33,37 +33,86 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
33
33
  }
34
34
  };
35
35
 
36
- const formatYearTick = ( timestamp: number ) => {
37
- const date = new Date( timestamp );
38
- 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
+ };
39
52
  };
40
53
 
41
- const formatDateTick = ( timestamp: number ) => {
42
- const date = new Date( timestamp );
43
- 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;
44
71
  };
45
72
 
46
- const formatHourTick = ( timestamp: number ) => {
47
- const date = new Date( timestamp );
48
- return date.toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
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;
49
82
  };
50
83
 
51
84
  // Hour ticks with the date at midnight boundaries, so multi-day spans of
52
85
  // 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
- };
86
+ const formatDateOrHourTick = boundaryFormat(
87
+ date => date.getHours() === 0 && date.getMinutes() === 0,
88
+ formatDateTick,
89
+ formatHourTick
90
+ );
59
91
 
60
92
  // Month ticks with the year at January boundaries, for month-or-coarser
61
93
  // 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' } );
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
+ };
67
116
  };
68
117
 
69
118
  // Smallest interval between consecutive points across all series, in hours.
@@ -85,64 +134,201 @@ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTran
85
134
  );
86
135
  };
87
136
 
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,
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
+ }
156
+
157
+ const spacingInHours = getPointSpacingInHours( sortedData );
158
+ if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
159
+ return 'hour';
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';
97
169
  };
98
170
 
99
- // Pick the most informative tick formatter for the data's resolution and time
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
100
174
  // 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.
175
+ // spanning up to a week, calendar dates within a year, otherwise years.
105
176
  export const getFormatter = (
106
177
  sortedData: ReturnType< typeof useChartDataTransform >,
107
178
  tickResolution?: TickResolution
108
- ) => {
179
+ ): TickFormat => {
180
+ const resolution = getBucketResolution( sortedData, tickResolution );
181
+
109
182
  // 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' ) {
183
+ // buckets starting mid-year would render as bare month names.
184
+ if ( resolution === 'year' ) {
113
185
  return formatYearTick;
114
186
  }
115
187
 
116
- const minX = Math.min( ...sortedData.map( datom => datom.data.at( 0 )?.date ) );
117
- const maxX = Math.max( ...sortedData.map( datom => datom.data.at( -1 )?.date ) );
188
+ if ( resolution === 'month' ) {
189
+ return formatMonthOrYearTick;
190
+ }
191
+
192
+ const span = getSpan( sortedData );
193
+ if ( ! span ) {
194
+ return formatDateTick;
195
+ }
196
+
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
+ }
118
206
 
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;
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
+ }, [] );
125
222
 
126
- const diffInHours = Math.abs( differenceInHours( maxX, minX ) );
127
- if ( diffInHours <= 24 && isSubDaily ) {
128
- return formatHourTick;
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 ) ) ) );
129
233
  }
130
234
 
131
- if ( diffInHours <= 24 * 7 && isSubDaily ) {
132
- return formatDateOrHourTick;
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 );
133
243
  }
134
244
 
135
- const diffInYears = Math.abs( differenceInYears( maxX, minX ) );
136
- if ( diffInYears <= 1 ) {
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;
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 [];
143
271
  }
144
272
 
145
- return formatYearTick;
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 ] );
146
332
  };
147
333
 
148
334
  // Estimate the largest number of x-axis ticks that fit without producing
@@ -153,9 +339,12 @@ export const guessOptimalNumTicks = (
153
339
  chartWidth: number,
154
340
  tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
155
341
  ) => {
156
- const minX = Math.min( ...data.map( datom => datom.data.at( 0 )?.date ) );
157
- const maxX = Math.max( ...data.map( datom => datom.data.at( -1 )?.date ) );
158
- 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 ] } );
159
348
 
160
349
  const upperBound = Math.min(
161
350
  data[ 0 ]?.data.length || 3,
@@ -1,4 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import { useContext } from 'react';
3
+ import { ChartScopeContext } from '../../../../providers/chart-scope';
2
4
  import { withResponsive } from '../index';
3
5
  import type { BaseChartProps } from '../../../../types';
4
6
 
@@ -311,4 +313,18 @@ describe( 'withResponsive', () => {
311
313
  expect( screen.getByTestId( 'responsive-container' ) ).toBeInTheDocument();
312
314
  } );
313
315
  } );
316
+
317
+ it( 'publishes the responsive wrapper element as the chart scope', () => {
318
+ // The catalog is declared on the GlobalChartsProvider wrapper (or a standalone component's own element), not on every chart root, so this wrapper doesn't carry a scope class itself — but it must still be the element the JS bridge resolves scoped CSS variables against, via ChartScopeContext, so it picks up any override set inside the provider tree the same way a CSS-painted sibling would.
319
+ const ScopeProbe = () => {
320
+ const scopeNode = useContext( ChartScopeContext );
321
+ return <span data-testid="scope-probe">{ scopeNode?.dataset.testid }</span>;
322
+ };
323
+ const Chart: ( props: BaseChartProps ) => JSX.Element = () => <ScopeProbe />;
324
+ const Wrapped = withResponsive( Chart );
325
+
326
+ render( <Wrapped data={ [] } /> );
327
+
328
+ expect( screen.getByTestId( 'scope-probe' ) ).toHaveTextContent( 'responsive-wrapper' );
329
+ } );
314
330
  } );
@@ -1,6 +1,7 @@
1
1
  import { useParentSize } from '@visx/responsive';
2
2
  import clsx from 'clsx';
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { ChartScopeContext } from '../../../providers/chart-scope';
4
5
  import styles from './with-responsive.module.scss';
5
6
  import type { BaseChartProps } from '../../../types';
6
7
  import type { ComponentType } from 'react';
@@ -66,6 +67,9 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
66
67
 
67
68
  const hasAspectRatio = aspectRatio !== undefined && aspectRatio > 0;
68
69
 
70
+ // The context needs a re-render when the node attaches; wrapperRef alone cannot provide that, so mirror the node into state from the same callback ref.
71
+ const [ scopeNode, setScopeNode ] = useState< HTMLDivElement | null >( null );
72
+
69
73
  // Keep our own handle on the wrapper so we can read its live height below, and
70
74
  // still hand the node to useParentSize's ref (a callback ref in practice; guard
71
75
  // the object-ref shape too).
@@ -73,6 +77,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
73
77
  const setWrapperRef = useCallback(
74
78
  ( node: HTMLDivElement | null ) => {
75
79
  wrapperRef.current = node;
80
+ setScopeNode( node );
76
81
  if ( typeof parentRef === 'function' ) {
77
82
  parentRef( node );
78
83
  } else if ( parentRef ) {
@@ -160,17 +165,19 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
160
165
  ...( height !== undefined ? { height } : null ),
161
166
  } }
162
167
  >
163
- { hasAspectRatio ? (
164
- <div
165
- data-testid="responsive-content"
166
- className={ styles.content }
167
- style={ { width: boxWidth, height: boxHeight } }
168
- >
169
- { wrappedComponent }
170
- </div>
171
- ) : (
172
- wrappedComponent
173
- ) }
168
+ <ChartScopeContext.Provider value={ scopeNode }>
169
+ { hasAspectRatio ? (
170
+ <div
171
+ data-testid="responsive-content"
172
+ className={ styles.content }
173
+ style={ { width: boxWidth, height: boxHeight } }
174
+ >
175
+ { wrappedComponent }
176
+ </div>
177
+ ) : (
178
+ wrappedComponent
179
+ ) }
180
+ </ChartScopeContext.Provider>
174
181
  </div>
175
182
  );
176
183
  };
@@ -3,9 +3,9 @@
3
3
  // The selection's translucency lives in the SVG opacity properties so
4
4
  // the colors themselves can come straight from WPDS tokens.
5
5
  &__selection {
6
- fill: var(--a8c-charts-color-zoom-selection, var(--wpds-color-background-interactive-brand-strong, #3858e9));
6
+ fill: var(--a8c-charts-color-zoom-selection);
7
7
  fill-opacity: 0.16;
8
- stroke: var(--a8c-charts-color-zoom-selection-stroke, var(--wpds-color-stroke-interactive-brand, #3858e9));
8
+ stroke: var(--a8c-charts-color-zoom-selection-stroke);
9
9
  stroke-opacity: 0.65;
10
10
  stroke-width: var(--wpds-border-width-xs, 1px);
11
11
  pointer-events: none;
@@ -29,7 +29,7 @@
29
29
  // through it. Scoped to the rest state so the WPDS
30
30
  // hover/active/focus backgrounds still apply.
31
31
  &:not(:hover, :active, :focus) {
32
- background-color: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
32
+ background-color: var(--a8c-charts-color-surface);
33
33
  }
34
34
  }
35
35
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react';
14
14
  import { useTextTruncation } from '../../../hooks';
15
15
  import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers';
16
+ import { useStandaloneScopeClass } from '../../../providers/chart-scope';
16
17
  import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils';
17
18
  import styles from './base-legend.module.scss';
18
19
  import type { BaseLegendProps } from '../types';
@@ -102,6 +103,7 @@ export const BaseLegend: ForwardRefExoticComponent<
102
103
 
103
104
  const theme = useGlobalChartsTheme();
104
105
  const context = useContext( GlobalChartsContext );
106
+ const standaloneScopeClass = useStandaloneScopeClass();
105
107
 
106
108
  const legendScale = scaleOrdinal( {
107
109
  domain: items.map( item => item.label ),
@@ -189,7 +191,7 @@ export const BaseLegend: ForwardRefExoticComponent<
189
191
  wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
190
192
  role="list"
191
193
  data-testid={ `legend-${ orientation }` }
192
- className={ clsx( styles.legend, className ) }
194
+ className={ clsx( standaloneScopeClass, styles.legend, className ) }
193
195
  style={ theme.legend?.containerStyles }
194
196
  >
195
197
  { labels.map( ( label, i ) => {
@@ -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
 
@@ -1,5 +1,7 @@
1
1
  import { Tooltip, TooltipContext } from '@visx/xychart';
2
+ import clsx from 'clsx';
2
3
  import { useContext, useEffect, useCallback, useMemo } from 'react';
4
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
3
5
  import type { SeriesData, DataPointDate } from '../../types';
4
6
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
5
7
  import type { ReactNode } from 'react';
@@ -128,7 +130,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
128
130
  tabIndex={ -1 }
129
131
  role="tooltip"
130
132
  aria-atomic="true"
131
- className={ keyboardFocusedClassName }
133
+ className={ clsx( CHART_SCOPE_CLASS, keyboardFocusedClassName ) }
132
134
  data-testid={ `chart-tooltip-${ selectedIndex }` }
133
135
  key={ `chart-tooltip-${ selectedIndex }` }
134
136
  >
@@ -138,7 +140,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
138
140
  }
139
141
 
140
142
  return (
141
- <div role="tooltip" aria-live="polite">
143
+ <div className={ CHART_SCOPE_CLASS } role="tooltip" aria-live="polite">
142
144
  { tooltipContent }
143
145
  </div>
144
146
  );
@@ -1,4 +1,6 @@
1
1
  import { formatNumber } from '@automattic/number-formatters';
2
+ import clsx from 'clsx';
3
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
2
4
  import styles from './base-tooltip.module.scss';
3
5
  import type { CSSProperties, ComponentType, ReactNode } from 'react';
4
6
 
@@ -57,13 +59,18 @@ export const BaseTooltip = ( {
57
59
  renderContainer = true,
58
60
  }: BaseTooltipProps ) => {
59
61
  const content = children || ( data && <Component data={ data } className={ className } /> );
62
+ const standaloneScopeClass = useStandaloneScopeClass();
60
63
 
61
64
  if ( ! renderContainer ) {
62
65
  return content;
63
66
  }
64
67
 
65
68
  return (
66
- <div className={ styles.tooltip } style={ { top, left, ...style } } role="tooltip">
69
+ <div
70
+ className={ clsx( standaloneScopeClass, styles.tooltip ) }
71
+ style={ { top, left, ...style } }
72
+ role="tooltip"
73
+ >
67
74
  { content }
68
75
  </div>
69
76
  );
@@ -1,4 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../../providers/chart-context/global-charts-provider';
3
+ import { CHART_SCOPE_CLASS } from '../../../styles/chart-scope-class';
2
4
  import { TrendIndicator } from '../trend-indicator';
3
5
 
4
6
  describe( 'TrendIndicator', () => {
@@ -74,8 +76,27 @@ describe( 'TrendIndicator', () => {
74
76
  <TrendIndicator direction="up" value="+10%" style={ { fontSize: '2rem' } } />
75
77
  );
76
78
 
79
+ // Note getComputedStyle converts lengths to px.
77
80
  // eslint-disable-next-line testing-library/no-node-access
78
- expect( container.firstChild ).toHaveStyle( { fontSize: '2rem' } );
81
+ expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
82
+ } );
83
+
84
+ describe( 'chart scope class', () => {
85
+ it( 'carries the scope class when rendered with no provider above it', () => {
86
+ render( <TrendIndicator direction="up" value="+10%" /> );
87
+
88
+ expect( screen.getByLabelText( 'Increase: +10%' ) ).toHaveClass( CHART_SCOPE_CLASS );
89
+ } );
90
+
91
+ it( 'does not carry the scope class when rendered inside a GlobalChartsProvider', () => {
92
+ render(
93
+ <GlobalChartsProvider>
94
+ <TrendIndicator direction="up" value="+10%" />
95
+ </GlobalChartsProvider>
96
+ );
97
+
98
+ expect( screen.getByLabelText( 'Increase: +10%' ) ).not.toHaveClass( CHART_SCOPE_CLASS );
99
+ } );
79
100
  } );
80
101
 
81
102
  describe( 'accessibility', () => {
@@ -6,19 +6,16 @@
6
6
  font-weight: var(--wpds-typography-font-weight-emphasis, 499);
7
7
  line-height: 1;
8
8
 
9
- // Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
10
- // to the deprecated --charts-trend-*-color override API and then to the
11
- // accessible WPDS semantic tokens.
12
9
  &--up {
13
- color: var(--a8c-charts-color-trend-up, var(--charts-trend-up-color, var(--wpds-color-foreground-content-success-weak, #008030)));
10
+ color: var(--a8c-charts-color-trend-up);
14
11
  }
15
12
 
16
13
  &--down {
17
- color: var(--a8c-charts-color-trend-down, var(--charts-trend-down-color, var(--wpds-color-foreground-content-error-weak, #cc1818)));
14
+ color: var(--a8c-charts-color-trend-down);
18
15
  }
19
16
 
20
17
  &--neutral {
21
- color: var(--a8c-charts-color-trend-neutral, var(--charts-trend-neutral-color, var(--wpds-color-foreground-content-neutral-weak, #707070)));
18
+ color: var(--a8c-charts-color-trend-neutral);
22
19
  }
23
20
 
24
21
  &__icon {
@@ -1,4 +1,5 @@
1
1
  import clsx from 'clsx';
2
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
2
3
  import styles from './trend-indicator.module.scss';
3
4
  import type { TrendIndicatorProps, TrendDirection } from './types';
4
5
 
@@ -47,10 +48,12 @@ export function TrendIndicator( {
47
48
  showIcon = true,
48
49
  }: TrendIndicatorProps ) {
49
50
  const ariaLabel = `${ DIRECTION_LABELS[ direction ] }: ${ value }`;
51
+ const standaloneScopeClass = useStandaloneScopeClass();
50
52
 
51
53
  return (
52
54
  <span
53
55
  className={ clsx(
56
+ standaloneScopeClass,
54
57
  styles[ 'trend-indicator' ],
55
58
  styles[ `trend-indicator--${ direction }` ],
56
59
  className