@automattic/charts 1.11.0 → 1.12.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 (31) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/TOKENS.md +12 -1
  3. package/dist/index.cjs +751 -97
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +11 -28
  6. package/dist/index.d.cts +58 -6
  7. package/dist/index.d.ts +58 -6
  8. package/dist/index.js +751 -97
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -1
  11. package/src/charts/area-chart/area-chart.tsx +28 -8
  12. package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
  13. package/src/charts/area-chart/types.ts +20 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +33 -9
  15. package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
  16. package/src/charts/line-chart/line-chart.tsx +40 -5
  17. package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
  18. package/src/charts/line-chart/types.ts +14 -0
  19. package/src/charts/private/x-zoom/index.ts +1 -0
  20. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
  21. package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
  22. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
  23. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  24. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  25. package/src/components/legend/private/base-legend.tsx +21 -9
  26. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  27. package/src/components/legend/test/legend.test.tsx +121 -1
  28. package/src/components/legend/types.ts +6 -0
  29. package/src/index.ts +1 -0
  30. package/src/types.ts +16 -0
  31. package/src/charts/private/x-zoom.module.scss +0 -50
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
2
 
3
- import { render, screen, waitFor } from '@testing-library/react';
3
+ import { render, screen, waitFor, within } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { GlyphDiamond } from '@visx/glyph';
6
6
  import { createElement, createRef } from 'react';
@@ -1294,4 +1294,193 @@ describe( 'LineChart', () => {
1294
1294
  'url(#chart-zoom-clip-zoomtest)'
1295
1295
  );
1296
1296
  } );
1297
+
1298
+ describe( 'Legend group collapsing', () => {
1299
+ const comparisonPair = [
1300
+ {
1301
+ label: 'Views',
1302
+ group: 'views',
1303
+ data: [
1304
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1305
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1306
+ ],
1307
+ },
1308
+ {
1309
+ label: 'Views — previous',
1310
+ group: 'views',
1311
+ options: { type: 'comparison' as const },
1312
+ data: [
1313
+ { date: new Date( '2024-01-01' ), value: 8, label: 'Jan 1' },
1314
+ { date: new Date( '2024-01-02' ), value: 16, label: 'Jan 2' },
1315
+ ],
1316
+ },
1317
+ ];
1318
+
1319
+ it( 'renders one legend item per series by default', () => {
1320
+ renderWithTheme( {
1321
+ showLegend: true,
1322
+ chartId: 'legend-groups-default',
1323
+ data: comparisonPair,
1324
+ } );
1325
+
1326
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1327
+ expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
1328
+ } );
1329
+
1330
+ it( 'collapses a group to one item when legend.collapseGroups is set', () => {
1331
+ renderWithTheme( {
1332
+ showLegend: true,
1333
+ legend: { collapseGroups: true },
1334
+ chartId: 'legend-groups-collapsed',
1335
+ data: comparisonPair,
1336
+ } );
1337
+
1338
+ expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
1339
+ expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
1340
+ } );
1341
+
1342
+ it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
1343
+ const user = userEvent.setup();
1344
+
1345
+ renderWithTheme( {
1346
+ showLegend: true,
1347
+ legend: { interactive: true },
1348
+ chartId: 'legend-groups-interactive',
1349
+ data: comparisonPair,
1350
+ } );
1351
+
1352
+ const buttons = screen.getAllByRole( 'button' );
1353
+ await user.click( buttons[ 0 ] );
1354
+
1355
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1356
+ expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
1357
+ } );
1358
+
1359
+ it( 'toggles the whole group when interactive with collapseGroups', async () => {
1360
+ const user = userEvent.setup();
1361
+
1362
+ renderWithTheme( {
1363
+ showLegend: true,
1364
+ legend: { interactive: true, collapseGroups: true },
1365
+ chartId: 'legend-groups-interactive-collapsed',
1366
+ data: comparisonPair,
1367
+ } );
1368
+
1369
+ const buttons = screen.getAllByRole( 'button' );
1370
+ expect( buttons ).toHaveLength( 1 );
1371
+
1372
+ await user.click( buttons[ 0 ] );
1373
+
1374
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
1375
+ expect(
1376
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1377
+ ).toBeInTheDocument();
1378
+ } );
1379
+ } );
1380
+
1381
+ describe( 'Interactive Legend — value axis', () => {
1382
+ const twoSeries = [
1383
+ {
1384
+ label: 'Views',
1385
+ data: [
1386
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1387
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1388
+ ],
1389
+ options: {},
1390
+ },
1391
+ {
1392
+ label: 'Visitors',
1393
+ data: [
1394
+ { date: new Date( '2024-01-01' ), value: 100, label: 'Jan 1' },
1395
+ { date: new Date( '2024-01-02' ), value: 200, label: 'Jan 2' },
1396
+ ],
1397
+ options: {},
1398
+ },
1399
+ ];
1400
+ // Bare numbers inside the plot (scoped via the chart's grid role, away from visx's
1401
+ // off-screen text-measurement SVGs) are value-axis ticks: the series labels carry no digits
1402
+ // and the empty-state message is text-only.
1403
+ const numericTick = /^[\d,]+$/;
1404
+
1405
+ it( 'pins the value axis when rescaleYOnVisibilityChange is false', async () => {
1406
+ const user = userEvent.setup();
1407
+
1408
+ renderWithTheme( {
1409
+ showLegend: true,
1410
+ legend: { interactive: true },
1411
+ rescaleYOnVisibilityChange: false,
1412
+ chartId: 'line-stable-axis',
1413
+ data: twoSeries,
1414
+ } );
1415
+
1416
+ const chart = screen.getByRole( 'grid' );
1417
+ const ticksBefore = within( chart )
1418
+ .getAllByText( numericTick )
1419
+ .map( el => el.textContent )
1420
+ .sort();
1421
+ expect( ticksBefore.length ).toBeGreaterThan( 0 );
1422
+
1423
+ // Hide the high-range Visitors series; with a pinned domain the axis keeps its ticks.
1424
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1425
+
1426
+ const ticksAfter = within( chart )
1427
+ .getAllByText( numericTick )
1428
+ .map( el => el.textContent )
1429
+ .sort();
1430
+ expect( ticksAfter ).toEqual( ticksBefore );
1431
+ } );
1432
+
1433
+ it( 'rescales the value axis by default when a series is toggled off', async () => {
1434
+ const user = userEvent.setup();
1435
+
1436
+ renderWithTheme( {
1437
+ showLegend: true,
1438
+ legend: { interactive: true },
1439
+ chartId: 'line-rescale-axis',
1440
+ data: twoSeries,
1441
+ } );
1442
+
1443
+ const chart = screen.getByRole( 'grid' );
1444
+ const ticksBefore = within( chart )
1445
+ .getAllByText( numericTick )
1446
+ .map( el => el.textContent )
1447
+ .sort();
1448
+ expect( ticksBefore.length ).toBeGreaterThan( 0 );
1449
+
1450
+ // Default preserves the pre-existing behaviour: hiding the high-range Visitors series
1451
+ // lets the axis rescale to the remaining Views range, so its ticks change.
1452
+ await user.click( screen.getAllByRole( 'button' )[ 1 ] );
1453
+
1454
+ const ticksAfter = within( chart )
1455
+ .getAllByText( numericTick )
1456
+ .map( el => el.textContent )
1457
+ .sort();
1458
+ expect( ticksAfter ).not.toEqual( ticksBefore );
1459
+ } );
1460
+
1461
+ it( 'drops the axes when all series are hidden so they do not collapse', async () => {
1462
+ const user = userEvent.setup();
1463
+
1464
+ renderWithTheme( {
1465
+ showLegend: true,
1466
+ legend: { interactive: true },
1467
+ chartId: 'line-hidden-axes',
1468
+ data: twoSeries,
1469
+ } );
1470
+
1471
+ const chart = screen.getByRole( 'grid' );
1472
+ expect( within( chart ).getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
1473
+
1474
+ const buttons = screen.getAllByRole( 'button' );
1475
+ await user.click( buttons[ 0 ] );
1476
+ await user.click( buttons[ 1 ] );
1477
+
1478
+ // With no visible data the value scale would collapse, so the axes are removed rather
1479
+ // than rendered squished at the top — no tick labels remain.
1480
+ expect( within( chart ).queryAllByText( numericTick ) ).toHaveLength( 0 );
1481
+ expect(
1482
+ screen.getByText( /all series are hidden.*click legend items to show data/i )
1483
+ ).toBeInTheDocument();
1484
+ } );
1485
+ } );
1297
1486
  } );
@@ -2,6 +2,7 @@ import type {
2
2
  BaseChartProps,
3
3
  DataPointDate,
4
4
  SeriesData,
5
+ SeriesChartLegendConfig,
5
6
  AnnotationStyles,
6
7
  DataPoint,
7
8
  } from '../../types';
@@ -28,6 +29,10 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
28
29
  };
29
30
 
30
31
  export interface LineChartProps extends BaseChartProps< SeriesData[] > {
32
+ /**
33
+ * Legend configuration. Supports `collapseGroups` on top of the shared options.
34
+ */
35
+ legend?: SeriesChartLegendConfig;
31
36
  withGradientFill: boolean;
32
37
  smoothing?: boolean;
33
38
  curveType?: CurveType;
@@ -47,6 +52,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
47
52
  * button appears in the top-right of the chart while zoomed.
48
53
  */
49
54
  zoomable?: boolean;
55
+ /**
56
+ * Whether the Y axis rescales to fit only the visible series when series are
57
+ * hidden or shown through the interactive legend.
58
+ * Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
59
+ * axis to the full data extent so hiding series does not move the chart's
60
+ * baseline — useful for comparison charts. Matches `AreaChart`.
61
+ * @default true
62
+ */
63
+ rescaleYOnVisibilityChange?: boolean;
50
64
  children?: ReactNode;
51
65
  }
52
66
 
@@ -0,0 +1 @@
1
+ export { useXZoom, ZoomClip, ZoomResetButton, ZoomSelectionRect } from './x-zoom';
@@ -1,8 +1,10 @@
1
- import { act, renderHook } from '@testing-library/react';
1
+ import { act, render, renderHook, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { useRef } from 'react';
3
- import { useXZoom } from '../x-zoom';
4
- import type { SingleChartRef } from '../single-chart-context';
4
+ import { useXZoom, ZoomResetButton } from '../index';
5
+ import type { SingleChartRef } from '../../single-chart-context';
5
6
  import type { EventHandlerParams } from '@visx/xychart';
7
+ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
6
8
 
7
9
  // A fake scale that lets the hook convert pixel positions to data values.
8
10
  // The hook only uses `.invert()`; everything else can be stubbed.
@@ -140,3 +142,44 @@ describe( 'useXZoom', () => {
140
142
  expect( result.current.domain ).toBeNull();
141
143
  } );
142
144
  } );
145
+
146
+ const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
147
+ event.preventDefault();
148
+
149
+ describe( 'ZoomResetButton', () => {
150
+ test( 'renders a labelled button with a hover tooltip', () => {
151
+ const noop = jest.fn();
152
+ render( <ZoomResetButton onClick={ noop } /> );
153
+ const button = screen.getByTestId( 'chart-zoom-reset' );
154
+ expect( button.tagName ).toBe( 'BUTTON' );
155
+ expect( button ).toHaveClass( 'x-zoom__reset' );
156
+ expect( button ).toHaveAccessibleName( 'Reset zoom' );
157
+ // WPDS `IconButton` would supply a tooltip of its own, but it pulls in a
158
+ // CommonJS dependency that breaks Script Module consumers (see
159
+ // ZoomResetButton). `title` restores the hover hint on plain `Button`.
160
+ expect( button ).toHaveAttribute( 'title', 'Reset zoom' );
161
+ } );
162
+
163
+ test( 'fires onClick when activated', async () => {
164
+ const onClick = jest.fn();
165
+ render( <ZoomResetButton onClick={ onClick } /> );
166
+ await userEvent.click( screen.getByTestId( 'chart-zoom-reset' ) );
167
+ expect( onClick ).toHaveBeenCalledTimes( 1 );
168
+ } );
169
+
170
+ test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
171
+ const onClick = jest.fn();
172
+ // Mirrors the chart's grid wrapper, whose keyboard-navigation handler
173
+ // calls preventDefault() on bubbled keydowns — which would cancel the
174
+ // native Enter/Space button activation.
175
+ render(
176
+ <div role="grid" tabIndex={ 0 } onKeyDown={ preventDefaultKeydown }>
177
+ <ZoomResetButton onClick={ onClick } />
178
+ </div>
179
+ );
180
+ act( () => screen.getByTestId( 'chart-zoom-reset' ).focus() );
181
+ await userEvent.keyboard( '{Enter}' );
182
+ await userEvent.keyboard( ' ' );
183
+ expect( onClick ).toHaveBeenCalledTimes( 2 );
184
+ } );
185
+ } );
@@ -0,0 +1,43 @@
1
+ .x-zoom {
2
+
3
+ // The selection's translucency lives in the SVG opacity properties so
4
+ // the colors themselves can come straight from WPDS tokens.
5
+ &__selection {
6
+ fill: var(--a8c-charts-color-zoom-selection, var(--wpds-color-background-interactive-brand-strong, #3858e9));
7
+ fill-opacity: 0.16;
8
+ stroke: var(--a8c-charts-color-zoom-selection-stroke, var(--wpds-color-stroke-interactive-brand, #3858e9));
9
+ stroke-opacity: 0.65;
10
+ stroke-width: var(--wpds-border-width-xs, 1px);
11
+ pointer-events: none;
12
+ }
13
+
14
+ // Overlay placement and icon-only geometry — the visual treatment (border,
15
+ // hover/focus, sizing) comes from the WPDS Button. The elevation shadow is
16
+ // deliberately kept for separation from chart content (CHARTS-237
17
+ // design call, carrying over CHARTS-200's tokenization).
18
+ &__reset {
19
+ position: absolute;
20
+ top: var(--wpds-dimension-gap-sm, 8px);
21
+ right: var(--wpds-dimension-gap-sm, 8px);
22
+ z-index: 2;
23
+ box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
24
+
25
+ // `Button` is sized for a text label. These are the same three custom
26
+ // properties @wordpress/ui's own IconButton composition sets to make a
27
+ // square icon-only control; we set them directly because IconButton
28
+ // itself cannot be used here — it renders a Base UI tooltip, whose
29
+ // CommonJS `use-sync-external-store` dependency makes Rolldown emit a
30
+ // dynamic `require()` that throws in Script Module consumers.
31
+ --wp-ui-button-aspect-ratio: 1;
32
+ --wp-ui-button-padding-inline: 0;
33
+ --wp-ui-button-min-width: unset;
34
+
35
+ // WPDS outline-neutral is transparent-bodied at rest; give this
36
+ // floating control an opaque body so chart content doesn't show
37
+ // through it. Scoped to the rest state so the WPDS
38
+ // hover/active/focus backgrounds still apply.
39
+ &:not(:hover, :active, :focus) {
40
+ background-color: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
41
+ }
42
+ }
43
+ }
@@ -1,11 +1,12 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { __ } from '@wordpress/i18n';
3
+ import { Button } from '@wordpress/ui';
3
4
  import { useCallback, useContext, useMemo, useState } from 'react';
4
5
  import styles from './x-zoom.module.scss';
5
- import type { SingleChartRef } from './single-chart-context';
6
+ import type { SingleChartRef } from '../single-chart-context';
6
7
  import type { AxisScale } from '@visx/axis';
7
8
  import type { EventHandlerParams } from '@visx/xychart';
8
- import type { ReactNode, RefObject } from 'react';
9
+ import type { KeyboardEvent, ReactNode, RefObject } from 'react';
9
10
 
10
11
  const MIN_DRAG_PIXELS = 6;
11
12
 
@@ -170,40 +171,67 @@ export function ZoomClip( {
170
171
  }
171
172
 
172
173
  /**
173
- * Visible icon-only reset button rendered as an HTML overlay on top of
174
- * the chart container. The host should wrap its SVG in a `position: relative`
175
- * container so the button anchors correctly.
174
+ * Visible icon-only reset control rendered as an HTML overlay on top of the
175
+ * chart container, using the WPDS `Button`. The host should wrap its SVG in a
176
+ * `position: relative` container so the button anchors correctly.
177
+ *
178
+ * `IconButton` would be the natural fit, but it renders a Base UI tooltip whose
179
+ * CommonJS `use-sync-external-store` dependency makes Rolldown emit a dynamic
180
+ * `require()` into `dist`, which throws on evaluation in WordPress Script
181
+ * Module consumers. `Button` gives the same treatment without that dependency;
182
+ * the tooltip is replaced by `aria-label` + `title`.
176
183
  *
177
184
  * @param props - Props.
178
185
  * @param props.onClick - Click handler. Typically the `reset` from `useXZoom`.
179
186
  * @return JSX element.
180
187
  */
181
188
  export function ZoomResetButton( { onClick }: { onClick: () => void } ) {
189
+ // The chart's keyboard-navigation wrapper calls preventDefault() on
190
+ // bubbled keydowns, which would cancel the native Enter/Space button
191
+ // activation. Keep activation keys out of its reach.
192
+ const stopActivationKeys = useCallback( ( event: KeyboardEvent< HTMLButtonElement > ) => {
193
+ if ( event.key === 'Enter' || event.key === ' ' ) {
194
+ event.stopPropagation();
195
+ }
196
+ }, [] );
182
197
  const label = __( 'Reset zoom', 'jetpack-charts' );
183
198
  return (
184
- <button
185
- type="button"
199
+ <Button
186
200
  className={ styles[ 'x-zoom__reset' ] }
187
- onClick={ onClick }
201
+ onKeyDown={ stopActivationKeys }
188
202
  aria-label={ label }
189
203
  title={ label }
204
+ variant="outline"
205
+ tone="neutral"
206
+ size="small"
207
+ onClick={ onClick }
190
208
  data-testid="chart-zoom-reset"
191
209
  >
192
- <svg
193
- className={ styles[ 'x-zoom__reset-icon' ] }
194
- viewBox="0 0 24 24"
195
- fill="none"
196
- stroke="currentColor"
197
- strokeWidth="2"
198
- strokeLinecap="round"
199
- strokeLinejoin="round"
200
- aria-hidden="true"
201
- focusable="false"
202
- >
203
- <circle cx="10" cy="10" r="6" />
204
- <line x1="15" y1="15" x2="20" y2="20" />
205
- <line x1="7" y1="10" x2="13" y2="10" />
206
- </svg>
207
- </button>
210
+ <Button.Icon
211
+ icon={
212
+ <svg
213
+ viewBox="0 0 24 24"
214
+ fill="none"
215
+ stroke="currentColor"
216
+ strokeWidth="2"
217
+ strokeLinecap="round"
218
+ strokeLinejoin="round"
219
+ aria-hidden="true"
220
+ focusable="false"
221
+ >
222
+ { /*
223
+ Icons render edge-to-edge at 24px, so inset the glyph the
224
+ way @wordpress/icons glyphs do (drawn within roughly 4-20
225
+ of the viewBox, ~1.5px effective stroke).
226
+ */ }
227
+ <g transform="translate(2.4 2.4) scale(0.8)">
228
+ <circle cx="10" cy="10" r="6" />
229
+ <line x1="15" y1="15" x2="20" y2="20" />
230
+ <line x1="7" y1="10" x2="13" y2="10" />
231
+ </g>
232
+ </svg>
233
+ }
234
+ />
235
+ </Button>
208
236
  );
209
237
  }
@@ -265,4 +265,156 @@ describe( 'useChartLegendItems', () => {
265
265
  expect( result.current[ 0 ].color ).toBeDefined();
266
266
  } );
267
267
  } );
268
+
269
+ describe( 'Group collapsing (SeriesData)', () => {
270
+ const groupedComparisonData: SeriesData[] = [
271
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
272
+ {
273
+ label: 'Views — previous',
274
+ group: 'views',
275
+ options: { type: 'comparison' as const },
276
+ data: [ { label: 'Mon', value: 90 } ],
277
+ },
278
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
279
+ {
280
+ label: 'Visitors — previous',
281
+ group: 'visitors',
282
+ options: { type: 'comparison' as const },
283
+ data: [ { label: 'Mon', value: 45 } ],
284
+ },
285
+ ];
286
+
287
+ describe( 'by default', () => {
288
+ test( 'keeps one item per series for a comparison group', () => {
289
+ const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
290
+ wrapper,
291
+ } );
292
+
293
+ expect( result.current ).toHaveLength( 4 );
294
+ expect( result.current.map( item => item.label ) ).toEqual( [
295
+ 'Views',
296
+ 'Views — previous',
297
+ 'Visitors',
298
+ 'Visitors — previous',
299
+ ] );
300
+ } );
301
+
302
+ test( 'gives each item only its own series label', () => {
303
+ const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
304
+ wrapper,
305
+ } );
306
+
307
+ expect( result.current.map( item => item.seriesLabels ) ).toEqual( [
308
+ [ 'Views' ],
309
+ [ 'Views — previous' ],
310
+ [ 'Visitors' ],
311
+ [ 'Visitors — previous' ],
312
+ ] );
313
+ } );
314
+
315
+ test( 'preserves the original series order for interleaved groups', () => {
316
+ // `group` predates collapsing and was used for colour coordination, so existing
317
+ // charts may interleave grouped series. The default legend must not reorder them.
318
+ const interleaved: SeriesData[] = [
319
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
320
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
321
+ {
322
+ label: 'Views — previous',
323
+ group: 'views',
324
+ options: { type: 'comparison' as const },
325
+ data: [ { label: 'Mon', value: 90 } ],
326
+ },
327
+ {
328
+ label: 'Visitors — previous',
329
+ group: 'visitors',
330
+ options: { type: 'comparison' as const },
331
+ data: [ { label: 'Mon', value: 45 } ],
332
+ },
333
+ ];
334
+
335
+ const { result } = renderHook( () => useChartLegendItems( interleaved ), { wrapper } );
336
+
337
+ expect( result.current.map( item => item.label ) ).toEqual( [
338
+ 'Views',
339
+ 'Visitors',
340
+ 'Views — previous',
341
+ 'Visitors — previous',
342
+ ] );
343
+ } );
344
+ } );
345
+
346
+ describe( 'with collapseGroups enabled', () => {
347
+ const collapsed = () =>
348
+ renderHook( () => useChartLegendItems( groupedComparisonData, { collapseGroups: true } ), {
349
+ wrapper,
350
+ } );
351
+
352
+ test( 'collapses series that share a group into one item per group', () => {
353
+ const { result } = collapsed();
354
+
355
+ expect( result.current ).toHaveLength( 2 );
356
+ } );
357
+
358
+ test( 'uses the primary (non-comparison) series label for the grouped item', () => {
359
+ const { result } = collapsed();
360
+
361
+ expect( result.current[ 0 ].label ).toBe( 'Views' );
362
+ expect( result.current[ 1 ].label ).toBe( 'Visitors' );
363
+ } );
364
+
365
+ test( 'exposes all member series labels via seriesLabels, primary first', () => {
366
+ const { result } = collapsed();
367
+
368
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'Views', 'Views — previous' ] );
369
+ expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Visitors', 'Visitors — previous' ] );
370
+ } );
371
+
372
+ test( 'collapses a shared group that has no comparison series', () => {
373
+ const sharedGroupNoComparison: SeriesData[] = [
374
+ { label: 'A', group: 'metrics', data: [ { label: 'x', value: 1 } ] },
375
+ { label: 'B', group: 'metrics', data: [ { label: 'x', value: 2 } ] },
376
+ ];
377
+
378
+ const { result } = renderHook(
379
+ () => useChartLegendItems( sharedGroupNoComparison, { collapseGroups: true } ),
380
+ { wrapper }
381
+ );
382
+
383
+ expect( result.current ).toHaveLength( 1 );
384
+ expect( result.current[ 0 ].label ).toBe( 'A' );
385
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'A', 'B' ] );
386
+ } );
387
+
388
+ test( 'does not collapse series with distinct group values', () => {
389
+ const distinctGroups: SeriesData[] = [
390
+ { label: 'A', group: 'a', data: [ { label: 'x', value: 1 } ] },
391
+ { label: 'B', group: 'b', data: [ { label: 'x', value: 2 } ] },
392
+ ];
393
+
394
+ const { result } = renderHook(
395
+ () => useChartLegendItems( distinctGroups, { collapseGroups: true } ),
396
+ { wrapper }
397
+ );
398
+
399
+ expect( result.current ).toHaveLength( 2 );
400
+ expect( result.current.map( item => item.label ) ).toEqual( [ 'A', 'B' ] );
401
+ } );
402
+
403
+ test( 'leaves ungrouped series as individual items with their own seriesLabels', () => {
404
+ const ungrouped: SeriesData[] = [
405
+ { label: 'One', data: [ { label: 'x', value: 1 } ] },
406
+ { label: 'Two', data: [ { label: 'x', value: 2 } ] },
407
+ ];
408
+
409
+ const { result } = renderHook(
410
+ () => useChartLegendItems( ungrouped, { collapseGroups: true } ),
411
+ { wrapper }
412
+ );
413
+
414
+ expect( result.current ).toHaveLength( 2 );
415
+ expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'One' ] );
416
+ expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Two' ] );
417
+ } );
418
+ } );
419
+ } );
268
420
  } );