@automattic/charts 3.2.0 → 4.1.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 (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,6 +1,6 @@
1
1
  import { createScale, getTicks } from '@visx/scale';
2
2
  import { useMemo } from 'react';
3
- import { getLongestTickWidth, resolveFontSize } from '../utils';
3
+ import { getEdgeTickWidths, getLongestTickWidth, resolveFontSize } from '../utils';
4
4
  import type { BaseChartProps, DataPointDate, SeriesData } from '../types';
5
5
  import type { XYChartTheme } from '@visx/xychart';
6
6
 
@@ -50,6 +50,33 @@ const DEFAULT_TICK_LENGTH = 8;
50
50
  */
51
51
  const DEFAULT_Y_TICK_WIDTH = 40;
52
52
 
53
+ type LabelStyle = { fontSize?: number | string; letterSpacing?: number | string };
54
+
55
+ /**
56
+ * Copy a label style with its lengths spelled out in px.
57
+ *
58
+ * `getStringWidth` applies the style through CSSOM. Blink resolves a bare number
59
+ * on an SVG `<text>`, but that is its own leniency rather than the CSS rule, and
60
+ * `buildChartTheme` hands us bare numbers.
61
+ *
62
+ * @param style - Raw label style from the theme.
63
+ * @return The same style with px-qualified lengths.
64
+ */
65
+ const toMeasurableStyle = < T extends LabelStyle >( style?: T ) => {
66
+ if ( ! style ) {
67
+ return style;
68
+ }
69
+
70
+ const fontSize = resolveFontSize( style.fontSize );
71
+ const { letterSpacing } = style;
72
+
73
+ return {
74
+ ...style,
75
+ ...( fontSize === undefined ? {} : { fontSize: `${ fontSize }px` } ),
76
+ ...( typeof letterSpacing === 'number' ? { letterSpacing: `${ letterSpacing }px` } : {} ),
77
+ };
78
+ };
79
+
53
80
  const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => {
54
81
  const xAxisStyles =
55
82
  orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
@@ -61,7 +88,7 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom
61
88
 
62
89
  const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH;
63
90
 
64
- return { fontSize, tickLength };
91
+ return { fontSize, tickLength, tickLabelStyle: toMeasurableStyle( xAxisStyles?.tickLabel ) };
65
92
  };
66
93
 
67
94
  export const useChartMargin = (
@@ -88,7 +115,9 @@ export const useChartMargin = (
88
115
  const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
89
116
  const yScale = createScale( {
90
117
  ...options.yScale,
91
- domain: [ minY, maxY ],
118
+ // A pinned domain is what the axis actually renders, so measure those
119
+ // ticks; the data's range would size the gutter for narrower labels.
120
+ domain: options.yScale?.domain ?? [ minY, maxY ],
92
121
  range: [ height, 0 ],
93
122
  } );
94
123
 
@@ -111,7 +140,7 @@ export const useChartMargin = (
111
140
  const yTickWidth = getLongestTickWidth(
112
141
  yTicks,
113
142
  options.axis?.y?.tickFormat,
114
- yAxisStyles.axisLabel
143
+ toMeasurableStyle( yAxisStyles.axisLabel )
115
144
  );
116
145
  // visx's default axis theme pushes y-axis tick labels a further 0.25em
117
146
  // away from the axis (dx of -0.25em on the left, 0.25em on the right), so
@@ -125,17 +154,20 @@ export const useChartMargin = (
125
154
  ( yAxisStyles?.tickLength ?? 0 ) +
126
155
  Math.ceil( yTickLabelFontSize * 0.25 );
127
156
 
128
- if ( yAxisOrientation === 'right' ) {
129
- defaultMargin.right = yMarginValue;
130
- } else {
131
- defaultMargin.left = yMarginValue;
157
+ // A hidden y axis reserves nothing; its gutter belongs to the plot area.
158
+ if ( options.axis?.y?.display !== false ) {
159
+ if ( yAxisOrientation === 'right' ) {
160
+ defaultMargin.right = yMarginValue;
161
+ } else {
162
+ defaultMargin.left = yMarginValue;
163
+ }
132
164
  }
133
165
 
134
166
  // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
135
167
  // This mirrors Y-axis behavior where margin is based on label size and tick length,
136
168
  // but keeps the padding minimal so consumers can control container spacing themselves.
137
169
  const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
138
- const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation );
170
+ const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
139
171
  const computedXMargin = fontSize + tickLength;
140
172
 
141
173
  if ( xOrientation === 'top' ) {
@@ -145,6 +177,19 @@ export const useChartMargin = (
145
177
  defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
146
178
  }
147
179
 
180
+ // An X-axis label is centered on its tick, so the ones at either end of the
181
+ // scale hang half their width outside the plot area and clip at the SVG edge.
182
+ if ( options.axis?.x?.display !== false ) {
183
+ const { first, last } = getEdgeTickWidths(
184
+ options.axis?.x?.tickValues ?? [],
185
+ options.axis?.x?.tickFormat,
186
+ tickLabelStyle
187
+ );
188
+
189
+ defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) );
190
+ defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) );
191
+ }
192
+
148
193
  return defaultMargin;
149
194
  }, [ options, theme, yTicks ] );
150
195
  };
@@ -1,8 +1,9 @@
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[] ) => {
@@ -17,29 +18,44 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
17
18
  );
18
19
 
19
20
  return useMemo( () => {
20
- // Only what is read as a string is resolved here — the palette and `backgroundColor` — plus `htmlLabel.color` below, which is painted outside the scope. Resolving against the chart's own scope element, never :root, is what reads an override set inside the provider tree.
21
- //
22
- // One resolver per theme build, so all three share a single getComputedStyle call.
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
 
29
- // The palette gets the same treatment as every other color here, and needs it more: `theme.colors` is the one field that is always a `var()` chain, and the four slots without a catalog default resolve to nothing at all. visx builds its `colorScale` from this array and uses it as the default stroke for a series rendered without an explicit one, so an unresolved entry paints nothing rather than degrading. Entries that resolve to nothing are dropped so the scale compacts the way the provider's own palette does.
30
- const paletteColors = [ ...seriesColors, ...( theme.colors ?? [] ) ]
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 ]
31
33
  .map( color => resolveColor( color ) )
32
34
  .filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
33
35
 
34
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.
35
- const htmlLabelColor = resolveColor( theme.svgLabelSmall?.fill );
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;
36
43
 
37
- // The grid, axis, tick and label colors are spread through untouched, and that is the whole mechanism: visx writes each one onto the element it paints — as an inline style for the grid, a presentation attribute elsewhere — and a `var()` chain resolves there natively. So the role is read at the painted element rather than snapshot at the provider wrapper, which is what makes an override on a chart's own class work, keeps a theme change live without a re-render, and leaves nothing to resolve during SSR. Resolving them here would freeze the color instead.
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.
38
46
  return buildChartTheme( {
39
47
  ...theme,
48
+ gridColor: '',
49
+ gridColorDark: '',
40
50
  colors: paletteColors,
41
- backgroundColor: resolveColor( theme.backgroundColor ),
51
+ backgroundColor: resolveColor( CATALOG_POINTERS.background ),
42
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 },
43
59
  } );
44
60
  }, [ theme, seriesColorKey, scopeElement ] );
45
61
  };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  HeatmapChart,
11
11
  HeatmapChartUnresponsive,
12
12
  buildCalendarHeatmapData,
13
+ useCalendarHeatmapData,
13
14
  } from './charts/heatmap-chart';
14
15
  export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
15
16
  export { LineChart, LineChartUnresponsive } from './charts/line-chart';
@@ -32,6 +33,7 @@ export {
32
33
  GlobalChartsProvider,
33
34
  useGlobalChartsContext,
34
35
  useGlobalChartsTheme,
36
+ useChartFormatting,
35
37
  GlobalChartsContext,
36
38
  defaultTheme,
37
39
  useChartScopeElement,
@@ -55,6 +57,7 @@ export type {
55
57
  ChartTheme,
56
58
  CompleteChartTheme,
57
59
  AxisOptions,
60
+ ChartFormatting,
58
61
  TickResolution,
59
62
  ScaleOptions,
60
63
  LegendItemStyles,
@@ -111,6 +114,7 @@ export type {
111
114
  HeatmapColumn,
112
115
  HeatmapCell,
113
116
  HeatmapTooltipData,
117
+ CalendarHeatmapOptions,
114
118
  CalendarHeatmapResult,
115
119
  } from './charts/heatmap-chart';
116
120
  export type { LeaderboardChartProps } from './charts/leaderboard-chart';
@@ -126,6 +130,7 @@ export type { SparklineProps, GradientConfig, SparklineDataPoint } from './chart
126
130
 
127
131
  // Utilities
128
132
  export {
133
+ getBucketInfo,
129
134
  parseAsLocalDate,
130
135
  formatMetricValue,
131
136
  formatPercentage,
@@ -134,3 +139,6 @@ export {
134
139
  } from './utils';
135
140
  export * from './utils/color-utils';
136
141
  export type { MetricValueType } from './utils';
142
+ export type { BucketInfo } from './types';
143
+
144
+ export type { CrosshairStyle } from './visx/types';
@@ -21,25 +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
28
  import { SERIES_PALETTE_POINTERS } from './private/series-palette';
28
- import { themeOverrideVars } from './private/theme-override-vars';
29
- import { withCatalogPointers } from './private/with-catalog-pointers';
30
29
  import { defaultTheme } from './themes';
31
30
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
32
31
  import type { ChartTheme, CompleteChartTheme } from '../../types';
33
- import type { CSSProperties, FC, ReactNode } from 'react';
32
+ import type { FC, ReactNode } from 'react';
34
33
 
35
34
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
36
35
 
37
36
  export interface GlobalChartsProviderProps {
38
37
  children: ReactNode;
39
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;
40
49
  }
41
50
 
42
- export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { children, theme } ) => {
51
+ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
52
+ children,
53
+ theme,
54
+ locale,
55
+ timeZone,
56
+ } ) => {
43
57
  const [ charts, setCharts ] = useState< Map< string, ChartRegistration > >( () => new Map() );
44
58
  // Track hidden series per chart: chartId -> Set<seriesLabel>
45
59
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
@@ -59,20 +73,11 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
59
73
  setScopeNode( node );
60
74
  }, [] );
61
75
 
62
- // 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).
63
- const { vars: overrideVars, roles: overriddenRoles } = useMemo(
64
- () => themeOverrideVars( theme ),
76
+ const providerTheme: CompleteChartTheme = useMemo(
77
+ () => ( theme ? mergeThemes( defaultTheme, theme ) : defaultTheme ),
65
78
  [ theme ]
66
79
  );
67
80
 
68
- const providerTheme: CompleteChartTheme = useMemo( () => {
69
- if ( ! theme ) {
70
- return defaultTheme;
71
- }
72
-
73
- return withCatalogPointers( mergeThemes( defaultTheme, theme ), overriddenRoles );
74
- }, [ theme, overriddenRoles ] );
75
-
76
81
  // Cache expensive color computations that only change when theme colors change
77
82
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
78
83
  // in <style> tags are applied to the DOM before we try to resolve them
@@ -88,14 +93,10 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
88
93
  // Useful for animations that should only run after the color palette is resolved
89
94
  const [ isColorPaletteResolved, setIsColorPaletteResolved ] = useState( false );
90
95
 
91
- // Compute color cache after DOM is updated (so CSS variables are available)
92
- // Resolves CSS variables from the wrapper element's scope to handle scoped variables
93
- // Note: Only re-runs when providerTheme changes, not when wrapper element changes.
94
- // This is intentional, as wrapperRef is expected to be stable for the lifetime of the provider.
95
- // A remount is not a gap in that: effects always run on mount, so a new instance resolves
96
- // against its own node. Only a node swap *within* one instance would go unseen, and the catalog
97
- // is declared on the `.a8c-charts-scope` class rather than on a particular element, so the
98
- // replacement carries the same computed values anyway.
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
+
99
100
  useLayoutEffect( () => {
100
101
  setIsColorPaletteResolved( false );
101
102
  const resolvedColors: string[] = [];
@@ -104,13 +105,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
104
105
  let minHue = 360;
105
106
  let maxHue = 0;
106
107
 
107
- // Resolved from the theme rather than from `SERIES_PALETTE_POINTERS`, and it has to be. In a
108
- // browser the two are equivalent — both name the same slots, and the wrapper's theme-layer
109
- // vars answer either. But `withCatalogPointers` puts the consumer's own color in each
110
- // pointer's terminal position, and that literal is the only carrier for the palette where
111
- // `getComputedStyle` resolves nothing: SSR and jsdom. Walking the manifest instead makes
112
- // every consumer palette collapse to the catalog seed there.
113
- for ( const color of providerTheme.colors ?? SERIES_PALETTE_POINTERS ) {
108
+ for ( const color of SERIES_PALETTE_POINTERS ) {
114
109
  // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
115
110
  // This uses normalizeColorToHex which resolves CSS variables and converts
116
111
  // rgb(), rgba(), hsl() formats to hex
@@ -145,7 +140,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
145
140
  minHue,
146
141
  maxHue,
147
142
  } );
148
- }, [ providerTheme ] );
143
+ }, [] );
149
144
 
150
145
  useEffect( () => {
151
146
  if ( colorCache.colors.length > 0 ) {
@@ -157,12 +152,8 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
157
152
  () => new Map()
158
153
  );
159
154
 
160
- // Reset group color mappings when the resolved palette changes.
161
- //
162
- // Keyed on the resolved colors rather than on `providerTheme.colors`, which holds five
163
- // catalog pointers and so does not move with a `theme.colors` change — a consumer's colors
164
- // reach the palette through the theme-layer vars on the wrapper. Keying on content also stops
165
- // a consumer passing an inline `theme` object from resetting the map on every render.
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.
166
157
  const paletteKey = colorCache.colors.join( ',' );
167
158
 
168
159
  useEffect( () => {
@@ -358,6 +349,12 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
358
349
  [ hiddenSeries ]
359
350
  );
360
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
+
361
358
  const value: GlobalChartsContextValue = useMemo(
362
359
  () => ( {
363
360
  charts,
@@ -365,6 +362,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
365
362
  unregisterChart,
366
363
  getChartData,
367
364
  theme: providerTheme,
365
+ formatting,
368
366
  getElementStyles,
369
367
  toggleSeriesVisibility,
370
368
  setSeriesVisibility,
@@ -381,6 +379,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
381
379
  unregisterChart,
382
380
  getChartData,
383
381
  providerTheme,
382
+ formatting,
384
383
  getElementStyles,
385
384
  toggleSeriesVisibility,
386
385
  setSeriesVisibility,
@@ -399,7 +398,7 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
399
398
  ref={ setWrapperNode }
400
399
  className={ CHART_SCOPE_CLASS }
401
400
  data-testid="charts-scope"
402
- style={ { display: 'contents', ...overrideVars } as CSSProperties }
401
+ style={ { display: 'contents' } }
403
402
  >
404
403
  <ChartScopeContext.Provider value={ scopeNode }>{ children }</ChartScopeContext.Provider>
405
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;
@@ -1,19 +1,9 @@
1
1
  /**
2
- * The series palette's slot manifest.
3
- *
4
- * Which slots exist is a fact about the catalog `chart-scope.scss` emits, not a theme value, so it
5
- * is declared here rather than in `themes.ts`, which now derives `defaultTheme.colors` from it.
6
- *
7
- * That is as far as the separation goes, and the limit is worth knowing before the field is
8
- * deleted. `GlobalChartsProvider` still resolves `providerTheme.colors`, not this manifest,
9
- * because `withCatalogPointers` parks the consumer's own color in each pointer's terminal position
10
- * and that literal is the palette's only carrier where `getComputedStyle` resolves nothing — SSR
11
- * and jsdom. Swapping the provider onto this manifest passes in a browser and collapses every
12
- * consumer palette to the catalog seed under jsdom; 24 tests say so. Removing `colors` therefore
13
- * needs a different home for that literal first, not just a change of source here.
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`.
14
4
  */
15
5
 
16
- /** How many series-palette slots the catalog emits. `theme.colors` entries past this are ignored. */
6
+ /** How many series-palette slots the catalog emits. */
17
7
  export const SERIES_SLOT_COUNT = 5;
18
8
 
19
9
  /**
@@ -1,3 +1,4 @@
1
+ import { ANNOTATION_POINTERS, CATALOG_POINTERS } from '../private/catalog-pointers';
1
2
  import { defaultTheme } from '../themes';
2
3
 
3
4
  const flatten = ( value: unknown ): string[] => {
@@ -13,32 +14,31 @@ const flatten = ( value: unknown ): string[] => {
13
14
  return [];
14
15
  };
15
16
 
16
- describe( 'defaultTheme', () => {
17
- const strings = flatten( defaultTheme );
17
+ const pointerStrings = [ ...flatten( CATALOG_POINTERS ), ...flatten( ANNOTATION_POINTERS ) ];
18
18
 
19
+ describe( 'catalog pointers', () => {
19
20
  it( 'holds no --wpds-* mapping — that lives only in the catalog stylesheet', () => {
20
- expect( strings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
21
+ expect( pointerStrings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
21
22
  } );
22
23
 
23
- it( 'points every themed color at an --a8c-charts-* catalog role', () => {
24
- const varStrings = strings.filter( value => value.startsWith( 'var(' ) );
24
+ it( 'points every color at an --a8c-charts-* catalog role', () => {
25
+ const varStrings = pointerStrings.filter( value => value.startsWith( 'var(' ) );
25
26
 
26
27
  expect( varStrings.length ).toBeGreaterThan( 0 );
27
28
  expect( varStrings.every( value => value.startsWith( 'var(--a8c-charts-' ) ) ).toBe( true );
28
29
  } );
29
30
 
30
- // The palette slots past the first are the exception, below: a terminal literal there would be a color nobody chose.
31
- it( 'gives every catalog pointer a terminal literal for the SSR and jsdom paths', () => {
32
- const varStrings = strings
31
+ // Palette slots past the first are the exception: a literal there would be a color nobody chose.
32
+ it( 'gives every pointer a terminal literal for the SSR and jsdom paths', () => {
33
+ const varStrings = pointerStrings
33
34
  .filter( value => value.startsWith( 'var(--a8c-charts-' ) )
34
35
  .filter( value => ! /^var\( *--a8c-charts-color-series-[2-9] *\)$/.test( value ) );
35
36
 
36
37
  expect( varStrings.every( value => value.includes( ',' ) ) ).toBe( true );
37
38
  } );
38
39
 
39
- // Only slot 1 seeds a palette. The rest resolve to nothing until a consumer sets them, and the provider drops what resolves to nothing, so the palette compacts instead of repeating one color.
40
40
  it( 'leaves the palette slots past the first with no default of their own', () => {
41
- expect( defaultTheme.colors ).toEqual( [
41
+ expect( CATALOG_POINTERS.series ).toEqual( [
42
42
  'var(--a8c-charts-color-series-1, #3858e9)',
43
43
  'var(--a8c-charts-color-series-2)',
44
44
  'var(--a8c-charts-color-series-3)',
@@ -47,3 +47,15 @@ describe( 'defaultTheme', () => {
47
47
  ] );
48
48
  } );
49
49
  } );
50
+
51
+ describe( 'defaultTheme', () => {
52
+ // A color has one route, a `--a8c-charts-color-*` role set in CSS. One reappearing
53
+ // here would be a second.
54
+ it( 'holds no color at all', () => {
55
+ const colorish = flatten( defaultTheme ).filter( value =>
56
+ /^(#|rgb|hsl|var\(--a8c-charts-color)/.test( value.trim() )
57
+ );
58
+
59
+ expect( colorish ).toEqual( [] );
60
+ } );
61
+ } );