@automattic/charts 2.0.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.
@@ -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', () => {
@@ -184,4 +184,64 @@ describe( 'useChartMargin', () => {
184
184
  // This is larger than the 20px default bottom margin, so it should be used.
185
185
  expect( result.current.bottom ).toBe( 25 );
186
186
  } );
187
+
188
+ describe( 'horizontal y ticks', () => {
189
+ const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
190
+ ...optionsBase,
191
+ axis: {
192
+ ...optionsBase.axis,
193
+ y: { ...optionsBase.axis.y, orientation: Orientation.left, tickFormat },
194
+ },
195
+ } );
196
+
197
+ it( 'measures dated ticks by formatting the raw timestamps once', () => {
198
+ const formatHour = ( timestamp: string | number ) =>
199
+ new Date( timestamp ).toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
200
+ const hourlyData = [
201
+ {
202
+ label: 'Series 1',
203
+ data: [
204
+ { date: new Date( 2024, 0, 1, 6 ), value: 10 },
205
+ { date: new Date( 2024, 0, 1, 7 ), value: 20 },
206
+ ],
207
+ },
208
+ ];
209
+
210
+ renderHook( () =>
211
+ useChartMargin( 300, horizontalOptions( formatHour ), hourlyData, baseTheme, true )
212
+ );
213
+
214
+ const [ ticks, measureFormatter ] = mockGetLongestTickWidth.mock.calls[ 0 ];
215
+ // Raw timestamps, so the formatter runs exactly once — formatting here
216
+ // and again while measuring would date-parse "6 AM" to "Invalid Date".
217
+ expect( ticks ).toEqual( [
218
+ new Date( 2024, 0, 1, 6 ).getTime(),
219
+ new Date( 2024, 0, 1, 7 ).getTime(),
220
+ ] );
221
+ expect( measureFormatter ).toBe( formatHour );
222
+ } );
223
+
224
+ it( "measures labelled ticks through the caller's formatter", () => {
225
+ const withSuffix = ( label: string | number ) => `${ label } (total)`;
226
+ const labelledData = [
227
+ {
228
+ label: 'Series 1',
229
+ data: [
230
+ { label: 'Mon', value: 10 },
231
+ { label: 'Tuesday', value: 20 },
232
+ ],
233
+ },
234
+ ];
235
+
236
+ renderHook( () =>
237
+ useChartMargin( 300, horizontalOptions( withSuffix ), labelledData, baseTheme, true )
238
+ );
239
+
240
+ const [ ticks, measureFormatter ] = mockGetLongestTickWidth.mock.calls[ 0 ];
241
+ // A formatter that lengthens labels has to reach the measurement, or the
242
+ // margin under-measures and the widest label clips at the SVG edge.
243
+ expect( ticks ).toEqual( [ 'Mon', 'Tuesday' ] );
244
+ expect( measureFormatter ).toBe( withSuffix );
245
+ } );
246
+ } );
187
247
  } );
@@ -0,0 +1,83 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useXYChartTheme } from '../use-xychart-theme';
3
+ import type { SeriesData } from '../../types';
4
+
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.
6
+ const STABLE_DATA: SeriesData[] = [];
7
+
8
+ describe( 'useXYChartTheme style reads', () => {
9
+ let getComputedStyleSpy: jest.SpyInstance;
10
+
11
+ beforeEach( () => {
12
+ document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
13
+ getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
14
+ } );
15
+
16
+ afterEach( () => {
17
+ 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' );
21
+ } );
22
+
23
+ const renderTheme = ( data: SeriesData[] = STABLE_DATA ) =>
24
+ renderHook( ( { series }: { series: SeriesData[] } ) => useXYChartTheme( series ), {
25
+ initialProps: { series: data },
26
+ } );
27
+
28
+ 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' );
31
+
32
+ const { result } = renderTheme();
33
+
34
+ expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
35
+ expect( result.current.backgroundColor ).toBe( '#111111' );
36
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
37
+ expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe( '#222222' );
38
+ } );
39
+
40
+ it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
41
+ const { rerender } = renderTheme();
42
+
43
+ getComputedStyleSpy.mockClear();
44
+ rerender( { series: STABLE_DATA } );
45
+
46
+ expect( getComputedStyleSpy ).not.toHaveBeenCalled();
47
+ } );
48
+
49
+ // 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
+ it( 'does not rebuild when the caller passes a fresh data array of the same strokes', () => {
51
+ const { rerender } = renderTheme( [] );
52
+
53
+ getComputedStyleSpy.mockClear();
54
+ rerender( { series: [] } );
55
+
56
+ expect( getComputedStyleSpy ).not.toHaveBeenCalled();
57
+ } );
58
+
59
+ it( 'rebuilds when a series stroke actually changes', () => {
60
+ const withStroke = ( stroke: string ) =>
61
+ [ { label: 'A', options: { stroke }, data: [] } ] as unknown as SeriesData[];
62
+ const { result, rerender } = renderTheme( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
63
+
64
+ getComputedStyleSpy.mockClear();
65
+ rerender( { series: withStroke( 'rebeccapurple' ) } );
66
+
67
+ expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
68
+ expect( result.current.colors[ 0 ] ).toBe( 'rebeccapurple' );
69
+ } );
70
+
71
+ // 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
+ it( 'keeps a stroke that contains separators intact', () => {
73
+ const { result } = renderTheme( [
74
+ { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
75
+ { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
76
+ ] as unknown as SeriesData[] );
77
+
78
+ expect( result.current.colors.slice( 0, 2 ) ).toEqual( [
79
+ 'rgba(0, 0, 0, 0.5)',
80
+ 'var(--brand, #fff)',
81
+ ] );
82
+ } );
83
+ } );
@@ -75,10 +75,9 @@ export const useChartMargin = (
75
75
  const allDataPoints = data.flatMap( series => series.data as DataPointDate[] );
76
76
 
77
77
  if ( horizontal ) {
78
- // When horizontal, y ticks renders fixed tick labels.
79
- return allDataPoints.map(
80
- d => d.label || options.axis?.y?.tickFormat( d.date.getTime(), 0, [] )
81
- );
78
+ // When horizontal, y ticks render the category values; leave them raw so
79
+ // the axis tick formatter is applied exactly once, when measuring below.
80
+ return allDataPoints.map( d => d.label || d.date?.getTime() );
82
81
  }
83
82
 
84
83
  if ( options.axis?.y?.tickValues?.length ) {
@@ -1,22 +1,28 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
- import { resolveCssVariable } from '../utils';
4
+ import { createCssVariableResolver } from '../utils';
5
5
  import type { SeriesData } from '../types';
6
6
 
7
- // visx applies grid, axis, and tick-label colors as SVG presentation attributes,
8
- // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values (or their
9
- // fallbacks) before handing the theme to buildChartTheme.
10
- const resolveColor = ( value?: string ): string | undefined =>
11
- value ? resolveCssVariable( value ) ?? value : value;
12
-
13
7
  export const useXYChartTheme = ( data: SeriesData[] ) => {
14
8
  const theme = useGlobalChartsTheme();
15
9
 
16
- return useMemo( () => {
17
- const seriesColors = ( data ?? [] )
10
+ // 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
+ const seriesColorKey = JSON.stringify(
12
+ ( data ?? [] )
18
13
  .map( series => series.options?.stroke )
19
- .filter( ( color ): color is string => Boolean( color ) );
14
+ .filter( ( color ): color is string => Boolean( color ) )
15
+ );
16
+
17
+ 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.
19
+ //
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
+ const resolveColor = ( value?: string ): string | undefined =>
23
+ value ? resolve( value ) ?? value : value;
24
+
25
+ const seriesColors: string[] = JSON.parse( seriesColorKey );
20
26
 
21
27
  return buildChartTheme( {
22
28
  ...theme,
@@ -39,5 +45,5 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
39
45
  fill: resolveColor( theme.svgLabelSmall.fill ),
40
46
  },
41
47
  } );
42
- }, [ theme, data ] );
48
+ }, [ theme, seriesColorKey ] );
43
49
  };
package/src/types.ts CHANGED
@@ -482,12 +482,12 @@ export type AxisOptions = {
482
482
  tickClassName?: string;
483
483
  tickFormat?: TickFormatter< ScaleInput< AxisScale > >;
484
484
  /**
485
- * Bucket resolution of the data on a time x-axis. When set, the automatic
486
- * tick formatter derives tick formats from it directly instead of
487
- * inferring the resolution from point spacing. The overall time span still
488
- * constrains the choice — e.g. hourly buckets spanning more than a week
489
- * get date ticks, since hour ticks would be unreadable at that span.
490
- * Ignored when `tickFormat` is set.
485
+ * Bucket resolution of the data, set on whichever axis carries the dates.
486
+ * When set, the automatic tick formatter derives tick formats from it
487
+ * directly instead of inferring the resolution from point spacing. For
488
+ * daily-or-finer buckets the overall time span still constrains the choice —
489
+ * e.g. hourly buckets spanning more than a week get date ticks, since hour
490
+ * ticks would be unreadable at that span. Ignored when `tickFormat` is set.
491
491
  */
492
492
  tickResolution?: TickResolution;
493
493
  /**
@@ -30,7 +30,7 @@ export { mergeThemes } from './merge-themes';
30
30
  export * from './color-utils';
31
31
 
32
32
  // CSS utilities
33
- export { resolveCssVariable } from './resolve-css-var';
33
+ export { resolveCssVariable, createCssVariableResolver } from './resolve-css-var';
34
34
 
35
35
  // Font sizing utilities
36
36
  export { resolveFontSize } from './resolve-font-size';
@@ -15,34 +15,57 @@ const CSS_VAR_NAME_PATTERN = /^--[\w-]+$/;
15
15
  * @param element - Optional DOM element to resolve the variable from (defaults to document.documentElement)
16
16
  * @return The resolved value, fallback value, or null if unresolvable
17
17
  */
18
- export const resolveCssVariable = (
19
- value: string,
18
+ export const resolveCssVariable = ( value: string, element?: HTMLElement | null ): string | null =>
19
+ createCssVariableResolver( element )( value );
20
+
21
+ /**
22
+ * Builds a resolver that reads every value from one `getComputedStyle` snapshot.
23
+ *
24
+ * `getComputedStyle` is the expensive half of resolving a token: each call can force the browser to flush pending style, and a chart theme resolves five roles at once. Taking the snapshot once per build turns that into a single query. The reads that share a snapshot happen synchronously inside one memo body, so no style change can land between them — the resolver is single-pass by design and is not meant to be held across renders.
25
+ *
26
+ * @param element - The element to resolve against, or null/undefined for the document root.
27
+ * @return A resolver that resolves each value exactly as `resolveCssVariable` does.
28
+ */
29
+ export function createCssVariableResolver(
20
30
  element?: HTMLElement | null
21
- ): string | null => {
22
- if ( ! value ) {
23
- return null;
24
- }
31
+ ): ( value: string ) => string | null {
32
+ let styles: CSSStyleDeclaration | null = null;
25
33
 
26
- // Check if it's a var() expression: var(--name) or var(--name, fallback)
27
- // Parse manually to avoid regex backtracking vulnerabilities
28
- if ( value.startsWith( 'var(' ) && value.endsWith( ')' ) ) {
29
- const parsed = parseVarExpression( value );
34
+ // Deferred so a resolver for a theme that turns out to hold only literals costs nothing. Only a successful read is kept: caching the null would leave a resolver built before its element is in the document dead for the rest of its life.
35
+ const getStyles = () => {
36
+ if ( ! styles ) {
37
+ styles = computedStyleFor( element );
38
+ }
30
39
 
31
- if ( parsed ) {
32
- const resolved = resolveVariableName( parsed.varName, element );
40
+ return styles;
41
+ };
33
42
 
34
- return resolved || parsed.fallback;
43
+ return ( value: string ): string | null => {
44
+ if ( ! value ) {
45
+ return null;
35
46
  }
36
- }
37
47
 
38
- // Check if it's a plain variable name (starts with --)
39
- if ( value.startsWith( '--' ) ) {
40
- return resolveVariableName( value, element );
41
- }
48
+ // Check if it's a var() expression: var(--name) or var(--name, fallback)
49
+ // Parse manually to avoid regex backtracking vulnerabilities
50
+ if ( value.startsWith( 'var(' ) && value.endsWith( ')' ) ) {
51
+ const parsed = parseVarExpression( value );
52
+
53
+ if ( parsed ) {
54
+ const resolved = readCustomProperty( parsed.varName, getStyles() );
42
55
 
43
- // Return regular values as-is (e.g., '#ffffff', 'red')
44
- return value;
45
- };
56
+ return resolved || parsed.fallback;
57
+ }
58
+ }
59
+
60
+ // Check if it's a plain variable name (starts with --)
61
+ if ( value.startsWith( '--' ) ) {
62
+ return readCustomProperty( value, getStyles() );
63
+ }
64
+
65
+ // Return regular values as-is (e.g., '#ffffff', 'red')
66
+ return value;
67
+ };
68
+ }
46
69
 
47
70
  /**
48
71
  * Parses a var() expression into its variable name and optional fallback.
@@ -87,24 +110,42 @@ function parseVarExpression( expr: string ): { varName: string; fallback: string
87
110
  }
88
111
 
89
112
  /**
90
- * Resolves a plain CSS variable name to its computed value.
113
+ * Takes one computed-style snapshot, or null where there is nothing to read (SSR, or a detached element).
114
+ *
115
+ * @param element - The element to read from, or null/undefined for the document root.
116
+ * @return The computed styles, or null.
117
+ */
118
+ function computedStyleFor( element?: HTMLElement | null ): CSSStyleDeclaration | null {
119
+ if ( typeof window === 'undefined' || typeof document === 'undefined' ) {
120
+ return null;
121
+ }
122
+
123
+ try {
124
+ return getComputedStyle( element || document.documentElement );
125
+ } catch {
126
+ // getComputedStyle throws on a detached element.
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Reads one custom property from a computed-style snapshot.
91
133
  *
92
134
  * @param varName - A CSS variable name like '--my-color'
93
- * @param element - Optional DOM element to resolve from
135
+ * @param styles - The snapshot to read from, or null
94
136
  * @return The computed value or null
95
137
  */
96
- function resolveVariableName( varName: string, element?: HTMLElement | null ): string | null {
97
- if ( typeof window === 'undefined' || typeof document === 'undefined' ) {
138
+ function readCustomProperty( varName: string, styles: CSSStyleDeclaration | null ): string | null {
139
+ if ( ! styles ) {
98
140
  return null;
99
141
  }
100
142
 
101
143
  try {
102
- const targetElement = element || document.documentElement;
103
- const computedValue = getComputedStyle( targetElement ).getPropertyValue( varName ).trim();
144
+ const computedValue = styles.getPropertyValue( varName ).trim();
104
145
 
105
146
  return computedValue || null;
106
147
  } catch {
107
- // Return null if getComputedStyle throws (e.g., detached element)
148
+ // Belt and braces — no engine is known to throw here. Kept because a caller may hand in a stand-in for CSSStyleDeclaration; `resolve-css-var.test.ts` covers exactly that.
108
149
  return null;
109
150
  }
110
151
  }
@@ -0,0 +1,83 @@
1
+ import { createCssVariableResolver } from '../resolve-css-var';
2
+
3
+ describe( 'createCssVariableResolver', () => {
4
+ let originalGetComputedStyle: typeof window.getComputedStyle;
5
+
6
+ beforeEach( () => {
7
+ originalGetComputedStyle = window.getComputedStyle;
8
+ } );
9
+
10
+ afterEach( () => {
11
+ window.getComputedStyle = originalGetComputedStyle;
12
+ } );
13
+
14
+ const mockStyles = ( values: Record< string, string > ) => {
15
+ const spy = jest.fn( () => ( {
16
+ getPropertyValue: ( prop: string ) => values[ prop ] ?? '',
17
+ } ) );
18
+ window.getComputedStyle = spy as unknown as typeof window.getComputedStyle;
19
+
20
+ return spy;
21
+ };
22
+
23
+ it( 'reads many values from a single snapshot', () => {
24
+ const spy = mockStyles( { '--a': '#111', '--b': '#222' } );
25
+ const resolve = createCssVariableResolver();
26
+
27
+ expect( resolve( '--a' ) ).toBe( '#111' );
28
+ expect( resolve( 'var(--b)' ) ).toBe( '#222' );
29
+ expect( spy ).toHaveBeenCalledTimes( 1 );
30
+ } );
31
+
32
+ it( 'takes no snapshot at all for values that need no lookup', () => {
33
+ const spy = mockStyles( {} );
34
+ const resolve = createCssVariableResolver();
35
+
36
+ expect( resolve( '#ffffff' ) ).toBe( '#ffffff' );
37
+ expect( resolve( '' ) ).toBeNull();
38
+ expect( spy ).not.toHaveBeenCalled();
39
+ } );
40
+
41
+ it( 'resolves against the element it was built for', () => {
42
+ const element = document.createElement( 'div' );
43
+ const spy = jest.fn( ( target: Element ) => ( {
44
+ getPropertyValue: () => ( target === element ? '#c029dc' : '#000000' ),
45
+ } ) );
46
+ window.getComputedStyle = spy as unknown as typeof window.getComputedStyle;
47
+
48
+ expect( createCssVariableResolver( element )( '--accent' ) ).toBe( '#c029dc' );
49
+ expect( spy ).toHaveBeenCalledWith( element );
50
+ } );
51
+
52
+ it( 'falls back to the var() fallback when the property is unset', () => {
53
+ mockStyles( {} );
54
+
55
+ expect( createCssVariableResolver()( 'var(--missing, #dbdbdb)' ) ).toBe( '#dbdbdb' );
56
+ } );
57
+
58
+ // A failed read is not cached: a resolver built before its element is in the document would otherwise stay dead for the rest of its life, which is the shape CHARTS-255 introduces.
59
+ it( 'retries after a snapshot that could not be taken', () => {
60
+ const failing = jest.fn( () => {
61
+ throw new Error( 'not attached' );
62
+ } );
63
+ window.getComputedStyle = failing as unknown as typeof window.getComputedStyle;
64
+
65
+ const resolve = createCssVariableResolver();
66
+ expect( resolve( '--a' ) ).toBeNull();
67
+
68
+ mockStyles( { '--a': '#111' } );
69
+ expect( resolve( '--a' ) ).toBe( '#111' );
70
+ } );
71
+
72
+ it( 'matches resolveCssVariable for the values it is given', () => {
73
+ mockStyles( { '--a': '#111' } );
74
+ const resolve = createCssVariableResolver();
75
+
76
+ expect( [
77
+ resolve( '--a' ),
78
+ resolve( 'var(--a)' ),
79
+ resolve( 'var(--z, red)' ),
80
+ resolve( 'red' ),
81
+ ] ).toEqual( [ '#111', '#111', 'red', 'red' ] );
82
+ } );
83
+ } );