@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
@@ -2,8 +2,8 @@
2
2
  display: inline-flex;
3
3
  align-items: center;
4
4
  gap: 0.125em;
5
- font-size: var(--wpds-typography-font-size-md, 13px);
6
- font-weight: var(--wpds-typography-font-weight-emphasis, 600);
5
+ font-size: var(--wpds-typography-font-size-md);
6
+ font-weight: var(--wpds-typography-font-weight-emphasis);
7
7
  line-height: 1;
8
8
 
9
9
  &--up {
@@ -4,7 +4,7 @@ import { useXYChartTheme } from '../use-xychart-theme';
4
4
  import type { SeriesData } from '../../types';
5
5
  import type { ReactNode } from 'react';
6
6
 
7
- // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs only when the scope element attaches or a series colour changes, so a dashboard mounting N charts pays the count below N times over.
7
+ // The theme resolves the background role and the five palette slots. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs only when the scope element attaches or a series color changes, so a dashboard mounting N charts pays the count below N times over.
8
8
  const STABLE_DATA: SeriesData[] = [];
9
9
 
10
10
  describe( 'useXYChartTheme style reads', () => {
@@ -13,7 +13,6 @@ describe( 'useXYChartTheme style reads', () => {
13
13
 
14
14
  beforeEach( () => {
15
15
  scope = document.createElement( 'div' );
16
- scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
17
16
  document.body.appendChild( scope );
18
17
  getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
19
18
  } );
@@ -33,14 +32,13 @@ describe( 'useXYChartTheme style reads', () => {
33
32
 
34
33
  it( 'reads every role from one computed-style snapshot, not one per role', () => {
35
34
  scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
36
- scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
35
+ scope.style.setProperty( '--a8c-charts-color-series-2', '#222222' );
37
36
 
38
37
  const { result } = renderWithScope();
39
38
 
40
39
  expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
41
40
  expect( result.current.backgroundColor ).toBe( '#111111' );
42
- expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
43
- expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe( '#222222' );
41
+ expect( result.current.colors ).toContain( '#222222' );
44
42
  } );
45
43
 
46
44
  it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
@@ -74,16 +72,25 @@ describe( 'useXYChartTheme style reads', () => {
74
72
  expect( result.current.colors[ 0 ] ).toBe( 'rebeccapurple' );
75
73
  } );
76
74
 
77
- // The memo key serialises the strokes rather than joining them, so a colour containing a comma or a space survives the round-trip intact.
75
+ // The memo key serializes the strokes rather than joining them, so a color containing a comma or a space survives the round-trip intact. `rgba(0, 0, 0, 0.5)` is the one that proves it: it holds both separators and comes back whole.
76
+ //
77
+ // The `var()` entry resolves to its fallback rather than passing through, which is the point of resolving the palette at all — visx paints these as SVG presentation attributes, where a `var()` resolves to nothing.
78
78
  it( 'keeps a stroke that contains separators intact', () => {
79
79
  const { result } = renderWithScope( [
80
80
  { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
81
81
  { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
82
82
  ] as unknown as SeriesData[] );
83
83
 
84
- expect( result.current.colors.slice( 0, 2 ) ).toEqual( [
85
- 'rgba(0, 0, 0, 0.5)',
86
- 'var(--brand, #fff)',
87
- ] );
84
+ expect( result.current.colors.slice( 0, 2 ) ).toEqual( [ 'rgba(0, 0, 0, 0.5)', '#fff' ] );
85
+ } );
86
+
87
+ // Four of the five palette slots carry no catalog default, so in jsdom they resolve to nothing. Passing those to visx unresolved would make them its default stroke for any series without an explicit one, painting nothing at all.
88
+ it( 'drops palette entries that resolve to nothing', () => {
89
+ const { result } = renderWithScope( [] );
90
+
91
+ expect( result.current.colors ).not.toEqual(
92
+ expect.arrayContaining( [ expect.stringContaining( 'var(' ) ] )
93
+ );
94
+ expect( result.current.colors.length ).toBeGreaterThan( 0 );
88
95
  } );
89
96
  } );
@@ -4,11 +4,11 @@ import { useXYChartTheme } from '../use-xychart-theme';
4
4
  import type { ReactNode } from 'react';
5
5
 
6
6
  describe( 'useXYChartTheme', () => {
7
- it( 'resolves grid colour against the scope element, not the document root', () => {
8
- document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#ff0000' );
7
+ it( 'resolves background color against the scope element, not the document root', () => {
8
+ document.documentElement.style.setProperty( '--a8c-charts-color-background', '#ff0000' );
9
9
 
10
10
  const scope = document.createElement( 'div' );
11
- scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
11
+ scope.style.setProperty( '--a8c-charts-color-background', '#00ff00' );
12
12
  document.body.appendChild( scope );
13
13
 
14
14
  const wrapper = ( { children }: { children: ReactNode } ) => (
@@ -17,9 +17,67 @@ describe( 'useXYChartTheme', () => {
17
17
 
18
18
  const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
19
19
 
20
- expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
20
+ expect( result.current.backgroundColor ).toBe( '#00ff00' );
21
21
 
22
- document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
22
+ document.documentElement.style.removeProperty( '--a8c-charts-color-background' );
23
23
  document.body.removeChild( scope );
24
24
  } );
25
+
26
+ // jsdom does not compute `var()`, so what a pointer resolves *to* is covered in Storybook.
27
+ it( 'hands visx the catalog pointer for paint-only colors rather than a resolved value', () => {
28
+ const scope = document.createElement( 'div' );
29
+ document.body.appendChild( scope );
30
+
31
+ const wrapper = ( { children }: { children: ReactNode } ) => (
32
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
33
+ );
34
+
35
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
36
+
37
+ expect( result.current.gridStyles.stroke ).toBe( 'var(--a8c-charts-color-grid, #dbdbdb)' );
38
+ expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe(
39
+ 'var(--a8c-charts-color-axis-x, #dbdbdb)'
40
+ );
41
+ expect( result.current.axisStyles.x.bottom.tickLine.stroke ).toBe(
42
+ 'var(--a8c-charts-color-tick-x, #dbdbdb)'
43
+ );
44
+ expect( result.current.axisStyles.x.bottom.tickLabel.fill ).toBe(
45
+ 'var(--a8c-charts-color-label-axis, #1e1e1e)'
46
+ );
47
+
48
+ document.body.removeChild( scope );
49
+ } );
50
+
51
+ // The one label color that has to arrive resolved: visx paints it on a portal container appended to `document.body`, where the catalog is not declared, and concatenates it into `box-shadow: 0 1px 2px ${color}55`, which a `var()` chain would invalidate.
52
+ it( 'resolves the tooltip label color while the tick labels keep the pointer', () => {
53
+ const scope = document.createElement( 'div' );
54
+ scope.style.setProperty( '--a8c-charts-color-label-axis', '#0000ff' );
55
+ document.body.appendChild( scope );
56
+
57
+ const wrapper = ( { children }: { children: ReactNode } ) => (
58
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
59
+ );
60
+
61
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
62
+
63
+ expect( result.current.htmlLabel.color ).toBe( '#0000ff' );
64
+ expect( result.current.svgLabelSmall.fill ).toBe(
65
+ 'var(--a8c-charts-color-label-axis, #1e1e1e)'
66
+ );
67
+
68
+ document.body.removeChild( scope );
69
+ } );
70
+
71
+ it( 'gives the y axis its own roles, resolving to none', () => {
72
+ const { result } = renderHook( () => useXYChartTheme( [] ) );
73
+
74
+ for ( const side of [ 'left', 'right' ] as const ) {
75
+ expect( result.current.axisStyles.y[ side ].axisLine.stroke ).toBe(
76
+ 'var(--a8c-charts-color-axis-y, none)'
77
+ );
78
+ expect( result.current.axisStyles.y[ side ].tickLine.stroke ).toBe(
79
+ 'var(--a8c-charts-color-tick-y, none)'
80
+ );
81
+ }
82
+ } );
25
83
  } );
@@ -1,15 +1,16 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
+ import { CATALOG_POINTERS } from '../providers/chart-context/private/catalog-pointers';
4
5
  import { useChartScopeElement } from '../providers/chart-scope';
5
- import { createCssVariableResolver } from '../utils';
6
+ import { createCssVariableResolver, normalizeColorToHex } from '../utils';
6
7
  import type { SeriesData } from '../types';
7
8
 
8
9
  export const useXYChartTheme = ( data: SeriesData[] ) => {
9
10
  const theme = useGlobalChartsTheme();
10
11
  const scopeElement = useChartScopeElement();
11
12
 
12
- // The only thing the theme takes from `data` is the series strokes, so key the memo on those rather than on the array's identity. A caller passing an inline literal — `<LineChart data={ [ … ] } />`, which the stories and several consumers do — otherwise rebuilds the whole theme on every render. Serialised rather than joined: a stroke can be `rgba(0, 0, 0, 0.5)` or `var(--brand, #fff)`, and any separator that reads naturally inside a colour cannot round-trip.
13
+ // The only thing the theme takes from `data` is the series strokes, so key the memo on those rather than on the array's identity. A caller passing an inline literal — `<LineChart data={ [ … ] } />`, which the stories and several consumers do — otherwise rebuilds the whole theme on every render. Serialized rather than joined: a stroke can be `rgba(0, 0, 0, 0.5)` or `var(--brand, #fff)`, and any separator that reads naturally inside a color cannot round-trip.
13
14
  const seriesColorKey = JSON.stringify(
14
15
  ( data ?? [] )
15
16
  .map( series => series.options?.stroke )
@@ -17,35 +18,44 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
17
18
  );
18
19
 
19
20
  return useMemo( () => {
20
- // visx applies grid, axis, and tick-label colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve the catalog token against the chart's own scope element — never :root — so this reads any override set inside the provider tree, the same one a CSS-painted element would inherit.
21
- //
22
- // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each: this memo re-runs only when the scope element attaches or a series colour changes, which for a dashboard of charts is the difference between two style queries per chart and ten.
21
+ // Only what is read as a string is resolved: the palette, `backgroundColor`, `htmlLabel.color`.
22
+ // At the chart's scope element, never :root, so an override inside the provider tree is seen.
23
+ // One resolver for all three, so they share a single getComputedStyle call.
23
24
  const resolve = createCssVariableResolver( scopeElement );
24
25
  const resolveColor = ( value?: string ): string | undefined =>
25
26
  value ? resolve( value ) ?? value : value;
26
27
 
27
28
  const seriesColors: string[] = JSON.parse( seriesColorKey );
28
29
 
30
+ // visx uses this as the default stroke for a series without one, so an
31
+ // unresolved entry paints nothing.
32
+ const paletteColors = [ ...seriesColors, ...CATALOG_POINTERS.series ]
33
+ .map( color => resolveColor( color ) )
34
+ .filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
35
+
36
+ // The tooltip is painted in a portal outside the scope, and visx concatenates this color into a `box-shadow` where a chain cannot take a suffix; see TOKENS.md#the-svg-bridge. Passing it explicitly leaves `svgLabelSmall.fill`, which visx derives it from, a chain for the SVG tick labels.
37
+ // Hex specifically: that concatenation appends `55`, which only yields a color
38
+ // after a 6-digit hex. An `rgb()` computed value takes the whole shadow down.
39
+ const resolvedLabelColor = resolveColor( CATALOG_POINTERS.labelAxis );
40
+ const htmlLabelColor = resolvedLabelColor
41
+ ? normalizeColorToHex( resolvedLabelColor ) || resolvedLabelColor
42
+ : resolvedLabelColor;
43
+
44
+ // visx's fallbacks for whichever axis or grid style object it is not given. All four are
45
+ // supplied, so these reach nothing; they stay because the config type requires them.
29
46
  return buildChartTheme( {
30
47
  ...theme,
31
- colors: [ ...seriesColors, ...( theme.colors ?? [] ) ],
32
- backgroundColor: resolveColor( theme.backgroundColor ),
33
- gridStyles: theme.gridStyles && {
34
- ...theme.gridStyles,
35
- stroke: resolveColor( theme.gridStyles.stroke ),
36
- },
37
- xAxisLineStyles: theme.xAxisLineStyles && {
38
- ...theme.xAxisLineStyles,
39
- stroke: resolveColor( theme.xAxisLineStyles.stroke ),
40
- },
41
- xTickLineStyles: theme.xTickLineStyles && {
42
- ...theme.xTickLineStyles,
43
- stroke: resolveColor( theme.xTickLineStyles.stroke ),
44
- },
45
- svgLabelSmall: theme.svgLabelSmall && {
46
- ...theme.svgLabelSmall,
47
- fill: resolveColor( theme.svgLabelSmall.fill ),
48
- },
48
+ gridColor: '',
49
+ gridColorDark: '',
50
+ colors: paletteColors,
51
+ backgroundColor: resolveColor( CATALOG_POINTERS.background ),
52
+ htmlLabel: htmlLabelColor ? { color: htmlLabelColor } : undefined,
53
+ gridStyles: { ...theme.gridStyles, stroke: CATALOG_POINTERS.grid },
54
+ xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: CATALOG_POINTERS.axisX },
55
+ xTickLineStyles: { ...theme.xTickLineStyles, stroke: CATALOG_POINTERS.tickX },
56
+ yAxisLineStyles: { stroke: CATALOG_POINTERS.axisY },
57
+ yTickLineStyles: { stroke: CATALOG_POINTERS.tickY },
58
+ svgLabelSmall: { ...theme.svgLabelSmall, fill: CATALOG_POINTERS.labelAxis },
49
59
  } );
50
60
  }, [ theme, seriesColorKey, scopeElement ] );
51
61
  };
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  GlobalChartsProvider,
33
33
  useGlobalChartsContext,
34
34
  useGlobalChartsTheme,
35
+ useChartFormatting,
35
36
  GlobalChartsContext,
36
37
  defaultTheme,
37
38
  useChartScopeElement,
@@ -55,6 +56,7 @@ export type {
55
56
  ChartTheme,
56
57
  CompleteChartTheme,
57
58
  AxisOptions,
59
+ ChartFormatting,
58
60
  TickResolution,
59
61
  ScaleOptions,
60
62
  LegendItemStyles,
@@ -126,6 +128,7 @@ export type { SparklineProps, GradientConfig, SparklineDataPoint } from './chart
126
128
 
127
129
  // Utilities
128
130
  export {
131
+ getBucketInfo,
129
132
  parseAsLocalDate,
130
133
  formatMetricValue,
131
134
  formatPercentage,
@@ -134,3 +137,4 @@ export {
134
137
  } from './utils';
135
138
  export * from './utils/color-utils';
136
139
  export type { MetricValueType } from './utils';
140
+ export type { BucketInfo } from './types';
@@ -21,24 +21,39 @@ import {
21
21
  resolveCssVariable,
22
22
  normalizeColorToHex,
23
23
  } from '../../utils';
24
+ import { sanitizeFormatting } from '../../utils/date-formatting';
24
25
  // Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
25
26
  import { ChartScopeContext } from '../chart-scope/chart-scope-context';
26
27
  import { getChartColor, type ColorCache } from './private/get-chart-color';
27
- import { themeOverrideVars } from './private/theme-override-vars';
28
- import { withCatalogPointers } from './private/with-catalog-pointers';
28
+ import { SERIES_PALETTE_POINTERS } from './private/series-palette';
29
29
  import { defaultTheme } from './themes';
30
30
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
31
31
  import type { ChartTheme, CompleteChartTheme } from '../../types';
32
- import type { CSSProperties, FC, ReactNode } from 'react';
32
+ import type { FC, ReactNode } from 'react';
33
33
 
34
34
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
35
35
 
36
36
  export interface GlobalChartsProviderProps {
37
37
  children: ReactNode;
38
38
  theme?: Partial< ChartTheme >;
39
+ /**
40
+ * BCP-47 language tag every date label is rendered in, e.g. `de-DE`.
41
+ * Defaults to the viewer's browser locale.
42
+ */
43
+ locale?: string;
44
+ /**
45
+ * IANA time zone every date label is dated in, e.g. `Asia/Tokyo`.
46
+ * Defaults to the viewer's browser time zone.
47
+ */
48
+ timeZone?: string;
39
49
  }
40
50
 
41
- export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => {
51
+ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
52
+ children,
53
+ theme,
54
+ locale,
55
+ timeZone,
56
+ } ) => {
42
57
  const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() );
43
58
  // Track hidden series per chart: chartId -> Set<seriesLabel>
44
59
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
@@ -58,20 +73,11 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
58
73
  setScopeNode( node );
59
74
  }, [] );
60
75
 
61
- // themeOverrideVars reads the raw `theme` prop, never `providerTheme` — feeding it the restored theme below would make an overridden role's pointer look like a self-reference and drop the var (see themeOverrideVars' own doc comment).
62
- const { vars: overrideVars, roles: overriddenRoles } = useMemo(
63
- () => themeOverrideVars( theme ),
76
+ const providerTheme: CompleteChartTheme = useMemo(
77
+ () => ( theme ? mergeThemes( defaultTheme, theme ) : defaultTheme ),
64
78
  [ theme ]
65
79
  );
66
80
 
67
- const providerTheme: CompleteChartTheme = useMemo( () => {
68
- if ( ! theme ) {
69
- return defaultTheme;
70
- }
71
-
72
- return withCatalogPointers( mergeThemes( defaultTheme, theme ), overriddenRoles );
73
- }, [ theme, overriddenRoles ] );
74
-
75
81
  // Cache expensive color computations that only change when theme colors change
76
82
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
77
83
  // in <style> tags are applied to the DOM before we try to resolve them
@@ -87,49 +93,42 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
87
93
  // Useful for animations that should only run after the color palette is resolved
88
94
  const [ isColorPaletteResolved, setIsColorPaletteResolved ] = useState( false );
89
95
 
90
- // Compute color cache after DOM is updated (so CSS variables are available)
91
- // Resolves CSS variables from the wrapper element's scope to handle scoped variables
92
- // Note: Only re-runs when providerTheme changes, not when wrapper element changes.
93
- // This is intentional, as wrapperRef is expected to be stable for the lifetime of the provider.
96
+ // A layout effect rather than a memo: the catalog reaches the wrapper as a stylesheet, which
97
+ // must be applied before `getComputedStyle` can answer. Mount only — the slots are a fixed
98
+ // manifest and `wrapperRef` is stable for the provider's life.
99
+
94
100
  useLayoutEffect( () => {
95
101
  setIsColorPaletteResolved( false );
96
- const { colors } = providerTheme;
97
102
  const resolvedColors: string[] = [];
98
103
  const hues: number[] = [];
99
104
  const existingHslColors: Array< [ number, number, number ] > = [];
100
105
  let minHue = 360;
101
106
  let maxHue = 0;
102
107
 
103
- // Process all colors once and cache the results
104
- if ( Array.isArray( colors ) ) {
105
- for ( const color of colors ) {
106
- if ( color && typeof color === 'string' ) {
107
- // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
108
- // This uses normalizeColorToHex which resolves CSS variables and converts
109
- // rgb(), rgba(), hsl() formats to hex
110
- const normalizedColor = normalizeColorToHex(
111
- color,
112
- wrapperRef.current,
113
- resolveCssVariable
114
- );
115
-
116
- // Only process valid hex colors
117
- if ( normalizedColor.startsWith( '#' ) ) {
118
- resolvedColors.push( normalizedColor );
119
- const hslColor = d3Hsl( normalizedColor );
120
- // d3Hsl returns NaN values for invalid colors
121
- if ( ! isNaN( hslColor.h ) ) {
122
- const hslTuple: [ number, number, number ] = [
123
- hslColor.h,
124
- hslColor.s * 100,
125
- hslColor.l * 100,
126
- ];
127
- hues.push( hslTuple[ 0 ] );
128
- existingHslColors.push( hslTuple );
129
- minHue = Math.min( minHue, hslTuple[ 0 ] );
130
- maxHue = Math.max( maxHue, hslTuple[ 0 ] );
131
- }
132
- }
108
+ for ( const color of SERIES_PALETTE_POINTERS ) {
109
+ // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
110
+ // This uses normalizeColorToHex which resolves CSS variables and converts
111
+ // rgb(), rgba(), hsl() formats to hex
112
+ const normalizedColor = normalizeColorToHex( color, wrapperRef.current, resolveCssVariable );
113
+
114
+ // Only process valid hex colors. An unset palette slot returns its own
115
+ // `var()` unchanged, so this is also what compacts the palette: slots the
116
+ // consumer never set drop out here and `getChartColor` generates past
117
+ // whatever survived.
118
+ if ( normalizedColor.startsWith( '#' ) ) {
119
+ resolvedColors.push( normalizedColor );
120
+ const hslColor = d3Hsl( normalizedColor );
121
+ // d3Hsl returns NaN values for invalid colors
122
+ if ( ! isNaN( hslColor.h ) ) {
123
+ const hslTuple: [ number, number, number ] = [
124
+ hslColor.h,
125
+ hslColor.s * 100,
126
+ hslColor.l * 100,
127
+ ];
128
+ hues.push( hslTuple[ 0 ] );
129
+ existingHslColors.push( hslTuple );
130
+ minHue = Math.min( minHue, hslTuple[ 0 ] );
131
+ maxHue = Math.max( maxHue, hslTuple[ 0 ] );
133
132
  }
134
133
  }
135
134
  }
@@ -141,7 +140,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
141
140
  minHue,
142
141
  maxHue,
143
142
  } );
144
- }, [ providerTheme ] );
143
+ }, [] );
145
144
 
146
145
  useEffect( () => {
147
146
  if ( colorCache.colors.length > 0 ) {
@@ -153,11 +152,14 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
153
152
  () => new Map()
154
153
  );
155
154
 
156
- // Reset group color mappings when theme colors change
155
+ // Keyed on the resolved colors rather than the cache object, so a consumer passing an inline
156
+ // `theme` cannot reset the map on every render.
157
+ const paletteKey = colorCache.colors.join( ',' );
158
+
157
159
  useEffect( () => {
158
160
  // Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems
159
161
  setGroupToColorMap( new Map() );
160
- }, [ providerTheme.colors ] );
162
+ }, [ paletteKey ] );
161
163
 
162
164
  const registerChart = useCallback( ( id: string, data: ChartRegistration ) => {
163
165
  setCharts( prev => new Map( prev ).set( id, data ) );
@@ -347,6 +349,12 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
347
349
  [ hiddenSeries ]
348
350
  );
349
351
 
352
+ // Held as one object so a chart's formatting memos key on a single stable reference.
353
+ const formatting = useMemo(
354
+ () => sanitizeFormatting( { locale, timeZone } ),
355
+ [ locale, timeZone ]
356
+ );
357
+
350
358
  const value: GlobalChartsContextValue = useMemo(
351
359
  () => ( {
352
360
  charts,
@@ -354,6 +362,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
354
362
  unregisterChart,
355
363
  getChartData,
356
364
  theme: providerTheme,
365
+ formatting,
357
366
  getElementStyles,
358
367
  toggleSeriesVisibility,
359
368
  setSeriesVisibility,
@@ -370,6 +379,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
370
379
  unregisterChart,
371
380
  getChartData,
372
381
  providerTheme,
382
+ formatting,
373
383
  getElementStyles,
374
384
  toggleSeriesVisibility,
375
385
  setSeriesVisibility,
@@ -388,7 +398,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
388
398
  ref={ setWrapperNode }
389
399
  className={ CHART_SCOPE_CLASS }
390
400
  data-testid="charts-scope"
391
- style={ { display: 'contents', ...overrideVars } as CSSProperties }
401
+ style={ { display: 'contents' } }
392
402
  >
393
403
  <ChartScopeContext.Provider value={ scopeNode }>{ children }</ChartScopeContext.Provider>
394
404
  </div>
@@ -0,0 +1,22 @@
1
+ import { useContext } from 'react';
2
+ import { GlobalChartsContext } from '../global-charts-provider';
3
+ import type { ChartFormatting } from '../../../types';
4
+
5
+ // A stable reference, so a chart mounted without a provider doesn't rebuild its
6
+ // formatting memos on every render.
7
+ const RUNTIME_FORMATTING: ChartFormatting = {};
8
+
9
+ /**
10
+ * The locale and time zone dates are rendered in, as set on `GlobalChartsProvider`.
11
+ *
12
+ * Falls back to the runtime's own rather than throwing: charts render outside a
13
+ * provider, and a host that sets neither gets exactly the browser-default
14
+ * behavior this package had before the context existed.
15
+ *
16
+ * @return The host's formatting context.
17
+ */
18
+ export const useChartFormatting = (): ChartFormatting => {
19
+ const context = useContext( GlobalChartsContext );
20
+
21
+ return context?.formatting ?? RUNTIME_FORMATTING;
22
+ };
@@ -3,6 +3,7 @@ export { useGlobalChartsContext } from './hooks/use-global-charts-context';
3
3
  export { useChartId } from './hooks/use-chart-id';
4
4
  export { useChartRegistration } from './hooks/use-chart-registration';
5
5
  export { useGlobalChartsTheme } from './hooks/use-global-charts-theme';
6
+ export { useChartFormatting } from './hooks/use-chart-formatting';
6
7
  export type {
7
8
  GlobalChartsContextValue,
8
9
  ChartRegistration,
@@ -0,0 +1,42 @@
1
+ import { SERIES_PALETTE_POINTERS } from './series-palette';
2
+
3
+ /*
4
+ * The `var()` chain for each color JS has to hand to something. Nothing here is resolved in JS —
5
+ * the chain lands on the element; the terminal literal covers SSR and jsdom. See TOKENS.md.
6
+ */
7
+ export const CATALOG_POINTERS = {
8
+ background: 'var(--a8c-charts-color-background, #fff)',
9
+ labelAxis: 'var(--a8c-charts-color-label-axis, #1e1e1e)',
10
+ grid: 'var(--a8c-charts-color-grid, #dbdbdb)',
11
+ // The y pair resolves to `none`: that axis carries labels only until a consumer declares them.
12
+ axisX: 'var(--a8c-charts-color-axis-x, #dbdbdb)',
13
+ tickX: 'var(--a8c-charts-color-tick-x, #dbdbdb)',
14
+ axisY: 'var(--a8c-charts-color-axis-y, none)',
15
+ tickY: 'var(--a8c-charts-color-tick-y, none)',
16
+ annotation: 'var(--a8c-charts-color-annotation, #1e1e1e)',
17
+ surface: 'var(--a8c-charts-color-surface, #fff)',
18
+ surfaceSecondary: 'var(--a8c-charts-color-surface-secondary, #f4f4f4)',
19
+ // Only for `renderMainMetric`, which hands a consumer's own markup something to paint with.
20
+ trendUp: 'var(--a8c-charts-color-trend-up, #008030)',
21
+ trendDown: 'var(--a8c-charts-color-trend-down, #cc1818)',
22
+ series: SERIES_PALETTE_POINTERS,
23
+ } as const;
24
+
25
+ /**
26
+ * The annotation parts visx paints, in the shape `@visx/annotation` takes. `radius`
27
+ * rides along as the base the theme and the per-datum styles merge onto.
28
+ */
29
+ export const ANNOTATION_POINTERS = {
30
+ label: {
31
+ anchorLineStroke: CATALOG_POINTERS.annotation,
32
+ backgroundFill: CATALOG_POINTERS.surface,
33
+ },
34
+ connector: {
35
+ stroke: CATALOG_POINTERS.annotation,
36
+ },
37
+ circleSubject: {
38
+ stroke: 'transparent',
39
+ fill: CATALOG_POINTERS.annotation,
40
+ radius: 5,
41
+ },
42
+ } as const;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The series palette's slot manifest. Which slots exist is a fact about the catalog
3
+ * `chart-scope.scss` emits, not a theme value, so it is declared here rather than in `themes.ts`.
4
+ */
5
+
6
+ /** How many series-palette slots the catalog emits. */
7
+ export const SERIES_SLOT_COUNT = 5;
8
+
9
+ /**
10
+ * The catalog role holding one series-palette slot.
11
+ *
12
+ * @param slot - The one-based slot number.
13
+ * @return The role name.
14
+ */
15
+ export const seriesRole = ( slot: number ): string => `--a8c-charts-color-series-${ slot }`;
16
+
17
+ /*
18
+ * Only slot 1 carries a terminal literal, and it is reached only where `getComputedStyle` resolves
19
+ * nothing — SSR and jsdom. The rest resolve to nothing until a consumer sets them, and the provider
20
+ * drops what resolves to nothing, so the palette compacts rather than repeating one color.
21
+ *
22
+ * The literal matches slot 1's own last-resort fallback in `chart-scope.scss`. It is deliberately
23
+ * the bare hex rather than that slot's full `var()` chain: this string is resolved against the DOM,
24
+ * where the chain has already been walked, and is only read at all when no DOM answered.
25
+ */
26
+ const SERIES_SLOT_1_FALLBACK = '#3858e9';
27
+
28
+ /** The catalog pointer for every slot, in slot order. */
29
+ export const SERIES_PALETTE_POINTERS: readonly string[] = Array.from(
30
+ { length: SERIES_SLOT_COUNT },
31
+ ( _, index ) =>
32
+ index === 0
33
+ ? `var(${ seriesRole( 1 ) }, ${ SERIES_SLOT_1_FALLBACK })`
34
+ : `var(${ seriesRole( index + 1 ) })`
35
+ );