@automattic/charts 1.11.0 → 2.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 (39) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/TOKENS.md +19 -1
  3. package/dist/index.cjs +2196 -2711
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +23 -43
  6. package/dist/index.d.cts +72 -6
  7. package/dist/index.d.ts +72 -6
  8. package/dist/index.js +278 -792
  9. package/dist/index.js.map +1 -1
  10. package/package.json +5 -4
  11. package/src/charts/area-chart/area-chart.tsx +31 -10
  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/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  17. package/src/charts/line-chart/line-chart.module.scss +3 -3
  18. package/src/charts/line-chart/line-chart.tsx +43 -7
  19. package/src/charts/line-chart/test/line-chart.test.tsx +234 -5
  20. package/src/charts/line-chart/types.ts +14 -0
  21. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
  22. package/src/charts/private/test/time-axis.test.ts +182 -0
  23. package/src/charts/private/time-axis.ts +84 -5
  24. package/src/charts/private/x-zoom/index.ts +1 -0
  25. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +92 -4
  26. package/src/charts/private/x-zoom/x-zoom.module.scss +35 -0
  27. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +44 -27
  28. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  29. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  30. package/src/components/legend/private/base-legend.module.scss +1 -1
  31. package/src/components/legend/private/base-legend.tsx +21 -9
  32. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  33. package/src/components/legend/test/legend.test.tsx +121 -1
  34. package/src/components/legend/types.ts +6 -0
  35. package/src/components/tooltip/base-tooltip.module.scss +1 -1
  36. package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
  37. package/src/index.ts +2 -0
  38. package/src/types.ts +31 -0
  39. package/src/charts/private/x-zoom.module.scss +0 -50
@@ -25,6 +25,11 @@ export interface ChartLegendOptions {
25
25
  showValues?: boolean;
26
26
  legendValueDisplay?: LegendValueDisplay;
27
27
  legendShape?: LegendShape< SeriesData[], number >;
28
+ /**
29
+ * Collapse series that share a `group` into a single legend item, labelled by the group's
30
+ * primary series. Off by default, so every series gets its own item.
31
+ */
32
+ collapseGroups?: boolean;
28
33
  }
29
34
 
30
35
  /**
@@ -95,13 +100,90 @@ function applyGlyphToLegendItem(
95
100
  return baseItem;
96
101
  }
97
102
 
103
+ type SeriesGroupMember = { series: SeriesData; index: number };
104
+
105
+ /**
106
+ * Buckets series by their `group`, preserving first-appearance order. Series with no group — or a
107
+ * group value unique to them — end up in a bucket of their own. Whether a multi-series bucket then
108
+ * collapses to a single legend item is decided by the caller: with `collapseGroups` on, every
109
+ * multi-member bucket collapses, and the comparison pattern only decides which member represents it.
110
+ * @param seriesData - The series data to group
111
+ * @return Ordered groups, each holding its member series with their original indices
112
+ */
113
+ function groupSeriesForLegend( seriesData: SeriesData[] ): SeriesGroupMember[][] {
114
+ const groups: SeriesGroupMember[][] = [];
115
+ const groupIndexByKey = new Map< string, number >();
116
+
117
+ seriesData.forEach( ( series, index ) => {
118
+ const member: SeriesGroupMember = { series, index };
119
+ const key = series.group;
120
+
121
+ if ( key === undefined ) {
122
+ groups.push( [ member ] );
123
+ return;
124
+ }
125
+
126
+ const existing = groupIndexByKey.get( key );
127
+ if ( existing === undefined ) {
128
+ groupIndexByKey.set( key, groups.length );
129
+ groups.push( [ member ] );
130
+ } else {
131
+ groups[ existing ].push( member );
132
+ }
133
+ } );
134
+
135
+ return groups;
136
+ }
137
+
98
138
  /**
99
- * Processes SeriesData into legend items
139
+ * Builds a single legend item from a representative series, tagging it with the series it controls
140
+ * @param member - The series (with its original index) that provides the label/colour
141
+ * @param seriesLabels - Every series label this item toggles (grouped) or just its own
142
+ * @param getElementStyles - Function to get element styles
143
+ * @param showValues - Whether to show values in legend
144
+ * @param withGlyph - Whether to include glyph rendering
145
+ * @param glyphSize - Size of the glyph
146
+ * @param renderGlyph - Component to render the glyph
147
+ * @param legendShape - The shape type for legend items (string literal or React component)
148
+ * @return The processed legend item
149
+ */
150
+ function buildSeriesLegendItem(
151
+ member: SeriesGroupMember,
152
+ seriesLabels: string[],
153
+ getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
154
+ showValues: boolean,
155
+ withGlyph: boolean,
156
+ glyphSize: number,
157
+ renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode,
158
+ legendShape?: LegendShape< SeriesData[], number >
159
+ ): BaseLegendItem {
160
+ const { color, glyph, shapeStyles } = getElementStyles( {
161
+ data: member.series,
162
+ index: member.index,
163
+ legendShape,
164
+ } );
165
+
166
+ const baseItem: BaseLegendItem = {
167
+ label: member.series.label,
168
+ value: showValues ? member.series.data?.length?.toString() || '0' : '',
169
+ color,
170
+ shapeStyle: shapeStyles,
171
+ seriesLabels,
172
+ };
173
+
174
+ return applyGlyphToLegendItem( baseItem, withGlyph, glyph, renderGlyph, glyphSize );
175
+ }
176
+
177
+ /**
178
+ * Processes SeriesData into legend items. Every series keeps its own legend entry unless
179
+ * `collapseGroups` is set, in which case series sharing a `group` collapse to one item labelled by
180
+ * the group's primary (its first non-comparison member).
100
181
  * @param seriesData - The series data to process
101
182
  * @param getElementStyles - Function to get element styles
102
183
  * @param showValues - Whether to show values in legend
103
184
  * @param withGlyph - Whether to include glyph rendering
104
185
  * @param glyphSize - Size of the glyph
186
+ * @param collapseGroups - Whether series sharing a group collapse to a single item
105
187
  * @param renderGlyph - Component to render the glyph
106
188
  * @param legendShape - The shape type for legend items (string literal or React component)
107
189
  * @return Array of processed legend items
@@ -112,27 +194,44 @@ function processSeriesData(
112
194
  showValues: boolean,
113
195
  withGlyph: boolean,
114
196
  glyphSize: number,
197
+ collapseGroups: boolean,
115
198
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode,
116
199
  legendShape?: LegendShape< SeriesData[], number >
117
200
  ): BaseLegendItem[] {
118
- const mapper = ( series: SeriesData, index: number ) => {
119
- const { color, glyph, shapeStyles } = getElementStyles( {
120
- data: series,
121
- index,
122
- legendShape,
123
- } );
201
+ const buildItem = ( member: SeriesGroupMember, seriesLabels: string[] ) =>
202
+ buildSeriesLegendItem(
203
+ member,
204
+ seriesLabels,
205
+ getElementStyles,
206
+ showValues,
207
+ withGlyph,
208
+ glyphSize,
209
+ renderGlyph,
210
+ legendShape
211
+ );
124
212
 
125
- const baseItem: BaseLegendItem = {
126
- label: series.label,
127
- value: showValues ? series.data?.length?.toString() || '0' : '',
128
- color,
129
- shapeStyle: shapeStyles,
130
- };
213
+ // Without collapsing there is no reason to bucket by group — mapping series directly keeps the
214
+ // original order, which matters for charts that already interleave grouped series for colour.
215
+ if ( ! collapseGroups ) {
216
+ return seriesData.map( ( series, index ) => buildItem( { series, index }, [ series.label ] ) );
217
+ }
131
218
 
132
- return applyGlyphToLegendItem( baseItem, withGlyph, glyph, renderGlyph, glyphSize );
133
- };
219
+ return groupSeriesForLegend( seriesData ).flatMap( members => {
220
+ if ( members.length > 1 ) {
221
+ const primary =
222
+ members.find( ( { series } ) => series.options?.type !== 'comparison' ) ?? members[ 0 ];
223
+
224
+ // Primary first so the interactive visibility check reads the series that owns the swatch.
225
+ const seriesLabels = [
226
+ primary.series.label,
227
+ ...members.filter( member => member !== primary ).map( ( { series } ) => series.label ),
228
+ ];
229
+
230
+ return [ buildItem( primary, seriesLabels ) ];
231
+ }
134
232
 
135
- return seriesData.map( mapper );
233
+ return members.map( member => buildItem( member, [ member.series.label ] ) );
234
+ } );
136
235
  }
137
236
 
138
237
  /**
@@ -196,6 +295,7 @@ export function useChartLegendItems<
196
295
  legendValueDisplay = 'percentage',
197
296
  withGlyph = false,
198
297
  glyphSize = 8,
298
+ collapseGroups = false,
199
299
  renderGlyph,
200
300
  } = options;
201
301
  const { getElementStyles } = useGlobalChartsContext();
@@ -213,6 +313,7 @@ export function useChartLegendItems<
213
313
  showValues,
214
314
  withGlyph,
215
315
  glyphSize,
316
+ collapseGroups,
216
317
  renderGlyph,
217
318
  legendShape
218
319
  );
@@ -236,6 +337,7 @@ export function useChartLegendItems<
236
337
  legendValueDisplay,
237
338
  withGlyph,
238
339
  glyphSize,
340
+ collapseGroups,
239
341
  renderGlyph,
240
342
  legendShape,
241
343
  ] );
@@ -54,6 +54,6 @@
54
54
  }
55
55
 
56
56
  .legend-item-value {
57
- font-weight: var(--wpds-typography-font-weight-medium, 499);
57
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
58
58
  flex-shrink: 0; // Prevent value from shrinking when text is ellipsized
59
59
  }
@@ -116,9 +116,17 @@ export const BaseLegend: ForwardRefExoticComponent<
116
116
 
117
117
  // Handle legend item clicks for interactive mode
118
118
  const handleLegendClick = useCallback(
119
- ( seriesLabel: string ) => {
119
+ ( seriesLabels: string[] ) => {
120
120
  if ( interactive && chartId && context ) {
121
- context.toggleSeriesVisibility( chartId, seriesLabel );
121
+ // Converge the whole group on the representative's next state: only toggle series that
122
+ // currently match it, so a desynced group (e.g. one member hidden programmatically)
123
+ // ends up uniformly hidden/shown after one click.
124
+ const representativeVisible = context.isSeriesVisible( chartId, seriesLabels[ 0 ] );
125
+ seriesLabels.forEach( label => {
126
+ if ( context.isSeriesVisible( chartId, label ) === representativeVisible ) {
127
+ context.toggleSeriesVisibility( chartId, label );
128
+ }
129
+ } );
122
130
  }
123
131
  },
124
132
  [ interactive, chartId, context ]
@@ -137,24 +145,24 @@ export const BaseLegend: ForwardRefExoticComponent<
137
145
 
138
146
  // Create event handlers to avoid inline arrow functions
139
147
  const createClickHandler = useCallback(
140
- ( labelText: string ) => {
148
+ ( seriesLabels: string[] ) => {
141
149
  if ( ! interactive ) {
142
150
  return undefined;
143
151
  }
144
- return () => handleLegendClick( labelText );
152
+ return () => handleLegendClick( seriesLabels );
145
153
  },
146
154
  [ interactive, handleLegendClick ]
147
155
  );
148
156
 
149
157
  const createKeyDownHandler = useCallback(
150
- ( labelText: string ) => {
158
+ ( seriesLabels: string[] ) => {
151
159
  if ( ! interactive ) {
152
160
  return undefined;
153
161
  }
154
162
  return ( event: KeyboardEvent ) => {
155
163
  if ( event.key === 'Enter' || event.key === ' ' ) {
156
164
  event.preventDefault();
157
- handleLegendClick( labelText );
165
+ handleLegendClick( seriesLabels );
158
166
  }
159
167
  };
160
168
  },
@@ -185,10 +193,14 @@ export const BaseLegend: ForwardRefExoticComponent<
185
193
  style={ theme.legend?.containerStyles }
186
194
  >
187
195
  { labels.map( ( label, i ) => {
188
- const visible = isSeriesVisible( label.text );
189
- const handleClick = createClickHandler( label.text );
190
- const handleKeyDown = createKeyDownHandler( label.text );
191
196
  const matchedItem = items[ i ];
197
+ // A grouped item toggles/reads every series it controls; a plain item just its own.
198
+ const seriesLabels = matchedItem?.seriesLabels?.length
199
+ ? matchedItem.seriesLabels
200
+ : [ label.text ];
201
+ const visible = isSeriesVisible( seriesLabels[ 0 ] );
202
+ const handleClick = createClickHandler( seriesLabels );
203
+ const handleKeyDown = createKeyDownHandler( seriesLabels );
192
204
 
193
205
  return (
194
206
  <LegendItem
@@ -0,0 +1,32 @@
1
+ import type {
2
+ ChartLegendConfig,
3
+ SeriesChartLegendConfig,
4
+ DataPointPercentage,
5
+ } from '../../../types';
6
+
7
+ // Type-level tests. The assertions that matter are the `@ts-expect-error` directives, which the
8
+ // package typecheck enforces — an unused directive is itself a compile error, so these fail loudly
9
+ // if `collapseGroups` ever leaks onto the base config that point-based charts use.
10
+ describe( 'collapseGroups legend config', () => {
11
+ test( 'is available on the series legend config', () => {
12
+ const seriesLegend: SeriesChartLegendConfig = { collapseGroups: true };
13
+
14
+ expect( seriesLegend.collapseGroups ).toBe( true );
15
+ } );
16
+
17
+ test( 'is not part of the base config point charts use', () => {
18
+ const pieLegend: ChartLegendConfig< DataPointPercentage[] > = {
19
+ // @ts-expect-error collapseGroups is only on SeriesChartLegendConfig — pie segments carry
20
+ // `group` purely to coordinate colours with other charts, not to collapse legend rows.
21
+ collapseGroups: true,
22
+ };
23
+
24
+ expect( pieLegend ).toBeDefined();
25
+ } );
26
+
27
+ test( 'leaves other legend options available on the base config', () => {
28
+ const pieLegend: ChartLegendConfig< DataPointPercentage[] > = { interactive: true };
29
+
30
+ expect( pieLegend.interactive ).toBe( true );
31
+ } );
32
+ } );
@@ -3,7 +3,12 @@ import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { useMemo } from 'react';
5
5
  import { SingleChartContext } from '../../../charts/private/single-chart-context';
6
- import { GlobalChartsProvider, useChartId, useChartRegistration } from '../../../providers';
6
+ import {
7
+ GlobalChartsProvider,
8
+ useChartId,
9
+ useChartRegistration,
10
+ useGlobalChartsContext,
11
+ } from '../../../providers';
7
12
  import { Legend } from '../legend';
8
13
  import { BaseLegend } from '../private/base-legend';
9
14
  import type { ChartType } from '../../../types';
@@ -632,5 +637,120 @@ describe( 'BaseLegend', () => {
632
637
  // Should still be visible (pressed)
633
638
  expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
634
639
  } );
640
+
641
+ it( 'toggles every series in a group when a grouped item is clicked', async () => {
642
+ const user = userEvent.setup();
643
+
644
+ const groupedItems = [
645
+ { label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views — previous' ] },
646
+ {
647
+ label: 'Visitors',
648
+ color: '#00ff00',
649
+ seriesLabels: [ 'Visitors', 'Visitors — previous' ],
650
+ },
651
+ ];
652
+
653
+ const VisibilityProbe = () => {
654
+ const { isSeriesVisible } = useGlobalChartsContext();
655
+ return (
656
+ <div>
657
+ <span data-testid="views">{ String( isSeriesVisible( 'test-chart', 'Views' ) ) }</span>
658
+ <span data-testid="views-prev">
659
+ { String( isSeriesVisible( 'test-chart', 'Views — previous' ) ) }
660
+ </span>
661
+ <span data-testid="visitors">
662
+ { String( isSeriesVisible( 'test-chart', 'Visitors' ) ) }
663
+ </span>
664
+ </div>
665
+ );
666
+ };
667
+
668
+ render(
669
+ <GlobalChartsProvider>
670
+ <BaseLegend items={ groupedItems } interactive={ true } chartId="test-chart" />
671
+ <VisibilityProbe />
672
+ </GlobalChartsProvider>
673
+ );
674
+
675
+ expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
676
+ expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
677
+
678
+ const buttons = screen.getAllByRole( 'button' );
679
+ await user.click( buttons[ 0 ] );
680
+
681
+ // One click hides both series in the Views group, leaving Visitors untouched.
682
+ expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'false' );
683
+ expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
684
+ expect( screen.getByTestId( 'visitors' ) ).toHaveTextContent( 'true' );
685
+ expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
686
+ } );
687
+
688
+ it( 'converges a desynced group to a uniform state in one click', async () => {
689
+ const user = userEvent.setup();
690
+
691
+ const groupedItems = [
692
+ { label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views — previous' ] },
693
+ ];
694
+
695
+ const DesyncProbe = () => {
696
+ const { isSeriesVisible, toggleSeriesVisibility } = useGlobalChartsContext();
697
+ return (
698
+ <div>
699
+ <button
700
+ data-testid="hide-previous"
701
+ onClick={ () => toggleSeriesVisibility( 'test-chart', 'Views — previous' ) }
702
+ >
703
+ hide previous
704
+ </button>
705
+ <span data-testid="views">{ String( isSeriesVisible( 'test-chart', 'Views' ) ) }</span>
706
+ <span data-testid="views-prev">
707
+ { String( isSeriesVisible( 'test-chart', 'Views — previous' ) ) }
708
+ </span>
709
+ </div>
710
+ );
711
+ };
712
+
713
+ render(
714
+ <GlobalChartsProvider>
715
+ <BaseLegend items={ groupedItems } interactive={ true } chartId="test-chart" />
716
+ <DesyncProbe />
717
+ </GlobalChartsProvider>
718
+ );
719
+
720
+ // Desync the group: hide only the comparison series programmatically.
721
+ await user.click( screen.getByTestId( 'hide-previous' ) );
722
+ expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
723
+ expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
724
+
725
+ // Clicking the grouped item hides the whole group, not inverts each member.
726
+ const legendButton = screen.getAllByRole( 'button' )[ 0 ];
727
+ await user.click( legendButton );
728
+ expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'false' );
729
+ expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'false' );
730
+
731
+ // And the next click shows the whole group again.
732
+ await user.click( legendButton );
733
+ expect( screen.getByTestId( 'views' ) ).toHaveTextContent( 'true' );
734
+ expect( screen.getByTestId( 'views-prev' ) ).toHaveTextContent( 'true' );
735
+ } );
736
+
737
+ it( 'falls back to the item label when seriesLabels is an empty array', async () => {
738
+ const user = userEvent.setup();
739
+
740
+ const items = [ { label: 'Views', color: '#0000ff', seriesLabels: [] as string[] } ];
741
+
742
+ render(
743
+ <GlobalChartsProvider>
744
+ <BaseLegend items={ items } interactive={ true } chartId="test-chart" />
745
+ </GlobalChartsProvider>
746
+ );
747
+
748
+ const legendButton = screen.getByRole( 'button' );
749
+ expect( legendButton ).toHaveAttribute( 'aria-pressed', 'true' );
750
+
751
+ await user.click( legendButton );
752
+
753
+ expect( legendButton ).toHaveAttribute( 'aria-pressed', 'false' );
754
+ } );
635
755
  } );
636
756
  } );
@@ -55,4 +55,10 @@ export type BaseLegendItem = {
55
55
  glyphSize?: number;
56
56
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
57
57
  shapeStyle?: CSSProperties & LineStyles;
58
+ /**
59
+ * The series labels this legend item controls. For a grouped item this is every series
60
+ * sharing the group (primary first); for a single series it is just its own label. Used by
61
+ * the interactive legend to toggle a whole group's visibility from one item.
62
+ */
63
+ seriesLabels?: string[];
58
64
  };
@@ -8,7 +8,7 @@
8
8
  color: #fff;
9
9
  border-radius: var(--wpds-border-radius-md, 4px);
10
10
  font-size: var(--wpds-typography-font-size-md, 13px);
11
- box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
11
+ box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
12
12
  position: absolute;
13
13
  pointer-events: none;
14
14
  transform: translate(-50%, -100%);
@@ -3,7 +3,7 @@
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
5
  font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-medium, 499);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis, 499);
7
7
  line-height: 1;
8
8
 
9
9
  // Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
package/src/index.ts CHANGED
@@ -51,12 +51,14 @@ export type {
51
51
  ChartTheme,
52
52
  CompleteChartTheme,
53
53
  AxisOptions,
54
+ TickResolution,
54
55
  ScaleOptions,
55
56
  LegendItemStyles,
56
57
  LegendLabelStyles,
57
58
  LegendShapeStyles,
58
59
  LegendPosition,
59
60
  ChartLegendConfig,
61
+ SeriesChartLegendConfig,
60
62
  BaseChartProps,
61
63
  GridProps,
62
64
  GoogleDataTableColumn,
package/src/types.ts CHANGED
@@ -462,6 +462,12 @@ export type CompleteChartTheme = Required< ChartTheme > & {
462
462
  Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >;
463
463
  };
464
464
 
465
+ /**
466
+ * Bucket resolution of time-series data, as known by the caller (e.g. a
467
+ * granularity selector), for consumers that don't need to infer it.
468
+ */
469
+ export type TickResolution = 'hour' | 'day' | 'week' | 'month' | 'year';
470
+
465
471
  export type AxisOptions = {
466
472
  orientation?: OrientationType;
467
473
  numTicks?: number;
@@ -475,6 +481,15 @@ export type AxisOptions = {
475
481
  labelClassName?: string;
476
482
  tickClassName?: string;
477
483
  tickFormat?: TickFormatter< ScaleInput< AxisScale > >;
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.
491
+ */
492
+ tickResolution?: TickResolution;
478
493
  /**
479
494
  * Whether to display this axis. Defaults to true.
480
495
  */
@@ -608,6 +623,22 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
608
623
  shapeStyles?: LegendShapeStyles;
609
624
  };
610
625
 
626
+ /**
627
+ * Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
628
+ * of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
629
+ * charts (pie, semi-circle pie) — whose data points carry `group` only to coordinate colours — can't
630
+ * set it.
631
+ */
632
+ export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
633
+ /**
634
+ * Collapse series that share a `group` into a single legend item, labelled by the group's
635
+ * primary series (its first non-comparison member). Off by default, so every series keeps its
636
+ * own item. Combines with `interactive`: a collapsed item toggles every series in its group, an
637
+ * uncollapsed one toggles only its own.
638
+ */
639
+ collapseGroups?: boolean;
640
+ };
641
+
611
642
  /**
612
643
  * Base properties shared across all chart components
613
644
  */
@@ -1,50 +0,0 @@
1
- .x-zoom {
2
-
3
- // The zoom control's bespoke colours are inlined, not exposed as
4
- // --a8c-charts-color-zoom-* override vars. The control is slated for rework
5
- // (reset button -> @wordpress/ui Button), so it is deliberately not a public
6
- // theming surface. Focus tracks the shared --a8c-charts-color-focus role; the
7
- // button text uses the WPDS content-foreground token.
8
- &__selection {
9
- fill: rgba(56, 88, 233, 0.16);
10
- stroke: rgba(56, 88, 233, 0.65);
11
- stroke-width: var(--wpds-border-width-xs, 1px);
12
- pointer-events: none;
13
- }
14
-
15
- &__reset {
16
- position: absolute;
17
- top: var(--wpds-dimension-gap-sm, 8px);
18
- right: var(--wpds-dimension-gap-sm, 8px);
19
- z-index: 2;
20
- display: inline-flex;
21
- align-items: center;
22
- justify-content: center;
23
- width: 28px;
24
- height: 28px;
25
- padding: 0;
26
- background: rgba(255, 255, 255, 0.92);
27
- color: var(--wpds-color-foreground-content-neutral, #1e1e1e);
28
- border:
29
- var(--wpds-border-width-xs, 1px) solid
30
- rgba(0, 0, 0, 0.16);
31
- border-radius: var(--wpds-border-radius-md, 4px);
32
- cursor: pointer;
33
- 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);
34
-
35
- &:hover {
36
- background: rgba(255, 255, 255, 1);
37
- }
38
-
39
- &:focus-visible {
40
- outline: 2px solid var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, #3858e9));
41
- outline-offset: 1px;
42
- }
43
- }
44
-
45
- &__reset-icon {
46
- width: 16px;
47
- height: 16px;
48
- flex-shrink: 0;
49
- }
50
- }