@automattic/charts 1.10.3 → 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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TOKENS.md +83 -0
  3. package/dist/index.cjs +894 -139
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +58 -62
  6. package/dist/index.d.cts +75 -13
  7. package/dist/index.d.ts +75 -13
  8. package/dist/index.js +894 -139
  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/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
  17. package/src/charts/heatmap-chart/heatmap-chart.module.scss +18 -13
  18. package/src/charts/heatmap-chart/heatmap-chart.tsx +11 -7
  19. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -3
  20. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +13 -7
  21. package/src/charts/leaderboard-chart/hooks/index.ts +1 -0
  22. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +115 -0
  23. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +39 -12
  24. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +67 -14
  25. package/src/charts/leaderboard-chart/test/leaderboard-chart-fit-rows.test.tsx +365 -0
  26. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +73 -2
  27. package/src/charts/leaderboard-chart/types.ts +12 -0
  28. package/src/charts/line-chart/line-chart.module.scss +2 -2
  29. package/src/charts/line-chart/line-chart.tsx +40 -5
  30. package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
  31. package/src/charts/line-chart/types.ts +14 -0
  32. package/src/charts/private/grid-control/grid-control.module.scss +1 -1
  33. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  34. package/src/charts/private/x-zoom/index.ts +1 -0
  35. package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
  36. package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
  37. package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
  38. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
  39. package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
  40. package/src/components/legend/private/base-legend.tsx +21 -9
  41. package/src/components/legend/test/legend-config-types.test.tsx +32 -0
  42. package/src/components/legend/test/legend.test.tsx +121 -1
  43. package/src/components/legend/types.ts +6 -0
  44. package/src/components/tooltip/base-tooltip.module.scss +3 -2
  45. package/src/components/trend-indicator/trend-indicator.module.scss +6 -5
  46. package/src/index.ts +1 -0
  47. package/src/providers/chart-context/themes.ts +33 -19
  48. package/src/types.ts +18 -2
  49. package/src/charts/private/x-zoom.module.scss +0 -48
@@ -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
  ] );
@@ -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
  };
@@ -2,8 +2,9 @@
2
2
  padding: var(--wpds-dimension-padding-sm, 8px);
3
3
  // No WPDS token fits a translucent dark tooltip surface:
4
4
  // bg-interactive-neutral-strong is opaque, and there is no
5
- // white-on-dark content foreground token.
6
- background-color: rgba(0, 0, 0, 0.85);
5
+ // white-on-dark content foreground token. The role carries the
6
+ // hardcoded translucent value as its fallback.
7
+ background-color: var(--a8c-charts-color-tooltip-surface, rgba(0, 0, 0, 0.85));
7
8
  color: #fff;
8
9
  border-radius: var(--wpds-border-radius-md, 4px);
9
10
  font-size: var(--wpds-typography-font-size-md, 13px);
@@ -6,18 +6,19 @@
6
6
  font-weight: var(--wpds-typography-font-weight-medium, 499);
7
7
  line-height: 1;
8
8
 
9
- // Trend colors use the documented --charts-trend-* override API, falling
10
- // back to the accessible WPDS semantic tokens.
9
+ // Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
10
+ // to the deprecated --charts-trend-*-color override API and then to the
11
+ // accessible WPDS semantic tokens.
11
12
  &--up {
12
- color: var(--charts-trend-up-color, var(--wpds-color-foreground-content-success-weak, #008030));
13
+ color: var(--a8c-charts-color-trend-up, var(--charts-trend-up-color, var(--wpds-color-foreground-content-success-weak, #008030)));
13
14
  }
14
15
 
15
16
  &--down {
16
- color: var(--charts-trend-down-color, var(--wpds-color-foreground-content-error-weak, #cc1818));
17
+ color: var(--a8c-charts-color-trend-down, var(--charts-trend-down-color, var(--wpds-color-foreground-content-error-weak, #cc1818)));
17
18
  }
18
19
 
19
20
  &--neutral {
20
- color: var(--charts-trend-neutral-color, var(--wpds-color-foreground-content-neutral-weak, #707070));
21
+ color: var(--a8c-charts-color-trend-neutral, var(--charts-trend-neutral-color, var(--wpds-color-foreground-content-neutral-weak, #707070)));
21
22
  }
22
23
 
23
24
  &__icon {
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export type {
57
57
  LegendShapeStyles,
58
58
  LegendPosition,
59
59
  ChartLegendConfig,
60
+ SeriesChartLegendConfig,
60
61
  BaseChartProps,
61
62
  GridProps,
62
63
  GoogleDataTableColumn,
@@ -4,24 +4,31 @@ import type { CompleteChartTheme } from '../../types';
4
4
  * Default theme configuration
5
5
  */
6
6
  const defaultTheme: CompleteChartTheme = {
7
- backgroundColor: 'var(--wpds-color-background-surface-neutral-strong, #fff)',
7
+ backgroundColor:
8
+ 'var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff))',
8
9
  labelBackgroundColor: 'transparent', // label background color (transparent by default)
9
10
  // White label text sits on top of arbitrary series colors, so it has no WPDS
10
- // content-foreground equivalent and stays hardcoded (tokenization outlier).
11
- labelTextColor: '#FFFFFF',
11
+ // content-foreground equivalent; the role carries the hardcoded value as its fallback.
12
+ labelTextColor: 'var(--a8c-charts-color-label-on-fill, #FFFFFF)',
12
13
  colors: [ '#98C8DF', '#006DAB', '#A6DC80', '#1F9828', '#FF8C8F' ],
13
14
  gridStyles: {
14
- stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)',
15
+ stroke: 'var(--a8c-charts-color-grid, var(--wpds-color-stroke-surface-neutral, #dbdbdb))',
15
16
  strokeWidth: 1,
16
17
  },
17
18
  tickLength: 4,
18
19
  gridColor: '',
19
20
  gridColorDark: '',
20
- xTickLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
21
- xAxisLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
21
+ xTickLineStyles: {
22
+ stroke: 'var(--a8c-charts-color-tick, var(--wpds-color-stroke-surface-neutral, #dbdbdb))',
23
+ strokeWidth: 1,
24
+ },
25
+ xAxisLineStyles: {
26
+ stroke: 'var(--a8c-charts-color-axis, var(--wpds-color-stroke-surface-neutral, #dbdbdb))',
27
+ strokeWidth: 1,
28
+ },
22
29
  legend: {
23
30
  labelStyles: {
24
- color: 'var(--wpds-color-foreground-content-neutral, #1e1e1e)',
31
+ color: 'var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e))',
25
32
  },
26
33
  containerStyles: {},
27
34
  shapeStyles: [],
@@ -34,26 +41,30 @@ const defaultTheme: CompleteChartTheme = {
34
41
  // elements for axis labels and ticks. Setting `inherit` lets SVG text
35
42
  // pick up the host application's font-family via normal CSS inheritance.
36
43
  svgLabelSmall: {
37
- fill: 'var(--wpds-color-foreground-content-neutral, #1e1e1e)',
44
+ fill: 'var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e))',
38
45
  fontFamily: 'inherit',
39
46
  },
40
47
  svgLabelBig: { fontFamily: 'inherit' },
41
48
  annotationStyles: {
42
49
  label: {
43
- anchorLineStroke: 'var(--wpds-color-foreground-content-neutral, #1e1e1e)',
44
- backgroundFill: 'var(--wpds-color-background-surface-neutral-strong, #fff)',
50
+ anchorLineStroke:
51
+ 'var(--a8c-charts-color-annotation, var(--wpds-color-foreground-content-neutral, #1e1e1e))',
52
+ backgroundFill:
53
+ 'var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff))',
45
54
  },
46
55
  connector: {
47
- stroke: 'var(--wpds-color-foreground-content-neutral, #1e1e1e)',
56
+ stroke:
57
+ 'var(--a8c-charts-color-annotation, var(--wpds-color-foreground-content-neutral, #1e1e1e))',
48
58
  },
49
59
  circleSubject: {
50
60
  stroke: 'transparent',
51
- fill: 'var(--wpds-color-foreground-content-neutral, #1e1e1e)',
61
+ fill: 'var(--a8c-charts-color-annotation, var(--wpds-color-foreground-content-neutral, #1e1e1e))',
52
62
  radius: 5,
53
63
  },
54
64
  },
55
65
  geoChart: {
56
- featureFillColor: 'var(--wpds-color-background-surface-neutral-weak, #f4f4f4)',
66
+ featureFillColor:
67
+ 'var(--a8c-charts-color-surface-secondary, var(--wpds-color-background-surface-neutral-weak, #f4f4f4))',
57
68
  },
58
69
  leaderboardChart: {
59
70
  rowGap: 12,
@@ -61,15 +72,18 @@ const defaultTheme: CompleteChartTheme = {
61
72
  labelSpacing: 'xs',
62
73
  // [negative, neutral, positive]
63
74
  deltaColors: [
64
- 'var(--wpds-color-foreground-content-error-weak, #cc1818)',
65
- 'var(--wpds-color-foreground-content-neutral-weak, #707070)',
66
- 'var(--wpds-color-foreground-content-success-weak, #008030)',
75
+ 'var(--a8c-charts-color-trend-down, var(--wpds-color-foreground-content-error-weak, #cc1818))',
76
+ 'var(--a8c-charts-color-trend-neutral, var(--wpds-color-foreground-content-neutral-weak, #707070))',
77
+ 'var(--a8c-charts-color-trend-up, var(--wpds-color-foreground-content-success-weak, #008030))',
67
78
  ],
68
79
  },
69
80
  conversionFunnelChart: {
70
- backgroundColor: 'var(--wpds-color-background-surface-neutral-weak, #f4f4f4)',
71
- positiveChangeColor: 'var(--wpds-color-foreground-content-success-weak, #008030)',
72
- negativeChangeColor: 'var(--wpds-color-foreground-content-error-weak, #cc1818)',
81
+ backgroundColor:
82
+ 'var(--a8c-charts-color-surface-secondary, var(--wpds-color-background-surface-neutral-weak, #f4f4f4))',
83
+ positiveChangeColor:
84
+ 'var(--a8c-charts-color-trend-up, var(--wpds-color-foreground-content-success-weak, #008030))',
85
+ negativeChangeColor:
86
+ 'var(--a8c-charts-color-trend-down, var(--wpds-color-foreground-content-error-weak, #cc1818))',
73
87
  },
74
88
  lineChart: {
75
89
  lineStyles: {
package/src/types.ts CHANGED
@@ -208,8 +208,8 @@ export type LeaderboardEntry = {
208
208
  previousShare?: number;
209
209
 
210
210
  /**
211
- * Delta of the entry. Omit when this row has no matching comparison-period
212
- * value.
211
+ * Delta of the entry. Omit when the percentage change is unavailable, such
212
+ * as when the row has no comparison value or its previous value is zero.
213
213
  */
214
214
  delta?: number;
215
215
 
@@ -608,6 +608,22 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
608
608
  shapeStyles?: LegendShapeStyles;
609
609
  };
610
610
 
611
+ /**
612
+ * Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
613
+ * of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
614
+ * charts (pie, semi-circle pie) — whose data points carry `group` only to coordinate colours — can't
615
+ * set it.
616
+ */
617
+ export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
618
+ /**
619
+ * Collapse series that share a `group` into a single legend item, labelled by the group's
620
+ * primary series (its first non-comparison member). Off by default, so every series keeps its
621
+ * own item. Combines with `interactive`: a collapsed item toggles every series in its group, an
622
+ * uncollapsed one toggles only its own.
623
+ */
624
+ collapseGroups?: boolean;
625
+ };
626
+
611
627
  /**
612
628
  * Base properties shared across all chart components
613
629
  */
@@ -1,48 +0,0 @@
1
- .x-zoom {
2
-
3
- // The translucent rgba overlay colors (selection fill/stroke, reset
4
- // background/border) have no WPDS equivalent and stay as-is; only the
5
- // opaque foreground and focus colors are tokenized.
6
- &__selection {
7
- fill: var(--charts-zoom-selection-fill, rgba(56, 88, 233, 0.16));
8
- stroke: var(--charts-zoom-selection-stroke, rgba(56, 88, 233, 0.65));
9
- stroke-width: var(--wpds-border-width-xs, 1px);
10
- pointer-events: none;
11
- }
12
-
13
- &__reset {
14
- position: absolute;
15
- top: var(--wpds-dimension-gap-sm, 8px);
16
- right: var(--wpds-dimension-gap-sm, 8px);
17
- z-index: 2;
18
- display: inline-flex;
19
- align-items: center;
20
- justify-content: center;
21
- width: 28px;
22
- height: 28px;
23
- padding: 0;
24
- background: var(--charts-zoom-reset-bg, rgba(255, 255, 255, 0.92));
25
- color: var(--charts-zoom-reset-fg, var(--wpds-color-foreground-content-neutral, #1e1e1e));
26
- border:
27
- var(--wpds-border-width-xs, 1px) solid
28
- var(--charts-zoom-reset-border, rgba(0, 0, 0, 0.16));
29
- border-radius: var(--wpds-border-radius-md, 4px);
30
- cursor: pointer;
31
- 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);
32
-
33
- &:hover {
34
- background: var(--charts-zoom-reset-bg-hover, rgba(255, 255, 255, 1));
35
- }
36
-
37
- &:focus-visible {
38
- outline: 2px solid var(--charts-zoom-reset-focus, var(--wpds-color-stroke-focus, #3858e9));
39
- outline-offset: 1px;
40
- }
41
- }
42
-
43
- &__reset-icon {
44
- width: 16px;
45
- height: 16px;
46
- flex-shrink: 0;
47
- }
48
- }