@automattic/charts 4.5.0 → 4.7.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 (62) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/TOKENS.md +12 -10
  3. package/dist/index.cjs +248 -146
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -20
  6. package/dist/index.d.cts +22 -11
  7. package/dist/index.d.ts +22 -11
  8. package/dist/index.js +249 -147
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  12. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +4 -2
  13. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -1
  14. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  15. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  16. package/src/charts/geo-chart/index.ts +7 -1
  17. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  18. package/src/charts/geo-chart/types.ts +12 -0
  19. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
  20. package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
  22. package/src/charts/heatmap-chart/types.ts +2 -6
  23. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  24. package/src/charts/line-chart/line-chart.module.scss +0 -5
  25. package/src/charts/line-chart/line-chart.tsx +8 -40
  26. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  27. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  28. package/src/charts/pie-chart/pie-chart.tsx +22 -76
  29. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  30. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  31. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  32. package/src/charts/private/label-text-color.ts +116 -0
  33. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  34. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  35. package/src/charts/private/test/label-text-color.test.ts +191 -0
  36. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  37. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  38. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  39. package/src/components/tooltip/base-tooltip.module.scss +12 -8
  40. package/src/components/tooltip/base-tooltip.tsx +11 -9
  41. package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
  42. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  43. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  44. package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
  45. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  46. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
  47. package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
  48. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  49. package/src/hooks/index.ts +1 -0
  50. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  51. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  52. package/src/hooks/use-xychart-theme.ts +4 -12
  53. package/src/index.ts +7 -1
  54. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
  55. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  56. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  57. package/src/styles/chart-scope-class.ts +5 -0
  58. package/src/styles/chart-scope.scss +9 -6
  59. package/src/styles/test/chart-scope.test.ts +3 -3
  60. package/src/utils/color-utils.ts +2 -1
  61. package/src/visx/types.ts +1 -1
  62. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
@@ -1,5 +1,5 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
- import { BarSeries, buildChartTheme, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
2
+ import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
3
3
  import { useContext, useEffect } from 'react';
4
4
  import { XyChartTooltip } from '../xy-chart-tooltip';
5
5
  import type { XyChartTooltipProps } from '../../../visx/types';
@@ -103,20 +103,35 @@ const renderChart = (
103
103
 
104
104
  describe( 'XyChartTooltip', () => {
105
105
  test.each( [
106
- [ 'CanvasText', '0 1px 2px #22222255' ],
107
- [ '#123456', '0 1px 2px #12345655' ],
108
- ] )( 'renders a valid shadow for htmlLabel color %s', async ( color, boxShadow ) => {
109
- const theme = buildChartTheme( {
110
- backgroundColor: '#ffffff',
111
- colors: [ '#123456' ],
112
- tickLength: 4,
113
- gridColor: '#dddddd',
114
- gridColorDark: '#222222',
115
- htmlLabel: { color },
116
- } );
117
- renderChart( {}, undefined, undefined, theme );
106
+ [ 'bounded', {} ],
107
+ [ 'unbounded', { detectBounds: false } ],
108
+ [ 'below-axis', { tooltipPlacement: 'below-axis' as const } ],
109
+ ] )( 'draws the %s box on the package surface, with no visx box styles', async ( _, props ) => {
110
+ renderChart( props );
111
+ const box = await screen.findByTestId( 'tooltip-box' );
112
+ expect( box ).toHaveClass( 'surface' );
113
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'box-shadow' ) );
114
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background-color' ) );
115
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'padding' ) );
116
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'border-radius' ) );
117
+ } );
118
+
119
+ test( 'strips the surface from an unbounded box when unstyled', async () => {
120
+ renderChart( { detectBounds: false, unstyled: true } );
121
+ const box = await screen.findByTestId( 'tooltip-box' );
122
+ expect( box ).not.toHaveClass( 'surface' );
123
+ } );
124
+
125
+ test( 'strips the surface from a bounded box when unstyled', async () => {
126
+ renderChart( { unstyled: true } );
127
+ const box = await screen.findByTestId( 'tooltip-box' );
128
+ expect( box ).not.toHaveClass( 'surface' );
129
+ } );
118
130
 
119
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { boxShadow } );
131
+ test( 'keeps a caller class beside the surface on an unbounded box', async () => {
132
+ renderChart( { detectBounds: false, className: 'extra' } );
133
+ const box = await screen.findByTestId( 'tooltip-box' );
134
+ expect( box ).toHaveClass( 'surface', 'extra' );
120
135
  } );
121
136
 
122
137
  test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
@@ -351,25 +366,23 @@ describe( 'XyChartTooltip', () => {
351
366
  } );
352
367
 
353
368
  test.each( [ 'auto', 'below-axis' ] as const )(
354
- 'preserves %s background and stacking with a partial style override',
369
+ 'keeps the %s surface and stacking under a partial style override',
355
370
  async tooltipPlacement => {
356
- const { unmount } = renderChart( { tooltipPlacement } );
357
- const defaultBox = await screen.findByTestId( 'tooltip-box' );
358
- const background = defaultBox.style.backgroundColor;
359
- expect( background ).not.toBe( '' );
360
- expect( background ).not.toBe( 'transparent' );
361
- unmount();
362
-
363
371
  renderChart( { tooltipPlacement, style: { color: 'red' } } );
364
-
365
- await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
366
- backgroundColor: background,
367
- zIndex: '3',
368
- color: 'rgb(255, 0, 0)',
369
- } );
372
+ const box = await screen.findByTestId( 'tooltip-box' );
373
+ expect( box ).toHaveClass( 'surface' );
374
+ expect( box ).toHaveStyle( { zIndex: '3', color: 'rgb(255, 0, 0)' } );
370
375
  }
371
376
  );
372
377
 
378
+ test( 'lets a style override paint the box', async () => {
379
+ renderChart( { style: { background: 'rgb(0, 0, 0)', color: 'rgb(255, 255, 255)' } } );
380
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
381
+ background: 'rgb(0, 0, 0)',
382
+ color: 'rgb(255, 255, 255)',
383
+ } );
384
+ } );
385
+
373
386
  test( 'accepts the portal-era options without passing them to the box', async () => {
374
387
  renderChart( { scroll: true, debounce: 50, resizeObserverPolyfill: undefined } );
375
388
 
@@ -1,9 +1,12 @@
1
- import { Tooltip, defaultStyles } from '@visx/tooltip';
1
+ import { Tooltip } from '@visx/tooltip';
2
2
  import { DataContext, TooltipContext } from '@visx/xychart';
3
+ import clsx from 'clsx';
3
4
  import { useCallback, useContext, useState } from 'react';
4
5
  import { createPortal } from 'react-dom';
5
- import { isValidHexColor } from '../../utils';
6
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
7
+ import styles from './base-tooltip.module.scss';
6
8
  import { BoundedTooltip, TOOLTIP_Z_INDEX } from './private/bounded-tooltip';
9
+ import { TooltipTheme } from './private/tooltip-theme';
7
10
  import type {
8
11
  CrosshairStyle,
9
12
  RenderTooltipGlyphProps,
@@ -215,16 +218,29 @@ const XyChartTooltipContent = < Datum extends object >( {
215
218
  const marginLeft = margin?.left ?? 0;
216
219
 
217
220
  const TooltipComponent = detectBounds || tooltipPlacement !== 'auto' ? BoundedTooltip : Tooltip;
218
- const boxStyle: CSSProperties = {
219
- ...defaultStyles,
220
- zIndex,
221
- backgroundColor: theme?.backgroundColor ?? 'white',
222
- boxShadow: `0 1px 2px ${
223
- isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
224
- }`,
225
- ...theme?.htmlLabel,
226
- ...style,
227
- };
221
+ const boxStyle: CSSProperties = { zIndex, ...style };
222
+ // BoundedTooltip themes its own box.
223
+ const themesOwnBox = TooltipComponent === Tooltip && ! tooltipProps.unstyled;
224
+ const box = (
225
+ <TooltipComponent
226
+ left={ tooltipLeft }
227
+ top={
228
+ tooltipPlacement === 'below-axis'
229
+ ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
230
+ : ( tooltipAnchorTop ?? tooltipTop )
231
+ }
232
+ style={ boxStyle }
233
+ applyPositionStyle
234
+ { ...tooltipProps }
235
+ className={ clsx(
236
+ themesOwnBox && [ CHART_SCOPE_CLASS, styles.surface ],
237
+ tooltipProps.className
238
+ ) }
239
+ { ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
240
+ >
241
+ { tooltipContent }
242
+ </TooltipComponent>
243
+ );
228
244
 
229
245
  return (
230
246
  <>
@@ -258,23 +274,7 @@ const XyChartTooltipContent = < Datum extends object >( {
258
274
  { glyphs }
259
275
  </g>
260
276
  { container &&
261
- createPortal(
262
- <TooltipComponent
263
- left={ tooltipLeft }
264
- top={
265
- tooltipPlacement === 'below-axis'
266
- ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
267
- : ( tooltipAnchorTop ?? tooltipTop )
268
- }
269
- style={ boxStyle }
270
- applyPositionStyle
271
- { ...tooltipProps }
272
- { ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
273
- >
274
- { tooltipContent }
275
- </TooltipComponent>,
276
- container
277
- ) }
277
+ createPortal( themesOwnBox ? <TooltipTheme>{ box }</TooltipTheme> : box, container ) }
278
278
  </>
279
279
  );
280
280
  };
@@ -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';
@@ -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
  );
@@ -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
@@ -82,7 +82,13 @@ export type {
82
82
  PieSemiCircleChartProps,
83
83
  PieSemiCircleChartRenderTooltipParams,
84
84
  } from './charts/pie-semi-circle-chart';
85
- export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './charts/geo-chart';
85
+ export type {
86
+ GeoChartProps,
87
+ GeoRegion,
88
+ GeoResolution,
89
+ GeoDisplayMode,
90
+ GeoChartError,
91
+ } from './charts/geo-chart';
86
92
  export type { LegendValueDisplay, BaseLegendItem } from './components/legend';
87
93
  export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator';
88
94
  export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart';
@@ -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.
@@ -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
  *
@@ -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
+ } );
@@ -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
 
@@ -87,7 +90,7 @@
87
90
 
88
91
  @media (forced-colors: active) {
89
92
 
90
- :where(.a8c-charts-scope) {
93
+ :where(.a8c-charts-scope, .a8c-charts-tooltip-scope) {
91
94
  --a8c-charts-color-label-axis: CanvasText;
92
95
  --a8c-charts-color-grid: GrayText;
93
96
  --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
 
@@ -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 => {
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
- } );