@automattic/charts 2.0.1 → 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 (67) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +107 -48
  3. package/dist/index.cjs +294 -110
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +97 -43
  6. package/dist/index.d.cts +40 -35
  7. package/dist/index.d.ts +40 -35
  8. package/dist/index.js +292 -111
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +1 -1
  13. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  14. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
  15. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  16. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  17. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  18. package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
  19. package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
  20. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  21. package/src/charts/leaderboard-chart/types.ts +0 -2
  22. package/src/charts/line-chart/line-chart.module.scss +10 -5
  23. package/src/charts/line-chart/line-chart.tsx +1 -1
  24. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  25. package/src/charts/pie-chart/pie-chart.tsx +4 -1
  26. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
  27. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  28. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  29. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  30. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  31. package/src/components/legend/private/base-legend.tsx +3 -1
  32. package/src/components/tooltip/accessible-tooltip.tsx +4 -2
  33. package/src/components/tooltip/base-tooltip.tsx +8 -1
  34. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  35. package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
  36. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  37. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  38. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  39. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  40. package/src/hooks/use-xychart-theme.ts +6 -4
  41. package/src/index.ts +9 -2
  42. package/src/providers/chart-context/global-charts-provider.tsx +35 -12
  43. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  44. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  45. package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
  46. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  47. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  48. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  49. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  50. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  51. package/src/providers/chart-context/themes.ts +18 -27
  52. package/src/providers/chart-context/types.ts +0 -3
  53. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  54. package/src/providers/chart-scope/index.ts +3 -0
  55. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  56. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  57. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  58. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  59. package/src/providers/index.ts +1 -0
  60. package/src/styles/chart-scope-class.ts +6 -0
  61. package/src/styles/chart-scope.scss +83 -0
  62. package/src/styles/test/chart-scope.test.ts +165 -0
  63. package/src/types.ts +0 -34
  64. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  65. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  66. package/src/charts/private/grid-control/index.ts +0 -1
  67. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
@@ -60,20 +60,26 @@
60
60
  // row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
61
61
  min-width: var(--wpds-dimension-size-5xs, 4px);
62
62
 
63
- border-radius: var(--a8c-charts-border-radius-leaderboard-bar, var(--a8c--charts--leaderboard--bar--border-radius, 9999px));
63
+ border-radius: var(--a8c-charts-border-radius-leaderboard-bar);
64
64
  z-index: -1; /* places it behind the label */
65
65
 
66
+ // Keep the collapsed state in `from` and never on the element: a dropped
67
+ // animation then fails visible. `both`, not `forwards`, for the
68
+ // backwards fill.
66
69
  &--animated {
67
70
  transform-origin: left;
68
71
  transform-box: fill-box;
69
- transform: scaleX(0);
70
72
  animation:
71
- stretch var(--wpds-motion-duration-xl, 400ms)
72
- var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
73
+ stretch var(--a8c-charts-motion-duration-entrance)
74
+ var(--a8c-charts-motion-easing-entrance) both;
73
75
  }
74
76
 
75
77
  @keyframes stretch {
76
78
 
79
+ from {
80
+ transform: scaleX(0);
81
+ }
82
+
77
83
  to {
78
84
  transform: scaleX(1);
79
85
  }
@@ -104,7 +110,7 @@
104
110
  .emptyState {
105
111
  padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
106
112
  text-align: center;
107
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
113
+ color: var(--a8c-charts-color-label-secondary);
108
114
  font-size: var(--wpds-typography-font-size-md, 13px);
109
115
  font-style: italic;
110
116
  }
@@ -205,7 +211,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
205
211
  border-radius: var(--wpds-border-radius-sm, 2px);
206
212
  outline:
207
213
  $focus-ring-width solid
208
- var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
214
+ var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
209
215
  // Inset by its own width so the outer edge hugs the row boundary: the
210
216
  // outline then sits in the shared row padding, clear of the bar, and stays
211
217
  // inside the overflow:auto container that would clip an outset ring.
@@ -220,7 +226,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
220
226
  bottom: 0;
221
227
  margin-block: auto;
222
228
  opacity: 0;
223
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
229
+ color: var(--a8c-charts-color-label-secondary);
224
230
  transition:
225
231
  opacity var(--wpds-motion-duration-sm, 100ms)
226
232
  var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
@@ -65,8 +65,6 @@ export interface LeaderboardChartProps
65
65
  */
66
66
  style?: React.CSSProperties & {
67
67
  '--a8c-charts-border-radius-leaderboard-bar'?: string;
68
- /** @deprecated Use `--a8c-charts-border-radius-leaderboard-bar`. */
69
- '--a8c--charts--leaderboard--bar--border-radius'?: string;
70
68
  };
71
69
 
72
70
  /**
@@ -1,14 +1,15 @@
1
1
  .line-chart {
2
2
  position: relative;
3
3
 
4
+ // Keep the collapsed state in `from` and never on the element: a dropped
5
+ // animation then fails visible. `both`, not `forwards`, for backwards fill.
4
6
  &--animated {
5
7
 
6
8
  path {
7
9
  transform-origin: 0 95%;
8
- transform: scaleY(0);
9
10
  animation:
10
- rise var(--wpds-motion-duration-xl, 400ms)
11
- var(--wpds-motion-easing-expressive, cubic-bezier(0.25, 0, 0, 1)) forwards;
11
+ rise var(--a8c-charts-motion-duration-entrance)
12
+ var(--a8c-charts-motion-easing-entrance) both;
12
13
  }
13
14
  }
14
15
 
@@ -18,8 +19,8 @@
18
19
 
19
20
  &__tooltip,
20
21
  &__annotation-label-popover {
21
- background: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
22
- color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
22
+ background: var(--a8c-charts-color-surface);
23
+ color: var(--a8c-charts-color-label);
23
24
  padding: var(--wpds-dimension-padding-sm, 8px);
24
25
  }
25
26
 
@@ -110,6 +111,10 @@
110
111
 
111
112
  @keyframes rise {
112
113
 
114
+ from {
115
+ transform: scaleY(0);
116
+ }
117
+
113
118
  to {
114
119
  transform: scaleY(1);
115
120
  }
@@ -193,7 +193,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
193
193
 
194
194
  const providerTheme = useGlobalChartsTheme();
195
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, so read it back rather than paying another getComputedStyle on every render.
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
197
  const resolvedBackgroundColor = theme.backgroundColor ?? providerTheme.backgroundColor;
198
198
  const chartId = useChartId( providedChartId );
199
199
  const chartRef = useRef< HTMLDivElement >( null );
@@ -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,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 ) => {
@@ -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', () => {
@@ -79,6 +81,24 @@ describe( 'TrendIndicator', () => {
79
81
  expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
80
82
  } );
81
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
+ } );
100
+ } );
101
+
82
102
  describe( 'accessibility', () => {
83
103
  it( 'provides aria-label for up trend', () => {
84
104
  const { container } = render( <TrendIndicator direction="up" value="+14%" /> );
@@ -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
@@ -0,0 +1,28 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers/chart-context/global-charts-provider';
3
+ import { ChartScopeContext } from '../../providers/chart-scope';
4
+ import { useXYChartTheme } from '../use-xychart-theme';
5
+ import type { ReactNode } from 'react';
6
+
7
+ // A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches visx (SVG presentation attributes) through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-grid` override set inside the provider tree had no effect on the JS-resolved value — the two paths could disagree. After the fix, `providerTheme` carries the catalog pointer for an overridden role, so both paths resolve through the same CSS cascade and the nearer element wins.
8
+ describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
9
+ it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
10
+ const nestedOverride = document.createElement( 'div' );
11
+ nestedOverride.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( nestedOverride );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <GlobalChartsProvider theme={ { gridStyles: { stroke: 'rgb(255,0,0)' } } }>
16
+ <ChartScopeContext.Provider value={ nestedOverride }>
17
+ { children }
18
+ </ChartScopeContext.Provider>
19
+ </GlobalChartsProvider>
20
+ );
21
+
22
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
23
+
24
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
25
+
26
+ document.body.removeChild( nestedOverride );
27
+ } );
28
+ } );
@@ -1,35 +1,41 @@
1
1
  import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
2
3
  import { useXYChartTheme } from '../use-xychart-theme';
3
4
  import type { SeriesData } from '../../types';
5
+ import type { ReactNode } from 'react';
4
6
 
5
- // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs whenever `data` changes identity, so a dashboard mounting N charts pays the count below N times over.
7
+ // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs only when the scope element attaches or a series colour changes, so a dashboard mounting N charts pays the count below N times over.
6
8
  const STABLE_DATA: SeriesData[] = [];
7
9
 
8
10
  describe( 'useXYChartTheme style reads', () => {
11
+ let scope: HTMLElement;
9
12
  let getComputedStyleSpy: jest.SpyInstance;
10
13
 
11
14
  beforeEach( () => {
12
- document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
15
+ scope = document.createElement( 'div' );
16
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
17
+ document.body.appendChild( scope );
13
18
  getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
14
19
  } );
15
20
 
16
21
  afterEach( () => {
17
22
  getComputedStyleSpy.mockRestore();
18
- document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
19
- document.documentElement.style.removeProperty( '--a8c-charts-color-background' );
20
- document.documentElement.style.removeProperty( '--a8c-charts-color-axis' );
23
+ scope.remove();
21
24
  } );
22
25
 
23
- const renderTheme = ( data: SeriesData[] = STABLE_DATA ) =>
26
+ const renderWithScope = ( data: SeriesData[] = STABLE_DATA ) =>
24
27
  renderHook( ( { series }: { series: SeriesData[] } ) => useXYChartTheme( series ), {
25
28
  initialProps: { series: data },
29
+ wrapper: ( { children }: { children: ReactNode } ) => (
30
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
31
+ ),
26
32
  } );
27
33
 
28
34
  it( 'reads every role from one computed-style snapshot, not one per role', () => {
29
- document.documentElement.style.setProperty( '--a8c-charts-color-background', '#111111' );
30
- document.documentElement.style.setProperty( '--a8c-charts-color-axis', '#222222' );
35
+ scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
36
+ scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
31
37
 
32
- const { result } = renderTheme();
38
+ const { result } = renderWithScope();
33
39
 
34
40
  expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
35
41
  expect( result.current.backgroundColor ).toBe( '#111111' );
@@ -38,7 +44,7 @@ describe( 'useXYChartTheme style reads', () => {
38
44
  } );
39
45
 
40
46
  it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
41
- const { rerender } = renderTheme();
47
+ const { rerender } = renderWithScope();
42
48
 
43
49
  getComputedStyleSpy.mockClear();
44
50
  rerender( { series: STABLE_DATA } );
@@ -48,7 +54,7 @@ describe( 'useXYChartTheme style reads', () => {
48
54
 
49
55
  // A caller passing an inline array literal — `<LineChart data={ [ … ] } />` — hands the hook a new array identity on every render. The memo keys on the series strokes instead, so the theme is not rebuilt and no style is read. Keyed on `data` this cost one query per render, and before the roles shared a snapshot it cost five.
50
56
  it( 'does not rebuild when the caller passes a fresh data array of the same strokes', () => {
51
- const { rerender } = renderTheme( [] );
57
+ const { rerender } = renderWithScope( [] );
52
58
 
53
59
  getComputedStyleSpy.mockClear();
54
60
  rerender( { series: [] } );
@@ -59,7 +65,7 @@ describe( 'useXYChartTheme style reads', () => {
59
65
  it( 'rebuilds when a series stroke actually changes', () => {
60
66
  const withStroke = ( stroke: string ) =>
61
67
  [ { label: 'A', options: { stroke }, data: [] } ] as unknown as SeriesData[];
62
- const { result, rerender } = renderTheme( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
68
+ const { result, rerender } = renderWithScope( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
63
69
 
64
70
  getComputedStyleSpy.mockClear();
65
71
  rerender( { series: withStroke( 'rebeccapurple' ) } );
@@ -70,7 +76,7 @@ describe( 'useXYChartTheme style reads', () => {
70
76
 
71
77
  // The memo key serialises the strokes rather than joining them, so a colour containing a comma or a space survives the round-trip intact.
72
78
  it( 'keeps a stroke that contains separators intact', () => {
73
- const { result } = renderTheme( [
79
+ const { result } = renderWithScope( [
74
80
  { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
75
81
  { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
76
82
  ] as unknown as SeriesData[] );
@@ -0,0 +1,25 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
3
+ import { useXYChartTheme } from '../use-xychart-theme';
4
+ import type { ReactNode } from 'react';
5
+
6
+ describe( 'useXYChartTheme', () => {
7
+ it( 'resolves grid colour against the scope element, not the document root', () => {
8
+ document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#ff0000' );
9
+
10
+ const scope = document.createElement( 'div' );
11
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( scope );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
16
+ );
17
+
18
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
19
+
20
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
21
+
22
+ document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
23
+ document.body.removeChild( scope );
24
+ } );
25
+ } );
@@ -1,11 +1,13 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
+ import { useChartScopeElement } from '../providers/chart-scope';
4
5
  import { createCssVariableResolver } from '../utils';
5
6
  import type { SeriesData } from '../types';
6
7
 
7
8
  export const useXYChartTheme = ( data: SeriesData[] ) => {
8
9
  const theme = useGlobalChartsTheme();
10
+ const scopeElement = useChartScopeElement();
9
11
 
10
12
  // The only thing the theme takes from `data` is the series strokes, so key the memo on those rather than on the array's identity. A caller passing an inline literal — `<LineChart data={ [ … ] } />`, which the stories and several consumers do — otherwise rebuilds the whole theme on every render. Serialised rather than joined: a stroke can be `rgba(0, 0, 0, 0.5)` or `var(--brand, #fff)`, and any separator that reads naturally inside a colour cannot round-trip.
11
13
  const seriesColorKey = JSON.stringify(
@@ -15,10 +17,10 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
15
17
  );
16
18
 
17
19
  return useMemo( () => {
18
- // visx applies grid, axis, and tick-label colors as SVG presentation attributes, where CSS var() cannot resolve. Resolve them to concrete values before handing the theme to buildChartTheme.
20
+ // visx applies grid, axis, and tick-label colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve the catalog token against the chart's own scope element — never :root — so this reads any override set inside the provider tree, the same one a CSS-painted element would inherit.
19
21
  //
20
- // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each.
21
- const resolve = createCssVariableResolver();
22
+ // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each: this memo re-runs only when the scope element attaches or a series colour changes, which for a dashboard of charts is the difference between two style queries per chart and ten.
23
+ const resolve = createCssVariableResolver( scopeElement );
22
24
  const resolveColor = ( value?: string ): string | undefined =>
23
25
  value ? resolve( value ) ?? value : value;
24
26
 
@@ -45,5 +47,5 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
45
47
  fill: resolveColor( theme.svgLabelSmall.fill ),
46
48
  },
47
49
  } );
48
- }, [ theme, seriesColorKey ] );
50
+ }, [ theme, seriesColorKey, scopeElement ] );
49
51
  };