@automattic/charts 4.7.0 → 4.9.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +834 -321
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +68 -17
  6. package/dist/index.d.cts +102 -28
  7. package/dist/index.d.ts +102 -28
  8. package/dist/index.js +838 -327
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +7 -4
  12. package/src/charts/area-chart/test/area-chart.test.tsx +24 -15
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  15. package/src/charts/area-chart/types.ts +2 -0
  16. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  17. package/src/charts/bar-chart/bar-chart.tsx +25 -16
  18. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  19. package/src/charts/bar-chart/test/bar-chart.test.tsx +106 -62
  20. package/src/charts/bar-chart/types.ts +2 -0
  21. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -8
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +183 -66
  26. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  27. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  28. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  29. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +15 -3
  30. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  31. package/src/charts/heatmap-chart/private/index.ts +6 -3
  32. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  33. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  34. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  35. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +231 -8
  36. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  37. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  38. package/src/charts/heatmap-chart/types.ts +6 -3
  39. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +46 -4
  40. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +37 -1
  41. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +53 -0
  42. package/src/charts/leaderboard-chart/types.ts +6 -1
  43. package/src/charts/line-chart/line-chart.tsx +7 -4
  44. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  46. package/src/charts/line-chart/test/line-chart.test.tsx +45 -35
  47. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  48. package/src/charts/line-chart/types.ts +5 -3
  49. package/src/charts/pie-chart/pie-chart.tsx +153 -143
  50. package/src/charts/pie-chart/test/pie-chart.test.tsx +171 -0
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +126 -124
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +55 -1
  53. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  54. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  55. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  57. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  58. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  59. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +44 -0
  60. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  61. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  62. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  63. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  64. package/src/charts/private/test/label-text-color.test.ts +6 -8
  65. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  66. package/src/components/tooltip/base-tooltip.tsx +31 -32
  67. package/src/components/tooltip/index.ts +3 -1
  68. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  69. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  70. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  71. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  72. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  74. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +283 -365
  75. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  76. package/src/components/tooltip/tooltip-box.tsx +43 -0
  77. package/src/components/tooltip/types.ts +7 -1
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  79. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  80. package/src/hooks/use-chart-margin.tsx +21 -18
  81. package/src/index.ts +8 -3
  82. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  83. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  84. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  85. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  86. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  87. package/src/styles/chart-scope.scss +1 -0
  88. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  89. package/src/types.ts +18 -2
  90. package/src/utils/test/date-formatting.test.ts +0 -10
  91. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  92. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -282,16 +282,20 @@ describe( 'useChartMargin', () => {
282
282
  expect( result.current.left ).toBe( 60 );
283
283
  } );
284
284
 
285
- it( 'reserves nothing for a hidden x axis', () => {
285
+ it.each( [ 'bottom', 'top' ] )( 'reserves nothing for a hidden %s x axis', orientation => {
286
286
  mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
287
+ const largeLabels = { axisLabel: { fontSize: 16 }, tickLength: 10 } as unknown as never;
288
+ const theme = {
289
+ ...baseTheme,
290
+ axisStyles: { ...baseTheme.axisStyles, x: { top: largeLabels, bottom: largeLabels } },
291
+ } as XYChartTheme;
287
292
 
288
293
  const { result } = renderHook( () =>
289
- useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme )
294
+ useChartMargin( 300, datedXOptions( { display: false, orientation } ), data, theme )
290
295
  );
291
296
 
292
297
  expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled();
293
- expect( result.current.right ).toBe( 20 );
294
- expect( result.current.left ).toBe( 51 );
298
+ expect( result.current ).toEqual( { top: 10, right: 20, bottom: 10, left: 51 } );
295
299
  } );
296
300
  } );
297
301
 
@@ -27,10 +27,10 @@ const DEFAULT_MARGIN_BOTTOM = 20;
27
27
  const DEFAULT_MARGIN_LEFT = 20;
28
28
 
29
29
  /**
30
- * Bottom margin to use when the X-axis is rendered at the top.
30
+ * Bottom margin when no X-axis sits below the chart, because it is on top or hidden.
31
31
  * We only need a small buffer below the chart in that case.
32
32
  */
33
- const DEFAULT_BOTTOM_FOR_TOP_AXIS = 10;
33
+ const DEFAULT_BOTTOM_WITHOUT_X_AXIS = 10;
34
34
 
35
35
  /**
36
36
  * Fallback font size used when we cannot derive a font size
@@ -168,23 +168,24 @@ export const useChartMargin = (
168
168
  }
169
169
  }
170
170
 
171
- // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
172
- // This mirrors Y-axis behavior where margin is based on label size and tick length,
173
- // but keeps the padding minimal so consumers can control container spacing themselves.
174
- const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
175
- const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
176
- const computedXMargin = fontSize + tickLength;
177
-
178
- if ( xOrientation === 'top' ) {
179
- defaultMargin.top = Math.max( defaultMargin.top, computedXMargin );
180
- defaultMargin.bottom = DEFAULT_BOTTOM_FOR_TOP_AXIS;
181
- } else {
182
- defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
183
- }
184
-
185
- // An X-axis label is centered on its tick, so the ones at either end of the
186
- // scale hang half their width outside the plot area and clip at the SVG edge.
171
+ // A hidden x axis reserves nothing either, whichever side it sits on.
187
172
  if ( options.axis?.x?.display !== false ) {
173
+ // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
174
+ // This mirrors Y-axis behavior where margin is based on label size and tick length,
175
+ // but keeps the padding minimal so consumers can control container spacing themselves.
176
+ const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
177
+ const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
178
+ const computedXMargin = fontSize + tickLength;
179
+
180
+ if ( xOrientation === 'top' ) {
181
+ defaultMargin.top = Math.max( defaultMargin.top, computedXMargin );
182
+ defaultMargin.bottom = DEFAULT_BOTTOM_WITHOUT_X_AXIS;
183
+ } else {
184
+ defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
185
+ }
186
+
187
+ // An X-axis label is centered on its tick, so the ones at either end of the
188
+ // scale hang half their width outside the plot area and clip at the SVG edge.
188
189
  const { first, last } = getEdgeTickWidths(
189
190
  options.axis?.x?.tickValues ?? [],
190
191
  options.axis?.x?.tickFormat,
@@ -193,6 +194,8 @@ export const useChartMargin = (
193
194
 
194
195
  defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) );
195
196
  defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) );
197
+ } else {
198
+ defaultMargin.bottom = DEFAULT_BOTTOM_WITHOUT_X_AXIS;
196
199
  }
197
200
 
198
201
  return defaultMargin;
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export { PieSemiCircleChart, PieSemiCircleChartUnresponsive } from './charts/pie
21
21
  export { Sparkline, SparklineUnresponsive } from './charts/sparkline';
22
22
 
23
23
  // Components
24
- export { BaseTooltip } from './components/tooltip';
24
+ export { BaseTooltip, TooltipBox } from './components/tooltip';
25
25
  export { Legend, useChartLegendItems } from './components/legend';
26
26
  export { TrendIndicator } from './components/trend-indicator';
27
27
 
@@ -97,8 +97,13 @@ export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart';
97
97
  export { useLeaderboardLegendItems } from './charts/leaderboard-chart/hooks';
98
98
 
99
99
  // Previously available via '@automattic/charts/tooltip', '@automattic/charts/legend'
100
- export { AccessibleTooltip } from './components/tooltip';
101
- export type { BaseTooltipProps, TooltipData, TooltipProps } from './components/tooltip';
100
+ export { AccessibleTooltip, XYChartTooltip } from './components/tooltip';
101
+ export type {
102
+ BaseTooltipProps,
103
+ TooltipBoxProps,
104
+ TooltipData,
105
+ TooltipProps,
106
+ } from './components/tooltip';
102
107
  export type { LegendProps, BaseLegendProps, ChartLegendOptions } from './components/legend';
103
108
 
104
109
  // Previously available via '@automattic/charts/bar-chart', '@automattic/charts/line-chart', etc.
@@ -1,4 +1,3 @@
1
- import { color as d3Color } from '@visx/vendor/d3-color';
2
1
  import {
3
2
  createContext,
4
3
  useCallback,
@@ -14,6 +13,7 @@ import {
14
13
  import '../../styles/chart-scope.scss';
15
14
  import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
16
15
  import {
16
+ createCssVariableResolver,
17
17
  getItemShapeStyles,
18
18
  getSeriesBarStyles,
19
19
  getSeriesLineStyles,
@@ -32,6 +32,7 @@ import {
32
32
  LABEL_INVERSE_FALLBACK,
33
33
  } from './private/catalog-pointers';
34
34
  import { createPaletteGenerator } from './private/palette-generator';
35
+ import { resolveOpaqueHex } from './private/resolve-opaque-hex';
35
36
  import { SERIES_PALETTE_POINTERS, SERIES_SLOT_1_FALLBACK } from './private/series-palette';
36
37
  import { defaultTheme } from './themes';
37
38
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
@@ -45,17 +46,6 @@ interface ColorCache {
45
46
  colorAt: ( index: number ) => string;
46
47
  }
47
48
 
48
- // A see-through color (transparent, or any alpha below 1) says nothing about what it will look like
49
- // over the chart, so it resolves to null rather than let its RGB leak into the palette.
50
- const resolveOpaqueHex = ( pointer: string, element: HTMLElement | null ): string | null => {
51
- const raw = resolveCssVariable( pointer, element );
52
- if ( ! raw || d3Color( raw )?.opacity !== 1 ) {
53
- return null;
54
- }
55
- const hex = normalizeColorToHex( pointer, element, resolveCssVariable );
56
- return isValidHexColor( hex ) ? hex : null;
57
- };
58
-
59
49
  const PLACEHOLDER_LABEL_COLORS = [ LABEL_FALLBACK, LABEL_INVERSE_FALLBACK ];
60
50
 
61
51
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
@@ -143,11 +133,12 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
143
133
  }
144
134
  }
145
135
 
136
+ const resolveAtWrapper = createCssVariableResolver( wrapperRef.current );
146
137
  const backgroundHex =
147
- resolveOpaqueHex( CATALOG_POINTERS.background, wrapperRef.current ) ?? BACKGROUND_FALLBACK;
138
+ resolveOpaqueHex( CATALOG_POINTERS.background, resolveAtWrapper ) ?? BACKGROUND_FALLBACK;
148
139
  // The two roles pie labels choose between on a fill; one left see-through is never painted there.
149
140
  const labelColors = [ CATALOG_POINTERS.label, CATALOG_POINTERS.labelInverse ]
150
- .map( pointer => resolveOpaqueHex( pointer, wrapperRef.current ) )
141
+ .map( pointer => resolveOpaqueHex( pointer, resolveAtWrapper ) )
151
142
  .filter( ( hex ): hex is string => hex !== null );
152
143
 
153
144
  setColorCache( {
@@ -37,7 +37,7 @@ export const useDefaultHiddenSeries = (
37
37
  // getHiddenSeries hands out a fresh copy per call, so hold the set still between
38
38
  // visibility changes. Charts derive their rendered series from it, and a new
39
39
  // identity every render invalidates every memo downstream — including the one
40
- // the accessible tooltip watches, which closes a tooltip mid-navigation.
40
+ // `XYChartTooltip` watches, which closes a tooltip mid-navigation.
41
41
  return useMemo(
42
42
  () => ( shouldUseDefaults ? new Set( labelsRef.current ) : getHiddenSeries( chartId ) ),
43
43
  [ shouldUseDefaults, getHiddenSeries, chartId ]
@@ -24,6 +24,7 @@ export const CATALOG_POINTERS = {
24
24
  annotation: 'var(--a8c-charts-color-annotation, #1e1e1e)',
25
25
  surface: 'var(--a8c-charts-color-surface, #fff)',
26
26
  surfaceSecondary: 'var(--a8c-charts-color-surface-secondary, #f4f4f4)',
27
+ track: 'var(--a8c-charts-color-track, #f0f0f0)',
27
28
  // Only for `renderMainMetric`, which hands a consumer's own markup something to paint with.
28
29
  trendUp: 'var(--a8c-charts-color-trend-up, #008030)',
29
30
  trendDown: 'var(--a8c-charts-color-trend-down, #cc1818)',
@@ -0,0 +1,16 @@
1
+ import { color as d3Color } from '@visx/vendor/d3-color';
2
+ import { isValidHexColor, normalizeColorToHex } from '../../../utils/color-utils';
3
+
4
+ // A see-through color (transparent, or any alpha below 1) says nothing about what it will look like
5
+ // over the chart, so it resolves to null rather than let its RGB stand in for the color.
6
+ export const resolveOpaqueHex = (
7
+ pointer: string,
8
+ resolve: ( value: string ) => string | null
9
+ ): string | null => {
10
+ const raw = resolve( pointer );
11
+ if ( ! raw || d3Color( raw )?.opacity !== 1 ) {
12
+ return null;
13
+ }
14
+ const hex = normalizeColorToHex( raw );
15
+ return isValidHexColor( hex ) ? hex : null;
16
+ };
@@ -177,7 +177,7 @@ describe( 'useDefaultHiddenSeries', () => {
177
177
 
178
178
  it( 'keeps the same set across renders until visibility changes', () => {
179
179
  // Charts memoize their rendered series off this set. A fresh identity per
180
- // render invalidates those memos, which re-runs the accessible tooltip's
180
+ // render invalidates those memos, which re-runs `XYChartTooltip`'s
181
181
  // effect and closes a tooltip the user is still navigating with.
182
182
  const Harness = () => (
183
183
  <GlobalChartsProvider>
@@ -80,6 +80,7 @@
80
80
  --a8c-charts-motion-duration-entrance: var(--wpds-motion-duration-xl);
81
81
  --a8c-charts-motion-easing-entrance: var(--wpds-motion-easing-expressive);
82
82
  --a8c-charts-border-radius-bar: var(--wpds-border-radius-md);
83
+ --a8c-charts-border-radius-bar-chart: 0;
83
84
  --a8c-charts-border-radius-cell: var(--wpds-border-radius-sm);
84
85
  // Pill shape; no WPDS radius reaches it.
85
86
  --a8c-charts-border-radius-leaderboard-bar: 9999px;
@@ -1,15 +1,5 @@
1
1
  import { parseAsLocalDate } from '../utils/date-parsing';
2
2
 
3
- // The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
4
- declare global {
5
- // eslint-disable-next-line @typescript-eslint/no-namespace
6
- namespace jest {
7
- interface Matchers< R > {
8
- toHaveWarnedWith( ...args: unknown[] ): R;
9
- }
10
- }
11
- }
12
-
13
3
  // Every case asserts an absolute instant, so running the suite from more than one
14
4
  // worker zone is what proves the result does not depend on the viewer's.
15
5
  const CASES: Array< [ string, string, string ] > = [
package/src/types.ts CHANGED
@@ -191,7 +191,7 @@ export type DataPointDate = {
191
191
  color?: string;
192
192
  };
193
193
 
194
- export type LeaderboardEntry = {
194
+ type LeaderboardEntryFields = {
195
195
  /**
196
196
  * Unique internal key (e.g., 'key-direct')
197
197
  */
@@ -245,7 +245,8 @@ export type LeaderboardEntry = {
245
245
  * For links or other interactive affordances (external-link icons, info
246
246
  * tooltips), put them in the `label` render prop instead of using onClick —
247
247
  * a row is either a button (onClick) or carries interactive label content,
248
- * never both, since interactive elements cannot be nested in HTML.
248
+ * never both, since interactive elements cannot be nested in HTML. The
249
+ * exception is a row that sets `hasInteractiveLabel`.
249
250
  */
250
251
  onClick?: ( event: MouseEvent< HTMLButtonElement > ) => void;
251
252
 
@@ -261,6 +262,21 @@ export type LeaderboardEntry = {
261
262
  ariaLabel?: string;
262
263
  };
263
264
 
265
+ export type LeaderboardEntry = LeaderboardEntryFields &
266
+ (
267
+ | { hasInteractiveLabel?: false }
268
+ | {
269
+ /**
270
+ * Set when the `label` holds its own link or button. The row's button
271
+ * then sits behind the row instead of around it, so both stay operable.
272
+ * Requires `onClick`, and `ariaLabel` since the button wraps no text.
273
+ */
274
+ hasInteractiveLabel: true;
275
+ onClick: ( event: MouseEvent< HTMLButtonElement > ) => void;
276
+ ariaLabel: string;
277
+ }
278
+ );
279
+
264
280
  export type GradientStop = {
265
281
  offset: string;
266
282
  color?: string;
@@ -1,15 +1,5 @@
1
1
  import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
2
2
 
3
- // The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
4
- declare global {
5
- // eslint-disable-next-line @typescript-eslint/no-namespace
6
- namespace jest {
7
- interface Matchers< R > {
8
- toHaveWarned(): R;
9
- }
10
- }
11
- }
12
-
13
3
  describe( 'createDateFormatter', () => {
14
4
  it( 'renders an invalid date rather than throwing', () => {
15
5
  const format = createDateFormatter(