@automattic/charts 4.6.0 → 4.8.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/TOKENS.md +15 -10
  3. package/dist/index.cjs +427 -211
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +40 -26
  6. package/dist/index.d.cts +69 -29
  7. package/dist/index.d.ts +69 -29
  8. package/dist/index.js +427 -213
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
  20. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
  21. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  22. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
  23. package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
  24. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  25. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  26. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  27. package/src/charts/heatmap-chart/private/index.ts +2 -1
  28. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  29. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
  30. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  31. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  32. package/src/charts/heatmap-chart/types.ts +6 -9
  33. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  34. package/src/charts/line-chart/line-chart.module.scss +0 -5
  35. package/src/charts/line-chart/line-chart.tsx +10 -42
  36. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  37. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  38. package/src/charts/pie-chart/pie-chart.tsx +26 -80
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  42. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  43. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  44. package/src/charts/private/label-text-color.ts +116 -0
  45. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  46. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  47. package/src/charts/private/test/label-text-color.test.ts +189 -0
  48. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  49. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  50. package/src/components/tooltip/base-tooltip.tsx +31 -30
  51. package/src/components/tooltip/index.ts +3 -1
  52. package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
  53. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  54. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  55. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  56. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  57. package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
  58. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  59. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  60. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  61. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
  62. package/src/components/tooltip/tooltip-box.module.scss +21 -0
  63. package/src/components/tooltip/tooltip-box.tsx +43 -0
  64. package/src/components/tooltip/types.ts +7 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
  66. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  69. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  70. package/src/hooks/use-chart-margin.tsx +21 -18
  71. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  72. package/src/hooks/use-xychart-theme.ts +4 -12
  73. package/src/index.ts +8 -3
  74. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  75. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
  76. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  77. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  78. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  79. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  81. package/src/styles/chart-scope-class.ts +5 -0
  82. package/src/styles/chart-scope.scss +10 -6
  83. package/src/styles/test/chart-scope.test.ts +3 -3
  84. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  85. package/src/utils/color-utils.ts +2 -1
  86. package/src/utils/test/date-formatting.test.ts +0 -10
  87. package/src/visx/types.ts +1 -1
  88. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
  89. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  90. package/src/components/tooltip/base-tooltip.module.scss +0 -20
  91. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -2,6 +2,7 @@ import clsx from 'clsx';
2
2
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
3
3
  import styles from './trend-indicator.module.scss';
4
4
  import type { TrendIndicatorProps, TrendDirection } from './types';
5
+ import type { JSX } from 'react';
5
6
 
6
7
  const DIRECTION_LABELS: Record< TrendDirection, string > = {
7
8
  up: 'Increase',
@@ -9,3 +9,4 @@ export { useZeroValueDisplay } from './use-zero-value-display';
9
9
  export { useDataWithPercentages } from './use-data-with-percentages';
10
10
  export { useLegendVisibilityData } from './use-legend-visibility-data';
11
11
  export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
12
+ export { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
@@ -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
 
@@ -1,4 +1,5 @@
1
1
  import { renderHook } from '@testing-library/react';
2
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
2
3
  import { ChartScopeContext } from '../../providers/chart-scope';
3
4
  import { useXYChartTheme } from '../use-xychart-theme';
4
5
  import type { ReactNode } from 'react';
@@ -48,8 +49,7 @@ describe( 'useXYChartTheme', () => {
48
49
  document.body.removeChild( scope );
49
50
  } );
50
51
 
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', () => {
52
+ it( 'hands visx the label pointer for the HTML label, even with an override in scope', () => {
53
53
  const scope = document.createElement( 'div' );
54
54
  scope.style.setProperty( '--a8c-charts-color-label-axis', '#0000ff' );
55
55
  document.body.appendChild( scope );
@@ -60,7 +60,7 @@ describe( 'useXYChartTheme', () => {
60
60
 
61
61
  const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
62
62
 
63
- expect( result.current.htmlLabel.color ).toBe( '#0000ff' );
63
+ expect( result.current.htmlLabel.color ).toBe( CATALOG_POINTERS.labelAxis );
64
64
  expect( result.current.svgLabelSmall.fill ).toBe(
65
65
  'var(--a8c-charts-color-label-axis, #1e1e1e)'
66
66
  );
@@ -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;
@@ -0,0 +1,5 @@
1
+ import { useEffect, useLayoutEffect } from 'react';
2
+
3
+ // useLayoutEffect on the client, useEffect on the server to avoid React's SSR warning.
4
+ export const useIsomorphicLayoutEffect =
5
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
4
  import { CATALOG_POINTERS } from '../providers/chart-context/private/catalog-pointers';
5
5
  import { useChartScopeElement } from '../providers/chart-scope';
6
- import { createCssVariableResolver, normalizeColorToHex } from '../utils';
6
+ import { createCssVariableResolver } from '../utils';
7
7
  import type { SeriesData } from '../types';
8
8
 
9
9
  export const useXYChartTheme = ( data: SeriesData[] ) => {
@@ -18,9 +18,9 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
18
18
  );
19
19
 
20
20
  return useMemo( () => {
21
- // Only what is read as a string is resolved: the palette, `backgroundColor`, `htmlLabel.color`.
21
+ // Only what is read as a string is resolved: the palette and `backgroundColor`.
22
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
+ // One resolver for both, so they share a single getComputedStyle call.
24
24
  const resolve = createCssVariableResolver( scopeElement );
25
25
  const resolveColor = ( value?: string ): string | undefined =>
26
26
  value ? ( resolve( value ) ?? value ) : value;
@@ -33,14 +33,6 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
33
33
  .map( color => resolveColor( color ) )
34
34
  .filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
35
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
36
  // visx's fallbacks for whichever axis or grid style object it is not given. All four are
45
37
  // supplied, so these reach nothing; they stay because the config type requires them.
46
38
  return buildChartTheme( {
@@ -49,7 +41,7 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
49
41
  gridColorDark: '',
50
42
  colors: paletteColors,
51
43
  backgroundColor: resolveColor( CATALOG_POINTERS.background ),
52
- htmlLabel: htmlLabelColor ? { color: htmlLabelColor } : undefined,
44
+ htmlLabel: { color: CATALOG_POINTERS.labelAxis },
53
45
  gridStyles: { ...theme.gridStyles, stroke: CATALOG_POINTERS.grid },
54
46
  xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: CATALOG_POINTERS.axisX },
55
47
  xTickLineStyles: { ...theme.xTickLineStyles, stroke: CATALOG_POINTERS.tickX },
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( {
@@ -1,9 +1,7 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
1
+ import { useMemo, useRef } from 'react';
2
+ import { useIsomorphicLayoutEffect } from '../../../hooks';
2
3
  import { useGlobalChartsContext } from './use-global-charts-context';
3
4
 
4
- // Hide seeded series before paint without triggering React's SSR warning.
5
- const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
6
-
7
5
  /**
8
6
  * Seeds a chart's hidden series once per chart ID, the first time the provider
9
7
  * sees that ID.
@@ -39,7 +37,7 @@ export const useDefaultHiddenSeries = (
39
37
  // getHiddenSeries hands out a fresh copy per call, so hold the set still between
40
38
  // visibility changes. Charts derive their rendered series from it, and a new
41
39
  // identity every render invalidates every memo downstream — including the one
42
- // the accessible tooltip watches, which closes a tooltip mid-navigation.
40
+ // `XYChartTooltip` watches, which closes a tooltip mid-navigation.
43
41
  return useMemo(
44
42
  () => ( shouldUseDefaults ? new Set( labelsRef.current ) : getHiddenSeries( chartId ) ),
45
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)',
@@ -2,6 +2,9 @@ import { relativeLuminance, validateHexColor } from '../../../utils';
2
2
 
3
3
  export type Lab = readonly [ number, number, number ];
4
4
 
5
+ /** sRGB channels from 0 to 255, left unrounded. */
6
+ export type Rgb = readonly [ number, number, number ];
7
+
5
8
  /** A color as seen in normal vision, then under deuteranopia and protanopia. */
6
9
  export type ColorViews = readonly [ Lab, Lab, Lab ];
7
10
 
@@ -23,13 +26,22 @@ const toLinear = ( channel: number ): number =>
23
26
  const fromLinear = ( channel: number ): number =>
24
27
  channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow( channel, 1 / 2.4 ) - 0.055;
25
28
 
26
- const hexToLinear = ( hex: string ): number[] => {
29
+ /**
30
+ * The sRGB channels of a hex color.
31
+ *
32
+ * @param hex - A six-digit hex color.
33
+ * @return Channels from 0 to 255.
34
+ * @throws {Error} if the hex string is malformed
35
+ */
36
+ export const hexToRgb = ( hex: string ): Rgb => {
27
37
  validateHexColor( hex );
28
- return [ 1, 3, 5 ].map( start =>
29
- toLinear( parseInt( hex.slice( start, start + 2 ), 16 ) / 255 )
30
- );
38
+ const [ r, g, b ] = [ 1, 3, 5 ].map( start => parseInt( hex.slice( start, start + 2 ), 16 ) );
39
+ return [ r, g, b ];
31
40
  };
32
41
 
42
+ const hexToLinear = ( hex: string ): number[] =>
43
+ hexToRgb( hex ).map( channel => toLinear( channel / 255 ) );
44
+
33
45
  const linearToHex = ( linear: number[] ): string =>
34
46
  '#' +
35
47
  linear
@@ -197,6 +209,35 @@ export const hexToOklch = ( hex: string ): { chroma: number; hue: number } => {
197
209
  };
198
210
  };
199
211
 
212
+ /**
213
+ * `foreground` over `background` at `weight`, left unrounded as CSS `color-mix` and alpha compositing leave it.
214
+ *
215
+ * A mix rounded to hex can land on the other side of a contrast threshold from the fill the browser paints.
216
+ *
217
+ * @param foreground - The color weighted by `weight`.
218
+ * @param background - The color it is mixed over.
219
+ * @param weight - Share of `foreground`, from 0 to 1.
220
+ * @return The mixed channels.
221
+ */
222
+ export const blendRgb = ( foreground: Rgb, background: Rgb, weight: number ): Rgb => {
223
+ const share = Math.min( 1, Math.max( 0, weight ) );
224
+ const [ r, g, b ] = foreground.map(
225
+ ( channel, i ) => background[ i ] + ( channel - background[ i ] ) * share
226
+ );
227
+ return [ r, g, b ];
228
+ };
229
+
230
+ /**
231
+ * WCAG relative luminance of unrounded sRGB channels.
232
+ *
233
+ * @param rgb - Channels from 0 to 255.
234
+ * @return Relative luminance from 0 to 1.
235
+ */
236
+ export const rgbLuminance = ( rgb: Rgb ): number => {
237
+ const [ r, g, b ] = rgb.map( channel => toLinear( channel / 255 ) );
238
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
239
+ };
240
+
200
241
  /**
201
242
  * WCAG contrast ratio between two relative luminances.
202
243
  *
@@ -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
+ };
@@ -3,6 +3,9 @@ import {
3
3
  deltaE2000,
4
4
  hexToOklch,
5
5
  hexToViews,
6
+ blendRgb,
7
+ hexToRgb,
8
+ rgbLuminance,
6
9
  oklchToHex,
7
10
  viewDistance,
8
11
  } from '../private/perceptual-color';
@@ -73,3 +76,31 @@ describe( 'contrastRatio', () => {
73
76
  expect( contrastRatio( '#3858e9', '#3858e9' ) ).toBe( 1 );
74
77
  } );
75
78
  } );
79
+
80
+ describe( 'hexToRgb', () => {
81
+ it( 'reads each channel', () => {
82
+ expect( hexToRgb( '#3858e9' ) ).toEqual( [ 56, 88, 233 ] );
83
+ } );
84
+ } );
85
+
86
+ describe( 'blendRgb', () => {
87
+ const blue = hexToRgb( '#3858e9' );
88
+ const white = hexToRgb( '#ffffff' );
89
+
90
+ it( 'matches each endpoint at full weight', () => {
91
+ expect( blendRgb( blue, white, 1 ) ).toEqual( [ 56, 88, 233 ] );
92
+ expect( blendRgb( blue, white, 0 ) ).toEqual( [ 255, 255, 255 ] );
93
+ } );
94
+
95
+ it( 'does not round the mix to a hex channel', () => {
96
+ expect( blendRgb( hexToRgb( '#000000' ), white, 0.5 ) ).toEqual( [ 127.5, 127.5, 127.5 ] );
97
+ } );
98
+ } );
99
+
100
+ describe( 'rgbLuminance', () => {
101
+ it( 'reads unrounded channels', () => {
102
+ // Rounded to #808080 this would read 0.2159.
103
+ expect( rgbLuminance( [ 127.5, 127.5, 127.5 ] ) ).toBeCloseTo( 0.214, 4 );
104
+ expect( rgbLuminance( [ 56, 88, 233 ] ) ).toBeCloseTo( 0.137, 4 );
105
+ } );
106
+ } );
@@ -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>
@@ -4,3 +4,8 @@
4
4
  * Deliberately unhashed: consumers are documented as being able to target it to override a token on the provider element.
5
5
  */
6
6
  export const CHART_SCOPE_CLASS = 'a8c-charts-scope';
7
+
8
+ /**
9
+ * Carries the catalog on the tooltip box, re-declared under the tooltip's own theme. Separate from `CHART_SCOPE_CLASS` so a page-wide override on that class does not reach the dark box.
10
+ */
11
+ export const TOOLTIP_SCOPE_CLASS = 'a8c-charts-tooltip-scope';
@@ -10,7 +10,9 @@
10
10
  // the same element wins. Chart roots deliberately do NOT re-declare these: an
11
11
  // element's own declaration beats an inherited one, so re-declaring lower down
12
12
  // would shadow a consumer override set inside the provider tree.
13
- :where(.a8c-charts-scope) {
13
+ // The tooltip box re-declares the catalog under its own class, so a rule on
14
+ // .a8c-charts-scope does not land on the dark box.
15
+ :where(.a8c-charts-scope, .a8c-charts-tooltip-scope) {
14
16
  // The series palette. GlobalChartsProvider resolves these five slots at its
15
17
  // wrapper and seeds ColorCache with whatever resolves; a slot that resolves
16
18
  // to nothing is skipped, so setting only slots 1 and 3 gives a two-color
@@ -49,14 +51,15 @@
49
51
  --a8c-charts-color-trend-down: var(--wpds-color-foreground-content-error-weak);
50
52
  --a8c-charts-color-trend-neutral: var(--wpds-color-foreground-content-neutral-weak);
51
53
  --a8c-charts-color-background: var(--wpds-color-background-surface-neutral-strong);
52
- // Surfaces floating over the chart — tooltips, the annotation label, the
53
- // zoom-reset control. Same WPDS token as --a8c-charts-color-background but
54
+ // Surfaces floating over the chart — the annotation label, the zoom-reset
55
+ // control. Same WPDS token as --a8c-charts-color-background but
54
56
  // deliberately not derived from it, so either can move without the other.
55
57
  --a8c-charts-color-surface: var(--wpds-color-background-surface-neutral-strong);
56
58
  --a8c-charts-color-surface-secondary: var(--wpds-color-background-surface-neutral-weak);
57
59
  --a8c-charts-color-track: var(--wpds-color-background-track-neutral-weak);
58
- // Translucent dark surface; no WPDS token carries this.
59
- --a8c-charts-color-tooltip-surface: rgb(0 0 0 / 85%);
60
+ // The background @wordpress/ui's Tooltip themes its popup with; WPDS has
61
+ // no inverted surface token yet.
62
+ --a8c-charts-color-tooltip-surface: #1e1e1e;
60
63
  --a8c-charts-color-zoom-selection: var(--wpds-color-background-interactive-brand-strong);
61
64
  --a8c-charts-color-zoom-selection-stroke: var(--wpds-color-stroke-interactive-brand);
62
65
 
@@ -77,6 +80,7 @@
77
80
  --a8c-charts-motion-duration-entrance: var(--wpds-motion-duration-xl);
78
81
  --a8c-charts-motion-easing-entrance: var(--wpds-motion-easing-expressive);
79
82
  --a8c-charts-border-radius-bar: var(--wpds-border-radius-md);
83
+ --a8c-charts-border-radius-bar-chart: 0;
80
84
  --a8c-charts-border-radius-cell: var(--wpds-border-radius-sm);
81
85
  // Pill shape; no WPDS radius reaches it.
82
86
  --a8c-charts-border-radius-leaderboard-bar: 9999px;
@@ -87,7 +91,7 @@
87
91
 
88
92
  @media (forced-colors: active) {
89
93
 
90
- :where(.a8c-charts-scope) {
94
+ :where(.a8c-charts-scope, .a8c-charts-tooltip-scope) {
91
95
  --a8c-charts-color-label-axis: CanvasText;
92
96
  --a8c-charts-color-grid: GrayText;
93
97
  --a8c-charts-color-axis-x: GrayText;
@@ -127,7 +127,7 @@ describe( 'chart scope catalog', () => {
127
127
  expect( mediaRules[ 0 ].cssRules ).toHaveLength( 1 );
128
128
 
129
129
  const rule = mediaRules[ 0 ].cssRules[ 0 ] as CSSStyleRule;
130
- expect( rule.selectorText ).toBe( ':where(.a8c-charts-scope)' );
130
+ expect( rule.selectorText ).toBe( ':where(.a8c-charts-scope, .a8c-charts-tooltip-scope)' );
131
131
  expect(
132
132
  Object.fromEntries(
133
133
  Array.from( rule.style ).map( property => [
@@ -171,8 +171,8 @@ describe( 'chart scope catalog', () => {
171
171
  expect( documented.get( token )?.fallback ).toEqual( declared.get( token )?.fallback );
172
172
  } );
173
173
 
174
- it( 'scopes the catalog to :where(.a8c-charts-scope) rather than :root', () => {
175
- expect( stylesheet ).toMatch( /:where\(\.a8c-charts-scope\)\s*{/ );
174
+ it( 'scopes the catalog to the chart and tooltip scope classes rather than :root', () => {
175
+ expect( stylesheet ).toMatch( /:where\(\.a8c-charts-scope, \.a8c-charts-tooltip-scope\)\s*{/ );
176
176
  expect( stylesheet ).not.toMatch( /(^|\s):root\s*{/ );
177
177
  } );
178
178
 
@@ -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 ] > = [
@@ -262,7 +262,8 @@ export const relativeLuminance = ( hex: string ): number => {
262
262
  * Whether light text reads better than dark text on the given background, using the W3C
263
263
  * luminance threshold (0.179) that maximizes contrast against black vs white.
264
264
  *
265
- * @param backgroundHex - Hex background color
265
+ * @deprecated Assumes black and white text, so it ignores label role overrides and cannot guarantee AA; charts no longer use it, and it will be removed in a future major version.
266
+ * @param backgroundHex - Hex background color
266
267
  * @return true if light text should be used; false (dark text) for malformed colors
267
268
  */
268
269
  export const prefersLightText = ( backgroundHex: string ): boolean => {
@@ -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(
package/src/visx/types.ts CHANGED
@@ -33,7 +33,7 @@ export type XyChartTooltipProps< Datum extends object > = {
33
33
  tooltipPlacement?: TooltipPlacement;
34
34
  /** Override the tooltip top anchor in SVG coordinates, including negative offsets. */
35
35
  tooltipAnchorTop?: number;
36
- /** Merge overrides with the default box styles; use `unstyled` to strip the box styling. */
36
+ /** Inline overrides on the tooltip surface; `unstyled` drops both. */
37
37
  style?: VisxTooltipProps[ 'style' ];
38
38
  snapTooltipToDatumX?: boolean;
39
39
  snapTooltipToDatumY?: boolean;
@@ -1,31 +0,0 @@
1
- import type { LineChartProps } from '../types';
2
-
3
- type Crosshairs = NonNullable< LineChartProps[ 'withTooltipCrosshairs' ] >;
4
-
5
- describe( 'line chart crosshair style types', () => {
6
- test( 'accepts paint attributes', () => {
7
- const crosshairs: Crosshairs = { verticalStyle: { stroke: 'purple', strokeWidth: 2 } };
8
-
9
- expect( crosshairs.verticalStyle?.stroke ).toBe( 'purple' );
10
- } );
11
-
12
- test( 'reserves geometry attributes for the chart', () => {
13
- const crosshairs: Crosshairs = {
14
- // @ts-expect-error Crosshair coordinates are computed from the active datum.
15
- verticalStyle: { x1: 12 },
16
- };
17
-
18
- const transformedCrosshairs: Crosshairs = {
19
- // @ts-expect-error Crosshair transforms are not paint attributes.
20
- verticalStyle: { transform: 'translate(100 0)' },
21
- };
22
- const transformedStyles: Crosshairs = {
23
- // @ts-expect-error Inline crosshair styles accept only paint properties.
24
- horizontalStyle: { style: { transform: 'translateX(100px)' } },
25
- };
26
-
27
- expect( crosshairs ).toBeDefined();
28
- expect( transformedCrosshairs ).toBeDefined();
29
- expect( transformedStyles ).toBeDefined();
30
- } );
31
- } );