@automattic/charts 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.1.7",
66
+ "@automattic/number-formatters": "^1.1.10",
67
67
  "@babel/runtime": "7.29.2",
68
68
  "@react-spring/web": "9.7.5",
69
69
  "@visx/annotation": "^3.12.0",
@@ -84,7 +84,7 @@
84
84
  "@visx/xychart": "^3.12.0",
85
85
  "@wordpress/i18n": "^6.0.0",
86
86
  "@wordpress/icons": "^12.0.0",
87
- "@wordpress/theme": "0.11.0",
87
+ "@wordpress/theme": "0.13.0",
88
88
  "@wordpress/ui": "0.11.0",
89
89
  "clsx": "2.1.1",
90
90
  "date-fns": "^4.1.0",
@@ -110,16 +110,16 @@
110
110
  "@types/react-dom": "18.3.7",
111
111
  "@typescript/native-preview": "7.0.0-dev.20260225.1",
112
112
  "@visx/glyph": "3.12.0",
113
- "@wordpress/components": "32.6.0",
114
- "@wordpress/element": "6.44.0",
115
- "@wordpress/private-apis": "1.44.0",
116
- "babel-jest": "30.3.0",
113
+ "@wordpress/components": "33.1.0",
114
+ "@wordpress/element": "6.46.0",
115
+ "@wordpress/private-apis": "1.46.0",
116
+ "babel-jest": "30.4.1",
117
117
  "babel-plugin-react-remove-properties": "^0.3.1",
118
118
  "esbuild": "0.27.4",
119
119
  "esbuild-plugin-babel": "^0.2.3",
120
120
  "esbuild-sass-plugin": "^3.1.0",
121
121
  "identity-obj-proxy": "^3.0.0",
122
- "jest": "30.3.0",
122
+ "jest": "30.4.2",
123
123
  "jest-extended": "7.0.0",
124
124
  "postcss": "8.5.14",
125
125
  "postcss-modules": "6.0.1",
@@ -1,5 +1,5 @@
1
1
  import { formatNumberCompact } from '@automattic/number-formatters';
2
- import { XYChart, AreaSeries, AreaStack, Grid, Axis } from '@visx/xychart';
2
+ import { XYChart, AnimatedAreaSeries, AnimatedAreaStack, Grid, Axis } from '@visx/xychart';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import {
@@ -135,6 +135,57 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
135
135
  totalPoints: dataSorted[ 0 ]?.data.length || 0,
136
136
  } );
137
137
 
138
+ // Computed from the full data set (ignoring legend visibility) so the y-axis stays
139
+ // fixed when series are toggled off — otherwise visx auto-fits to the remaining data
140
+ // and the chart appears to rescale. Skipped for non-default stack offsets, which
141
+ // reshape the y-extent (`expand` → [0,1], `wiggle`/`silhouette` → centred around
142
+ // zero) — letting visx derive the domain is correct there.
143
+ const fixedYDomain = useMemo< [ number, number ] | undefined >( () => {
144
+ if (
145
+ ! legendInteractive ||
146
+ ! dataSorted.length ||
147
+ ! dataSorted[ 0 ].data.length ||
148
+ ( stacked && stackOffset !== 'none' )
149
+ ) {
150
+ return undefined;
151
+ }
152
+
153
+ if ( stacked ) {
154
+ // d3-stack with `offset: 'none'` stacks positives upward from 0 and
155
+ // negatives downward from 0, so we need both extremes.
156
+ const numPoints = Math.max( ...dataSorted.map( s => s.data.length ) );
157
+ let posMax = 0;
158
+ let negMin = 0;
159
+ for ( let i = 0; i < numPoints; i++ ) {
160
+ let posSum = 0;
161
+ let negSum = 0;
162
+ for ( const series of dataSorted ) {
163
+ const v = Number( series.data[ i ]?.value );
164
+ if ( Number.isNaN( v ) ) continue;
165
+ if ( v >= 0 ) posSum += v;
166
+ else negSum += v;
167
+ }
168
+ if ( posSum > posMax ) posMax = posSum;
169
+ if ( negSum < negMin ) negMin = negSum;
170
+ }
171
+ return [ negMin, posMax ];
172
+ }
173
+
174
+ let max = -Infinity;
175
+ let min = Infinity;
176
+ for ( const series of dataSorted ) {
177
+ for ( const point of series.data ) {
178
+ const v = Number( point?.value );
179
+ if ( ! Number.isNaN( v ) ) {
180
+ if ( v > max ) max = v;
181
+ if ( v < min ) min = v;
182
+ }
183
+ }
184
+ }
185
+ if ( max === -Infinity ) return undefined;
186
+ return [ Math.min( 0, min ), max ];
187
+ }, [ dataSorted, stacked, stackOffset, legendInteractive ] );
188
+
138
189
  const chartOptions = useMemo( () => {
139
190
  const formatter = options?.axis?.x?.tickFormat || getFormatter( dataSorted );
140
191
 
@@ -164,10 +215,11 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
164
215
  nice: true,
165
216
  // Stacked areas should always include zero so the baseline is meaningful.
166
217
  zero: stacked,
218
+ ...( fixedYDomain ? { domain: fixedYDomain } : {} ),
167
219
  ...options?.yScale,
168
220
  },
169
221
  };
170
- }, [ options, dataSorted, width, stacked ] );
222
+ }, [ options, dataSorted, width, stacked, fixedYDomain ] );
171
223
 
172
224
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme );
173
225
 
@@ -191,11 +243,48 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
191
243
  } );
192
244
 
193
245
  const prefersReducedMotion = usePrefersReducedMotion();
246
+ const animationEnabled = !! animation && ! prefersReducedMotion;
194
247
 
195
248
  const accessors = {
196
249
  xAccessor: ( d: DataPointDate ) => d?.date,
197
250
  yAccessor: ( d: DataPointDate ) => d?.value,
198
251
  };
252
+ const zeroYAccessor = useCallback( () => 0, [] );
253
+
254
+ // Hidden series are still registered with visx (so paths can interpolate), but their
255
+ // data points should not appear in the tooltip.
256
+ const visibleLabels = useMemo(
257
+ () => new Set( seriesWithVisibility.filter( s => s.isVisible ).map( s => s.series.label ) ),
258
+ [ seriesWithVisibility ]
259
+ );
260
+ const filteredRenderTooltip = useCallback(
261
+ ( params: Parameters< typeof renderTooltip >[ 0 ] ) => {
262
+ if ( ! legendInteractive ) return renderTooltip( params );
263
+ const datumByKey = params?.tooltipData?.datumByKey;
264
+ if ( ! datumByKey ) return renderTooltip( params );
265
+ const filtered = Object.fromEntries(
266
+ Object.entries( datumByKey ).filter( ( [ key ] ) => visibleLabels.has( key ) )
267
+ );
268
+ if ( Object.keys( filtered ).length === 0 ) return null;
269
+ // `nearestDatum` may still point at a hidden series; re-point it at the first
270
+ // visible entry so consumers that read it (e.g. for the tooltip heading) don't
271
+ // surface hidden-series state.
272
+ const nearestDatum = params?.tooltipData?.nearestDatum;
273
+ const nextNearest =
274
+ nearestDatum && visibleLabels.has( nearestDatum.key )
275
+ ? nearestDatum
276
+ : { ...Object.values( filtered )[ 0 ], distance: nearestDatum?.distance ?? 0 };
277
+ return renderTooltip( {
278
+ ...params,
279
+ tooltipData: {
280
+ ...params.tooltipData,
281
+ datumByKey: filtered,
282
+ nearestDatum: nextNearest,
283
+ } as typeof params.tooltipData,
284
+ } );
285
+ },
286
+ [ renderTooltip, legendInteractive, visibleLabels ]
287
+ );
199
288
 
200
289
  // Defaults that depend on stacked vs overlapping mode.
201
290
  const resolvedFillOpacity = fillOpacity ?? ( stacked ? 0.85 : 0.4 );
@@ -224,6 +313,33 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
224
313
  const visibleSeries = seriesWithVisibility.filter( ( { isVisible } ) => isVisible );
225
314
  const curve = getCurveType( curveType, smoothing );
226
315
 
316
+ // Visx's `<AreaStack>` keys each `<Area>` by `stackIndex-dataKey`, so filtering out
317
+ // a hidden series would shift indexes and remount every path. Instead we keep every
318
+ // series mounted and zero out hidden ones via `yAccessor` — react-spring then
319
+ // interpolates the `d` attribute, giving a smooth "going down" effect for the
320
+ // hidden area and a smooth re-flow for the rest.
321
+ const renderSeries = ( {
322
+ series: seriesData,
323
+ index,
324
+ isVisible,
325
+ }: ( typeof seriesWithVisibility )[ number ] ) => {
326
+ const { color, lineStyles } = getElementStyles( { data: seriesData, index } );
327
+ return (
328
+ <AnimatedAreaSeries
329
+ key={ seriesData?.label || index }
330
+ dataKey={ seriesData?.label }
331
+ data={ seriesData.data as DataPointDate[] }
332
+ xAccessor={ accessors.xAccessor }
333
+ yAccessor={ isVisible || ! legendInteractive ? accessors.yAccessor : zeroYAccessor }
334
+ fill={ color }
335
+ fillOpacity={ resolvedFillOpacity }
336
+ { ...( stacked ? {} : { renderLine: resolvedWithStroke, curve } ) }
337
+ lineProps={ { stroke: color, ...lineStyles } }
338
+ data-testid={ `area-chart-series-${ index }` }
339
+ />
340
+ );
341
+ };
342
+
227
343
  return (
228
344
  <SingleChartContext.Provider
229
345
  value={ {
@@ -241,7 +357,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
241
357
  className={ clsx(
242
358
  'area-chart',
243
359
  styles[ 'area-chart' ],
244
- { [ styles[ 'area-chart--animated' ] ]: animation && ! prefersReducedMotion },
360
+ { [ styles[ 'area-chart--animated' ] ]: animationEnabled },
245
361
  className
246
362
  ) }
247
363
  style={ { width, height } }
@@ -295,62 +411,16 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
295
411
  ) : null }
296
412
 
297
413
  { ! allSeriesHidden && stacked && (
298
- <AreaStack
414
+ <AnimatedAreaStack
299
415
  curve={ curve }
300
416
  offset={ stackOffset }
301
417
  renderLine={ resolvedWithStroke }
302
418
  >
303
- { visibleSeries.map( ( { series: seriesData, index } ) => {
304
- const { color, lineStyles } = getElementStyles( {
305
- data: seriesData,
306
- index,
307
- } );
308
-
309
- return (
310
- <AreaSeries
311
- key={ seriesData?.label || index }
312
- dataKey={ seriesData?.label }
313
- data={ seriesData.data as DataPointDate[] }
314
- { ...accessors }
315
- fill={ color }
316
- fillOpacity={ resolvedFillOpacity }
317
- lineProps={ {
318
- stroke: color,
319
- ...lineStyles,
320
- } }
321
- data-testid={ `area-chart-series-${ index }` }
322
- />
323
- );
324
- } ) }
325
- </AreaStack>
419
+ { seriesWithVisibility.map( renderSeries ) }
420
+ </AnimatedAreaStack>
326
421
  ) }
327
422
 
328
- { ! allSeriesHidden &&
329
- ! stacked &&
330
- visibleSeries.map( ( { series: seriesData, index } ) => {
331
- const { color, lineStyles } = getElementStyles( {
332
- data: seriesData,
333
- index,
334
- } );
335
-
336
- return (
337
- <AreaSeries
338
- key={ seriesData?.label || index }
339
- dataKey={ seriesData?.label }
340
- data={ seriesData.data as DataPointDate[] }
341
- { ...accessors }
342
- fill={ color }
343
- fillOpacity={ resolvedFillOpacity }
344
- renderLine={ resolvedWithStroke }
345
- curve={ curve }
346
- lineProps={ {
347
- stroke: color,
348
- ...lineStyles,
349
- } }
350
- data-testid={ `area-chart-series-${ index }` }
351
- />
352
- );
353
- } ) }
423
+ { ! allSeriesHidden && ! stacked && seriesWithVisibility.map( renderSeries ) }
354
424
 
355
425
  { withTooltips && (
356
426
  <>
@@ -359,7 +429,7 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
359
429
  snapTooltipToDatumX
360
430
  // Stacked mode: yAccessor returns raw value, not stacked y — snapping mispositions.
361
431
  snapTooltipToDatumY={ ! stacked }
362
- renderTooltip={ renderTooltip }
432
+ renderTooltip={ filteredRenderTooltip }
363
433
  showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
364
434
  showHorizontalCrosshair={ withTooltipCrosshairs?.showHorizontal }
365
435
  selectedIndex={ selectedIndex }
@@ -148,6 +148,196 @@ describe( 'AreaChart', () => {
148
148
  } );
149
149
  } );
150
150
 
151
+ describe( 'Interactive legend', () => {
152
+ test( 'keeps every series mounted after hiding one (stacked)', async () => {
153
+ const user = userEvent.setup();
154
+ renderWithProvider( {
155
+ showLegend: true,
156
+ chartId: 'test-interactive-stacked',
157
+ legend: { interactive: true },
158
+ } );
159
+
160
+ expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
161
+ expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
162
+
163
+ await user.click( screen.getByText( 'Series A' ) );
164
+
165
+ // Both series remain in the DOM (hidden one is zeroed via yAccessor,
166
+ // not unmounted) so that visx stack indices stay stable.
167
+ expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
168
+ expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
169
+ } );
170
+
171
+ test( 'keeps every series mounted after hiding one (unstacked)', async () => {
172
+ const user = userEvent.setup();
173
+ renderWithProvider( {
174
+ stacked: false,
175
+ showLegend: true,
176
+ chartId: 'test-interactive-unstacked',
177
+ legend: { interactive: true },
178
+ } );
179
+
180
+ await user.click( screen.getByText( 'Series A' ) );
181
+
182
+ expect( screen.getByTestId( 'area-chart-series-0' ) ).toBeInTheDocument();
183
+ expect( screen.getByTestId( 'area-chart-series-1' ) ).toBeInTheDocument();
184
+ } );
185
+
186
+ test( 'tooltip omits hidden series after toggle', async () => {
187
+ const user = userEvent.setup();
188
+ renderWithProvider( {
189
+ showLegend: true,
190
+ chartId: 'test-interactive-tooltip',
191
+ legend: { interactive: true },
192
+ } );
193
+
194
+ await user.click( screen.getByText( 'Series A' ) );
195
+
196
+ // Open a tooltip via keyboard navigation, then verify the hidden
197
+ // series' label is absent from the rendered tooltip rows.
198
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
199
+ chart.focus();
200
+ await user.keyboard( '{ArrowRight}' );
201
+
202
+ const tooltip = await screen.findByRole( 'tooltip' );
203
+ expect( tooltip ).not.toHaveTextContent( 'Series A' );
204
+ expect( tooltip ).toHaveTextContent( 'Series B' );
205
+ } );
206
+
207
+ test( 'renderTooltip receives only visible series in datumByKey', async () => {
208
+ const user = userEvent.setup();
209
+ const renderTooltip = jest.fn( () => <div>tooltip</div> );
210
+ renderWithProvider( {
211
+ showLegend: true,
212
+ chartId: 'test-interactive-render-tooltip',
213
+ legend: { interactive: true },
214
+ renderTooltip,
215
+ } );
216
+
217
+ await user.click( screen.getByText( 'Series A' ) );
218
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
219
+ chart.focus();
220
+ await user.keyboard( '{ArrowRight}' );
221
+
222
+ // `renderTooltip` may be called for non-keyboard events too, but the
223
+ // keyboard-driven call must have filtered datumByKey down to visible series.
224
+ const calls = renderTooltip.mock.calls as unknown as Array<
225
+ [
226
+ {
227
+ tooltipData?: {
228
+ datumByKey?: Record< string, unknown >;
229
+ nearestDatum?: { key: string };
230
+ };
231
+ },
232
+ ]
233
+ >;
234
+ const keyboardCall = calls.find( ( [ params ] ) => params?.tooltipData?.datumByKey );
235
+ expect( keyboardCall ).toBeDefined();
236
+ const keys = Object.keys( keyboardCall![ 0 ].tooltipData!.datumByKey! );
237
+ expect( keys ).toContain( 'Series B' );
238
+ expect( keys ).not.toContain( 'Series A' );
239
+ // And `nearestDatum` should never point at a hidden series.
240
+ expect( keyboardCall![ 0 ].tooltipData?.nearestDatum?.key ).not.toBe( 'Series A' );
241
+ } );
242
+
243
+ test( 'y-axis domain stays fixed across legend toggles', async () => {
244
+ const user = userEvent.setup();
245
+ const ref = createRef< SingleChartRef >();
246
+ render(
247
+ <GlobalChartsProvider>
248
+ <AreaChartUnresponsive
249
+ { ...defaultProps }
250
+ showLegend
251
+ chartId="test-interactive-domain"
252
+ legend={ { interactive: true } }
253
+ ref={ ref }
254
+ />
255
+ </GlobalChartsProvider>
256
+ );
257
+
258
+ const initialDomain = (
259
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
260
+ )?.domain();
261
+ expect( initialDomain ).toBeDefined();
262
+
263
+ await user.click( screen.getByText( 'Series A' ) );
264
+
265
+ const afterToggleDomain = (
266
+ ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
267
+ )?.domain();
268
+ expect( afterToggleDomain ).toEqual( initialDomain );
269
+ } );
270
+
271
+ test( 'supports negative stacked values without clipping', () => {
272
+ const ref = createRef< SingleChartRef >();
273
+ render(
274
+ <GlobalChartsProvider>
275
+ <AreaChartUnresponsive
276
+ width={ 500 }
277
+ height={ 300 }
278
+ chartId="test-interactive-negative"
279
+ showLegend
280
+ legend={ { interactive: true } }
281
+ data={ [
282
+ {
283
+ label: 'Pos',
284
+ data: [
285
+ { date: new Date( '2024-01-01' ), value: 10 },
286
+ { date: new Date( '2024-01-02' ), value: 20 },
287
+ ],
288
+ },
289
+ {
290
+ label: 'Neg',
291
+ data: [
292
+ { date: new Date( '2024-01-01' ), value: -5 },
293
+ { date: new Date( '2024-01-02' ), value: -15 },
294
+ ],
295
+ },
296
+ ] }
297
+ ref={ ref }
298
+ />
299
+ </GlobalChartsProvider>
300
+ );
301
+
302
+ const [ min, max ] =
303
+ ( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain() ??
304
+ [];
305
+ expect( min ).toBeLessThanOrEqual( -15 );
306
+ expect( max ).toBeGreaterThanOrEqual( 20 );
307
+ } );
308
+
309
+ test( 'does not pin domain for non-default stack offsets', () => {
310
+ const ref = createRef< SingleChartRef >();
311
+ render(
312
+ <GlobalChartsProvider>
313
+ <AreaChartUnresponsive
314
+ { ...defaultProps }
315
+ chartId="test-interactive-expand"
316
+ showLegend
317
+ legend={ { interactive: true } }
318
+ stackOffset="expand"
319
+ ref={ ref }
320
+ />
321
+ </GlobalChartsProvider>
322
+ );
323
+
324
+ // `expand` normalises to [0,1]; if we accidentally pinned the raw-sum
325
+ // domain (e.g. [0, 35]), the top of the domain would be far above 1.
326
+ const [ min, max ] =
327
+ ( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain() ??
328
+ [];
329
+ expect( min ).toBeGreaterThanOrEqual( 0 );
330
+ expect( max ).toBeLessThanOrEqual( 1.001 );
331
+ } );
332
+ } );
333
+
334
+ describe( 'Without GlobalChartsProvider', () => {
335
+ test( 'self-wraps in a provider when none is present', () => {
336
+ render( <AreaChartUnresponsive { ...defaultProps } /> );
337
+ expect( screen.getByRole( 'grid', { name: /area chart/i } ) ).toBeInTheDocument();
338
+ } );
339
+ } );
340
+
151
341
  describe( 'Accessibility', () => {
152
342
  test( 'chart container has expected ARIA attributes', () => {
153
343
  renderWithProvider();
@@ -233,6 +423,19 @@ describe( 'AreaChart', () => {
233
423
  expect( glyphs ).toHaveLength( 2 );
234
424
  } );
235
425
 
426
+ test( 'renders glyphs in unstacked mode with interactive legend', async () => {
427
+ renderWithProvider( {
428
+ stacked: false,
429
+ showLegend: true,
430
+ chartId: 'test-unstacked-interactive',
431
+ legend: { interactive: true },
432
+ } );
433
+ await focusFirstDatum();
434
+
435
+ const glyphs = screen.getAllByTestId( /^area-chart-hover-glyph-/ );
436
+ expect( glyphs ).toHaveLength( 2 );
437
+ } );
438
+
236
439
  test( 'renders glyphs only for series with a datum at the hovered x (mismatched x-domains)', async () => {
237
440
  // Each series' data[0] is a different date. Keyboard nav fires
238
441
  // showTooltip for every series at its own data[selectedIndex];
package/AGENTS.md DELETED
@@ -1,78 +0,0 @@
1
- # AGENTS.md
2
-
3
- Package-specific guidance for AI agents working in `projects/js-packages/charts`.
4
-
5
- ## CRITICAL Rules
6
-
7
- - Do not invent behavior in docs. If unsure, verify implementation and stories first.
8
- - Do not assume wildcard exports like `./*` or `./providers/*` — they don't exist. Check the explicit exports in `package.json`.
9
-
10
- ## Changelog
11
-
12
- Run from monorepo root:
13
-
14
- ```bash
15
- jp changelog add js-packages/charts -s patch -t changed -e "Charts: <user-facing change>."
16
- ```
17
-
18
- ## Architecture Decisions (Do Not "Fix" These)
19
-
20
- - Accessibility behavior (keyboard navigation, accessible tooltips) is core chart behavior, not optional polish.
21
- - Charts are responsive by default — do not add external responsive wrappers that conflict with built-in sizing semantics.
22
-
23
- ## WordPress UI + Theme Integration
24
-
25
- The package is migrating to WordPress UI and Theme as its defaults. When adding or changing code, follow these defaults unless the task explicitly says otherwise:
26
-
27
- - **Design tokens (WPDS).** In SCSS, use `var(--wpds-dimension-*, <fallback>)`, `var(--wpds-border-*, <fallback>)`, and `var(--wpds-typography-*, <fallback>)` instead of hardcoded px values for spacing, padding, margins, border radius, border width, font size, and font weight. Fallbacks must match the WPDS spec value for that token — do not invent fallback values.
28
- - **UI primitives.** Prefer `Stack` and the stable `Text` from `@wordpress/ui` over ad-hoc flexbox or raw `<span>`/`<div>` for layout and text. Do not use `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) — use the stable `@wordpress/ui` equivalents. Exception: `__experimentalGrid` has no stable alternative yet and is acceptable to use for now.
29
- - **Theming.** Theming flows through `@wordpress/theme`'s `ThemeProvider` (unlocked via private APIs in Storybook; see `src/stories/chart-decorator.tsx`). Do not manually override DS tokens in stories or components to achieve theming — pass a color through `ThemeProvider` instead.
30
- - **Chart element styles.** Read chart element styles via `getElementStyles` from `GlobalChartsProvider`, not directly from `theme`. This is the supported path for color/style resolution across themes.
31
-
32
- ## Documentation Workflow
33
-
34
- - For docs tasks agents should use the skill at `.agents/skills/charts-docs.md`.
35
- - For public chart/component docs, maintain the standard set when applicable: `[feature-name].stories.tsx` + `.docs.mdx` + `.api.mdx`. Some docs are intentionally guide-only and skip the full triplet.
36
- - Only include animation docs when the component actually supports an `animation` prop.
37
-
38
- ## Conventions
39
-
40
- - Preserve backward compatibility for existing public APIs unless a breaking change is explicitly requested.
41
- - Prefer extending existing chart components/patterns over introducing new surface area.
42
- - Reuse existing hooks/providers/utilities before adding new abstractions.
43
- - Avoid `!important` unless there is no viable alternative and the rationale is documented.
44
- - Add focused behavioral tests for changed behavior; avoid speculative tests for unimplemented behavior.
45
- - Verify behavior/UI changes in Storybook using browser automation, not only unit tests.
46
- - Prefer charts-scoped PR titles (e.g. `Charts: ...`, `CHARTS-###: ...`).
47
- - Include test steps and visual evidence (screenshots/GIFs) in PR descriptions for UI changes.
48
-
49
- ## Common Pitfalls
50
-
51
- - Claiming Rollup is used for builds (it's tsup).
52
- - Documenting props or behavior not present in stories and implementation.
53
- - Refactoring core composition/provider patterns as if they are accidental complexity.
54
- - Defining new chart prop interfaces that diverge from established base chart contracts (for example, not aligning with `BaseChartProps` when appropriate).
55
- - Using ad-hoc flexbox layouts where established layout primitives (e.g. `Stack` from `@wordpress/ui`) should be preferred.
56
- - Accessing colors/styles directly from `theme` rather than using `getElementStyles` from `GlobalChartsProvider`.
57
- - Hardcoding px values in SCSS for spacing, borders, or typography where a WPDS token (`--wpds-dimension-*`, `--wpds-border-*`, `--wpds-typography-*`) exists.
58
- - CSS variable fallback values that diverge from the WPDS spec for that token.
59
- - Using `__experimental*` exports from `@wordpress/components` (e.g. `__experimentalText`, `__experimentalHStack`) instead of the stable `@wordpress/ui` equivalents. (`__experimentalGrid` is excepted — no stable alternative exists yet.)
60
- - Manually overriding DS tokens in stories or components to achieve theming instead of passing a color through `@wordpress/theme`'s `ThemeProvider`.
61
- - Responsive wrappers that conflict with component sizing semantics (fixed-height charts, resize behavior, aspect-ratio assumptions).
62
- - Updating `.docs.mdx` without the corresponding `.api.mdx` when API docs are affected.
63
- - Not checking CSF file references in `.docs.mdx` when changing or removing stories.
64
- - Stories that don't visibly demonstrate documented behavior/props, or render clipped due to container sizing.
65
- - Breaking MDX `<Source code={\`...\` } />` rendering by malformed/flattened indentation inside template literals.
66
- - Tooltip styles/positioning that only work on default backgrounds or fail at chart edges.
67
- - Using mock/placeholder series data in production code.
68
- - Avoidable multi-pass data transformations in render paths when a single pass suffices.
69
- - CSS layout/overflow workarounds without documenting why they're needed.
70
-
71
- ## Definition of Done
72
-
73
- - Behavior verified in Storybook and/or tests, not only by static checks.
74
- - Edits remain in package boundaries; avoid unrelated refactors.
75
-
76
- ## References
77
-
78
- - Published Storybook: `https://automattic.github.io/jetpack-storybook/?path=/docs/js-packages-charts-library`