@automattic/charts 3.1.1 → 4.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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +38 -11
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,8 +1,9 @@
1
1
  import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
2
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
2
+ import { useTooltip } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
6
7
  import {
7
8
  GlobalChartsProvider,
8
9
  useChartId,
@@ -10,7 +11,8 @@ import {
10
11
  useGlobalChartsContext,
11
12
  GlobalChartsContext,
12
13
  } from '../../providers';
13
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
14
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
15
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
14
16
  import { attachSubComponents } from '../../utils';
15
17
  import {
16
18
  isValidHexColor,
@@ -69,23 +71,28 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
69
71
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
70
72
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
71
73
  useTooltip< HeatmapTooltipData >();
72
- const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( {
73
- detectBounds: true,
74
- scroll: true,
75
- } );
76
- // Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which
77
- // is a new object each render and would loop the effect via showTooltip.
78
- const containerBoundsRef = useRef( containerBounds );
79
- containerBoundsRef.current = containerBounds;
74
+ const standaloneScopeClass = useStandaloneScopeClass();
75
+ const containerRef = useRef< HTMLDivElement >( null );
76
+ // The chart root positions the tooltip, so pointer and cell coordinates are
77
+ // measured against it — found by its id rather than by walking up, so
78
+ // whatever ChartLayout wraps the grid in cannot shift the origin.
79
+ const getTooltipOrigin = useCallback(
80
+ () =>
81
+ containerRef.current
82
+ ?.closest( `[data-chart-id="heatmap-chart-${ chartId }"]` )
83
+ ?.getBoundingClientRect() ?? null,
84
+ [ chartId ]
85
+ );
80
86
 
81
87
  const { color: primaryColorHex } = getElementStyles( {
82
88
  index: 0,
83
- overrideColor: primaryColor || heatmapChartSettings.primaryColor,
89
+ overrideColor: primaryColor,
84
90
  } );
85
91
 
86
- // Resolve the background against this chart's own scope element (not the provider's), matching where `--a8c-charts-color-heatmap-background` is substituted for the cell blend below — a chart-level override otherwise disagrees with a provider-level read.
92
+ // The cell blend substitutes this role at the cell; this read happens at the scope
93
+ // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
87
94
  const chartBackgroundHex = normalizeColorToHex(
88
- theme.backgroundColor,
95
+ CATALOG_POINTERS.background,
89
96
  scopeElement,
90
97
  resolveCssVariable
91
98
  );
@@ -212,20 +219,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
212
219
  if ( ! withTooltips ) {
213
220
  return;
214
221
  }
222
+ const origin = getTooltipOrigin();
223
+ if ( ! origin ) {
224
+ return;
225
+ }
215
226
  const target = event.currentTarget;
216
227
  const columnIndex = Number( target.dataset.column );
217
228
  const rowIndex = Number( target.dataset.row );
218
- // Read bounds from the ref (like the keyboard-tooltip effect) so this
219
- // callback stays stable across renders.
220
- const bounds = containerBoundsRef.current;
221
- // TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor.
222
229
  showTooltip( {
223
- tooltipLeft: event.clientX - bounds.left,
224
- tooltipTop: event.clientY - bounds.top,
230
+ tooltipLeft: event.clientX - origin.left,
231
+ tooltipTop: event.clientY - origin.top,
225
232
  tooltipData: buildTooltipData( columnIndex, rowIndex ),
226
233
  } );
227
234
  },
228
- [ withTooltips, showTooltip, buildTooltipData ]
235
+ [ withTooltips, showTooltip, buildTooltipData, getTooltipOrigin ]
229
236
  );
230
237
 
231
238
  const handleCellMouseLeave = useCallback( () => {
@@ -241,6 +248,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
241
248
  if ( ! withTooltips || selectedIndex === undefined ) {
242
249
  return;
243
250
  }
251
+ const origin = getTooltipOrigin();
252
+ if ( ! origin ) {
253
+ return;
254
+ }
244
255
  const col = Math.floor( selectedIndex / rows );
245
256
  const row = selectedIndex % rows;
246
257
  const cell =
@@ -248,13 +259,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
248
259
  ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
249
260
  : null;
250
261
  const rect = cell?.getBoundingClientRect();
251
- const bounds = containerBoundsRef.current;
252
262
  showTooltip( {
253
- tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0,
254
- tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0,
263
+ tooltipLeft: rect ? rect.left + rect.width / 2 - origin.left : 0,
264
+ tooltipTop: rect ? rect.top + rect.height / 2 - origin.top : 0,
255
265
  tooltipData: buildTooltipData( col, row ),
256
266
  } );
257
- }, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] );
267
+ }, [
268
+ selectedIndex,
269
+ withTooltips,
270
+ rows,
271
+ chartId,
272
+ buildTooltipData,
273
+ showTooltip,
274
+ getTooltipOrigin,
275
+ ] );
258
276
 
259
277
  const defaultRenderTooltip = useCallback(
260
278
  ( info: HeatmapTooltipData ) => (
@@ -296,7 +314,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
296
314
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
297
315
  const gridStyle: Record< string, string | number > = {
298
316
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
299
- '--a8c-charts-color-heatmap-background': theme.backgroundColor,
300
317
  gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
301
318
  gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
302
319
  };
@@ -472,11 +489,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
472
489
  } ) }
473
490
  </div>
474
491
  { withTooltips && tooltipOpen && tooltipData && (
475
- <TooltipInPortal top={ tooltipTop } left={ tooltipLeft }>
476
- <div className={ CHART_SCOPE_CLASS } role="tooltip" tabIndex={ -1 }>
492
+ <BoundedTooltip top={ tooltipTop } left={ tooltipLeft }>
493
+ <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
477
494
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
478
495
  </div>
479
- </TooltipInPortal>
496
+ </BoundedTooltip>
480
497
  ) }
481
498
  </ChartLayout>
482
499
  </ChartInstanceContext.Provider>
@@ -53,6 +53,9 @@ const toDate = ( point: DataPointDate ): Date | null => {
53
53
  return null;
54
54
  };
55
55
 
56
+ // Labels stay English on the browser's calendar, unlike the time axis: this is a
57
+ // plain function the host calls outside the provider, so `GlobalChartsProvider`'s
58
+ // `locale` and `timeZone` cannot reach it. CHARTS-266 has the detail.
56
59
  export const buildCalendarHeatmapData = (
57
60
  series: DataPointDate[],
58
61
  options: {
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
15
15
 
16
16
  export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
17
17
  const context = useContext( HeatmapContext );
18
- const { legend, backgroundColor } = useGlobalChartsTheme();
18
+ const { legend } = useGlobalChartsTheme();
19
19
  if ( ! context ) {
20
20
  return null;
21
21
  }
@@ -24,7 +24,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
24
24
 
25
25
  return (
26
26
  <Stack direction="row" gap="xs" align="center">
27
- <Text variant="body-sm" style={ labelStyle }>
27
+ <Text
28
+ variant="body-sm"
29
+ className={ styles[ 'heatmap-chart__legend-label' ] }
30
+ style={ labelStyle }
31
+ >
28
32
  { lessLabel ?? __( 'Less', 'jetpack-charts' ) }
29
33
  </Text>
30
34
  <Stack direction="row" gap="xs">
@@ -38,7 +42,6 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
38
42
  style={
39
43
  {
40
44
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
41
- '--a8c-charts-color-heatmap-background': backgroundColor,
42
45
  '--a8c-charts-heatmap-cell-intensity': intensity,
43
46
  } as CSSProperties
44
47
  }
@@ -46,7 +49,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
46
49
  );
47
50
  } ) }
48
51
  </Stack>
49
- <Text variant="body-sm" style={ labelStyle }>
52
+ <Text
53
+ variant="body-sm"
54
+ className={ styles[ 'heatmap-chart__legend-label' ] }
55
+ style={ labelStyle }
56
+ >
50
57
  { moreLabel ?? __( 'More', 'jetpack-charts' ) }
51
58
  </Text>
52
59
  </Stack>
@@ -352,24 +352,21 @@ describe( 'HeatmapChart', () => {
352
352
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
353
353
  } );
354
354
 
355
- test( 'resolves primaryColor from the chart theme', () => {
356
- render(
357
- <GlobalChartsProvider theme={ { heatmapChart: { primaryColor: '#0a0b0c' } } }>
358
- <HeatmapChart width={ 500 } height={ 300 } data={ data } />
359
- </GlobalChartsProvider>
360
- );
361
- const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
362
- expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
363
- } );
355
+ test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
356
+ const scope = document.createElement( 'div' );
357
+ scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
358
+ document.body.appendChild( scope );
364
359
 
365
- test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
366
360
  render(
367
- <GlobalChartsProvider theme={ { colors: [ '#0a0b0c' ] } }>
361
+ <GlobalChartsProvider>
368
362
  <HeatmapChart width={ 500 } height={ 300 } data={ data } />
369
- </GlobalChartsProvider>
363
+ </GlobalChartsProvider>,
364
+ { container: scope }
370
365
  );
371
366
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
372
367
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
368
+
369
+ document.body.removeChild( scope );
373
370
  } );
374
371
 
375
372
  test( 'the unresponsive export pins explicit width and height', () => {
@@ -393,3 +390,45 @@ describe( 'HeatmapChart', () => {
393
390
  expect( chart ).not.toHaveStyle( { height: '300px' } );
394
391
  } );
395
392
  } );
393
+
394
+ // Chart container at (100, 50); the tooltip box measures 120x40. Everything
395
+ // else the charts measure (wrapper, clipping lookups) reports the container.
396
+ const mockRects = () =>
397
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
398
+ this: Element
399
+ ) {
400
+ const box = this.classList.contains( 'visx-tooltip' );
401
+ return {
402
+ left: box ? 0 : 100,
403
+ top: box ? 0 : 50,
404
+ width: box ? 120 : 400,
405
+ height: box ? 40 : 300,
406
+ right: box ? 120 : 500,
407
+ bottom: box ? 40 : 350,
408
+ x: box ? 0 : 100,
409
+ y: box ? 0 : 50,
410
+ toJSON: () => ( {} ),
411
+ } as DOMRect;
412
+ } );
413
+
414
+ describe( 'HeatmapChart tooltip position', () => {
415
+ afterEach( () => {
416
+ jest.restoreAllMocks();
417
+ } );
418
+
419
+ test( 'places the box relative to the chart root, at the pointer plus the offsets', async () => {
420
+ mockRects();
421
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
422
+
423
+ await userEvent.setup().pointer( {
424
+ target: screen.getAllByTestId( 'heatmap-cell' )[ 0 ],
425
+ coords: { clientX: 180, clientY: 140 },
426
+ } );
427
+
428
+ // (180, 140) is (80, 90) inside the root; the box adds 10px each way.
429
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
430
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
431
+ transform: 'translate(90px, 100px)',
432
+ } );
433
+ } );
434
+ } );
@@ -69,8 +69,8 @@ export interface HeatmapChartProps
69
69
  /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
70
70
  minCellHeight?: number;
71
71
  /**
72
- * Color the cell scale interpolates toward at the highest value
73
- * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
72
+ * Color the cell scale interpolates toward at the highest value. Defaults to the
73
+ * first series palette slot, `--a8c-charts-color-series-1`.
74
74
  */
75
75
  primaryColor?: string;
76
76
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
@@ -13,8 +13,8 @@ export const SUBPIXEL_TOLERANCE = 0.5;
13
13
  * Counts how many leading rows fit inside the content container.
14
14
  *
15
15
  * Rows are read from the DOM rather than derived from constants: row height
16
- * depends on the theme's row gap, label wrapping, and whatever a caller renders
17
- * as a label, so any restated number would drift.
16
+ * depends on `--a8c-charts-dimension-leaderboard-row-gap`, label wrapping, and
17
+ * whatever a caller renders as a label, so any restated number would drift.
18
18
  *
19
19
  * Every row is a direct grid child marked with `data-row-index`; interactive rows
20
20
  * use a button wrapper and non-interactive rows use a div wrapper.
@@ -1,6 +1,6 @@
1
1
  import { __ } from '@wordpress/i18n';
2
2
  import { useMemo } from 'react';
3
- import { useGlobalChartsTheme, useGlobalChartsContext } from '../../../providers';
3
+ import { useGlobalChartsContext } from '../../../providers';
4
4
  import type { BaseLegendItem } from '../../../components/legend';
5
5
  import type { LeaderboardEntry } from '../../../types';
6
6
 
@@ -35,7 +35,6 @@ export function useLeaderboardLegendItems( {
35
35
  comparison?: string;
36
36
  };
37
37
  } ): BaseLegendItem[] {
38
- const { leaderboardChart: leaderboardChartSettings } = useGlobalChartsTheme();
39
38
  const { getElementStyles } = useGlobalChartsContext();
40
39
 
41
40
  return useMemo( () => {
@@ -48,7 +47,7 @@ export function useLeaderboardLegendItems( {
48
47
  // Add current period legend item
49
48
  const { color: resolvedPrimaryColor } = getElementStyles( {
50
49
  index: 0,
51
- overrideColor: primaryColor || leaderboardChartSettings.primaryColor,
50
+ overrideColor: primaryColor,
52
51
  } );
53
52
 
54
53
  items.push( {
@@ -60,7 +59,7 @@ export function useLeaderboardLegendItems( {
60
59
  if ( withComparison && ! withOverlayLabel ) {
61
60
  const { color: resolvedSecondaryColor } = getElementStyles( {
62
61
  index: 1,
63
- overrideColor: secondaryColor || leaderboardChartSettings.secondaryColor,
62
+ overrideColor: secondaryColor,
64
63
  } );
65
64
 
66
65
  items.push( {
@@ -76,7 +75,6 @@ export function useLeaderboardLegendItems( {
76
75
  secondaryColor,
77
76
  withComparison,
78
77
  legendLabels,
79
- leaderboardChartSettings,
80
78
  getElementStyles,
81
79
  withOverlayLabel,
82
80
  ] );
@@ -1,7 +1,7 @@
1
1
  .leaderboardChart {
2
2
  transition:
3
- opacity var(--wpds-motion-duration-lg, 300ms)
4
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
3
+ opacity var(--wpds-motion-duration-lg)
4
+ var(--wpds-motion-easing-subtle);
5
5
 
6
6
  &--responsive {
7
7
  height: 100%;
@@ -49,7 +49,7 @@
49
49
  }
50
50
 
51
51
  .label {
52
- padding-left: var(--wpds-dimension-padding-sm, 8px);
52
+ padding-left: var(--wpds-dimension-padding-sm);
53
53
  }
54
54
  }
55
55
 
@@ -58,7 +58,7 @@
58
58
  min-height: 6px;
59
59
  // Keeps a sliver of bar visible for tiny shares, and stops the interactive
60
60
  // row's fixed-pixel hover inset from collapsing a narrow bar to nothing.
61
- min-width: var(--wpds-dimension-size-5xs, 4px);
61
+ min-width: var(--wpds-dimension-size-5xs);
62
62
 
63
63
  border-radius: var(--a8c-charts-border-radius-leaderboard-bar);
64
64
  z-index: -1; /* places it behind the label */
@@ -97,6 +97,18 @@
97
97
  font-variant-numeric: tabular-nums;
98
98
  min-width: 3ch;
99
99
  text-align: end;
100
+
101
+ &--down {
102
+ color: var(--a8c-charts-color-trend-down);
103
+ }
104
+
105
+ &--neutral {
106
+ color: var(--a8c-charts-color-trend-neutral);
107
+ }
108
+
109
+ &--up {
110
+ color: var(--a8c-charts-color-trend-up);
111
+ }
100
112
  }
101
113
 
102
114
  .deltaPlaceholder {
@@ -108,10 +120,10 @@
108
120
  }
109
121
 
110
122
  .emptyState {
111
- padding: var(--wpds-dimension-padding-3xl, 32px) var(--wpds-dimension-padding-lg, 16px);
123
+ padding: var(--wpds-dimension-padding-3xl) var(--wpds-dimension-padding-lg);
112
124
  text-align: center;
113
125
  color: var(--a8c-charts-color-label-secondary);
114
- font-size: var(--wpds-typography-font-size-md, 13px);
126
+ font-size: var(--wpds-typography-font-size-md);
115
127
  font-style: italic;
116
128
  }
117
129
 
@@ -133,7 +145,7 @@
133
145
  // interactive row's outline (and its inset offset) stay equal. A Sass var,
134
146
  // not a custom property: a compile-time constant scoped to this module, so it
135
147
  // needs no runtime cascade and never lands on the DOM as an unprefixed var.
136
- $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
148
+ $focus-ring-width: var(--wpds-border-width-focus);
137
149
 
138
150
  .row {
139
151
  display: grid;
@@ -169,16 +181,16 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
169
181
  // value slide is given an RTL override below. No row background.
170
182
  .bar {
171
183
  transition:
172
- opacity var(--wpds-motion-duration-sm, 100ms)
173
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1)),
174
- width var(--wpds-motion-duration-sm, 100ms)
175
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
184
+ opacity var(--wpds-motion-duration-sm)
185
+ var(--wpds-motion-easing-subtle),
186
+ width var(--wpds-motion-duration-sm)
187
+ var(--wpds-motion-easing-subtle);
176
188
  }
177
189
 
178
190
  .valueContainer {
179
191
  transition:
180
- transform var(--wpds-motion-duration-sm, 100ms)
181
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
192
+ transform var(--wpds-motion-duration-sm)
193
+ var(--wpds-motion-easing-subtle);
182
194
  }
183
195
 
184
196
  &:hover,
@@ -186,7 +198,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
186
198
 
187
199
  // Fixed reserve for the chevron — the value slides by it in full, and the
188
200
  // bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
189
- --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl, 32px);
201
+ --a8c-charts-dimension-leaderboard-bar-hover-inset: var(--wpds-dimension-gap-2xl);
190
202
 
191
203
  .bar {
192
204
  opacity: 0.5;
@@ -208,10 +220,10 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
208
220
  }
209
221
 
210
222
  &:focus-visible {
211
- border-radius: var(--wpds-border-radius-sm, 2px);
223
+ border-radius: var(--wpds-border-radius-sm);
212
224
  outline:
213
225
  $focus-ring-width solid
214
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
226
+ var(--wpds-color-stroke-focus);
215
227
  // Inset by its own width so the outer edge hugs the row boundary: the
216
228
  // outline then sits in the shared row padding, clear of the bar, and stays
217
229
  // inside the overflow:auto container that would clip an outset ring.
@@ -221,15 +233,15 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
221
233
 
222
234
  .chevron {
223
235
  position: absolute;
224
- inset-inline-end: var(--wpds-dimension-padding-xs, 4px);
236
+ inset-inline-end: var(--wpds-dimension-padding-xs);
225
237
  top: 0;
226
238
  bottom: 0;
227
239
  margin-block: auto;
228
240
  opacity: 0;
229
241
  color: var(--a8c-charts-color-label-secondary);
230
242
  transition:
231
- opacity var(--wpds-motion-duration-sm, 100ms)
232
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
243
+ opacity var(--wpds-motion-duration-sm)
244
+ var(--wpds-motion-easing-subtle);
233
245
  pointer-events: none;
234
246
 
235
247
  // The glyph points toward the inline end, so flip it to point left in RTL.
@@ -26,6 +26,15 @@ import styles from './leaderboard-chart.module.scss';
26
26
  import type { LeaderboardChartProps } from './types';
27
27
  import type { LeaderboardEntry } from '../../types';
28
28
 
29
+ // Handed to `Grid` as a chain rather than set in this component's stylesheet: `Grid` writes
30
+ // its own gaps through an Emotion class, which a module class of equal specificity would not
31
+ // reliably outrank. The chain lands on the element and resolves there.
32
+ const ROW_GAP = 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)';
33
+ const COLUMN_GAP = 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)';
34
+
35
+ /** Indexed by `Math.sign( delta ) + 1`, so [negative, neutral, positive]. */
36
+ const DELTA_TREND_CLASS = [ 'deltaValue--down', 'deltaValue--neutral', 'deltaValue--up' ] as const;
37
+
29
38
  /**
30
39
  * Default value formatter using formatMetricValue
31
40
  *
@@ -198,22 +207,15 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
198
207
 
199
208
  // Process children to extract compound components
200
209
  const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LeaderboardChart' );
201
- const {
202
- labelSpacing,
203
- rowGap,
204
- columnGap,
205
- primaryColor: settingsPrimaryColor,
206
- secondaryColor: settingsSecondaryColor,
207
- deltaColors,
208
- } = leaderboardChartSettings;
210
+ const { labelSpacing } = leaderboardChartSettings;
209
211
  const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
210
212
  const { color: resolvedPrimaryColor } = getElementStyles( {
211
213
  index: 0,
212
- overrideColor: primaryColor || settingsPrimaryColor,
214
+ overrideColor: primaryColor,
213
215
  } );
214
216
  const { color: resolvedSecondaryColor } = getElementStyles( {
215
217
  index: 1,
216
- overrideColor: secondaryColor || settingsSecondaryColor,
218
+ overrideColor: secondaryColor,
217
219
  } );
218
220
 
219
221
  // Create legend items using the custom hook
@@ -292,6 +294,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
292
294
  legendElement={ false }
293
295
  legendChildren={ legendChildren }
294
296
  className={ clsx(
297
+ 'leaderboard-chart',
295
298
  styles.leaderboardChart,
296
299
  {
297
300
  [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight,
@@ -336,6 +339,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
336
339
  legendElement={ legendElement }
337
340
  legendChildren={ legendChildren }
338
341
  className={ clsx(
342
+ 'leaderboard-chart',
339
343
  styles.leaderboardChart,
340
344
  {
341
345
  [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight,
@@ -372,8 +376,8 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
372
376
  ) : (
373
377
  <Grid
374
378
  templateColumns="minmax(0, 1fr) auto"
375
- rowGap={ rowGap }
376
- columnGap={ columnGap }
379
+ rowGap={ ROW_GAP }
380
+ columnGap={ COLUMN_GAP }
377
381
  data-leaderboard-grid
378
382
  >
379
383
  { data.map( ( entry, rowIndex ) => {
@@ -389,8 +393,11 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
389
393
  const hasDelta = hasDeltaValue( entry );
390
394
  const showComparisonValue = showComparisonColumn && hasDelta;
391
395
  const showComparisonPlaceholder = showComparisonColumn && ! hasDelta;
392
- const colorIndex = showComparisonValue ? Math.sign( entry.delta ) + 1 : 1;
393
- const deltaColor = deltaColors[ colorIndex ];
396
+ // Math.sign gives -1/0/1; the placeholder has no delta to read, so it takes neutral.
397
+ const deltaTrendClass =
398
+ styles[
399
+ DELTA_TREND_CLASS[ showComparisonValue ? Math.sign( entry.delta ) + 1 : 1 ]
400
+ ];
394
401
 
395
402
  const rowCells = (
396
403
  <>
@@ -417,15 +424,18 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
417
424
  { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
418
425
 
419
426
  { showComparisonValue && (
420
- <Text className={ styles.deltaValue } style={ { color: deltaColor } }>
427
+ <Text className={ clsx( styles.deltaValue, deltaTrendClass ) }>
421
428
  { deltaFormatter( entry.delta ) }
422
429
  </Text>
423
430
  ) }
424
431
 
425
432
  { showComparisonPlaceholder && (
426
433
  <Text
427
- className={ clsx( styles.deltaValue, styles.deltaPlaceholder ) }
428
- style={ { color: deltaColor } }
434
+ className={ clsx(
435
+ styles.deltaValue,
436
+ styles.deltaPlaceholder,
437
+ deltaTrendClass
438
+ ) }
429
439
  >
430
440
  <span aria-hidden="true">—</span>
431
441
  <VisuallyHidden as="span">
@@ -665,4 +665,18 @@ describe( 'LeaderboardChart', () => {
665
665
  expect( screen.getAllByRole( 'button' ) ).toHaveLength( 1 );
666
666
  } );
667
667
  } );
668
+
669
+ describe( 'grid gaps', () => {
670
+ // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
671
+ const grid = () => document.querySelector( '[data-leaderboard-grid]' );
672
+
673
+ it( 'hands the catalog role to the grid', () => {
674
+ render( <LeaderboardChart data={ mockData } /> );
675
+
676
+ expect( grid() ).toHaveStyle( {
677
+ gridRowGap: 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)',
678
+ gridColumnGap: 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)',
679
+ } );
680
+ } );
681
+ } );
668
682
  } );
@@ -113,14 +113,8 @@ describe( 'useLeaderboardLegendItems', () => {
113
113
  } );
114
114
 
115
115
  describe( 'Color handling', () => {
116
- it( 'should use default theme colors', () => {
117
- const customTheme = {
118
- leaderboardChart: {
119
- primaryColor: '#FF0000',
120
- secondaryColor: '#00FF00',
121
- },
122
- };
123
- const wrapper = createWrapper( customTheme );
116
+ it( 'gives both legend items a color from the palette', () => {
117
+ const wrapper = createWrapper();
124
118
  const { result } = renderHook(
125
119
  () =>
126
120
  useLeaderboardLegendItems( {
@@ -131,7 +125,6 @@ describe( 'useLeaderboardLegendItems', () => {
131
125
  { wrapper }
132
126
  );
133
127
 
134
- // Note: The actual color will be resolved by the context, but we can check structure
135
128
  expect( result.current[ 0 ].color ).toBeTruthy();
136
129
  expect( result.current[ 1 ].color ).toBeTruthy();
137
130
  } );