@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
@@ -0,0 +1,191 @@
1
+ import { MIN_LABEL_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import {
3
+ blendRgb,
4
+ hexToRgb,
5
+ luminanceContrastRatio,
6
+ rgbLuminance,
7
+ } from '../../../providers/chart-context/private/perceptual-color';
8
+ import {
9
+ pickLabelTextColor,
10
+ pickLabelTextColorForFill,
11
+ resolveLabelRoles,
12
+ } from '../label-text-color';
13
+ import type { Rgb } from '../../../providers/chart-context/private/perceptual-color';
14
+ import type { LabelRoles, LabelTextColor, ResolvedRole } from '../label-text-color';
15
+
16
+ const color = ( hex: string, alpha = 1 ): ResolvedRole => ( {
17
+ kind: 'color',
18
+ rgb: hexToRgb( hex ),
19
+ alpha,
20
+ } );
21
+
22
+ const unreadable = ( raw: string | null ): ResolvedRole => ( { kind: 'unreadable', raw } );
23
+
24
+ const DEFAULT_ROLES: LabelRoles = { label: color( '#1e1e1e' ), labelInverse: color( '#f0f0f0' ) };
25
+
26
+ const WHITE = hexToRgb( '#ffffff' );
27
+ const MID_BLUE = hexToRgb( '#3858e9' );
28
+
29
+ describe( 'resolveLabelRoles', () => {
30
+ const resolverFor =
31
+ ( label: string | null, labelInverse: string | null ) =>
32
+ ( value: string ): string | null =>
33
+ value.includes( '--a8c-charts-color-label-inverse' ) ? labelInverse : label;
34
+
35
+ it( 'resolves opaque roles in any syntax d3 reads', () => {
36
+ expect( resolveLabelRoles( resolverFor( '#1E1E1E', 'rgb(240, 240, 240)' ) ) ).toEqual(
37
+ DEFAULT_ROLES
38
+ );
39
+ } );
40
+
41
+ it( "keeps a role's alpha", () => {
42
+ expect( resolveLabelRoles( resolverFor( 'rgba(30, 30, 30, 0.87)', '#f0f0f0' ) ).label ).toEqual(
43
+ color( '#1e1e1e', 0.87 )
44
+ );
45
+ } );
46
+
47
+ it( 'reads transparent as a role with no alpha', () => {
48
+ expect( resolveLabelRoles( resolverFor( 'transparent', '#f0f0f0' ) ).label ).toEqual(
49
+ color( '#000000', 0 )
50
+ );
51
+ } );
52
+
53
+ it( 'keeps the raw value of a role it cannot read', () => {
54
+ expect( resolveLabelRoles( resolverFor( null, 'oklch(98% 0 0)' ) ) ).toEqual( {
55
+ label: unreadable( null ),
56
+ labelInverse: unreadable( 'oklch(98% 0 0)' ),
57
+ } );
58
+ } );
59
+ } );
60
+
61
+ describe( 'pickLabelTextColorForFill', () => {
62
+ it( 'keeps the default role before the roles are read', () => {
63
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label' ) ).toBe( 'label' );
64
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label-inverse' ) ).toBe( 'label-inverse' );
65
+ } );
66
+
67
+ it( 'picks the role that contrasts more when it reaches AA', () => {
68
+ expect( pickLabelTextColorForFill( WHITE, DEFAULT_ROLES, 'label-inverse' ) ).toBe( 'label' );
69
+ expect( pickLabelTextColorForFill( MID_BLUE, DEFAULT_ROLES, 'label' ) ).toBe( 'label-inverse' );
70
+ } );
71
+
72
+ it( 'falls back to black or white when neither role reaches AA', () => {
73
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), DEFAULT_ROLES, 'label' ) ).toBe(
74
+ 'black'
75
+ );
76
+ expect( pickLabelTextColorForFill( hexToRgb( '#4663ea' ), DEFAULT_ROLES, 'label' ) ).toBe(
77
+ 'white'
78
+ );
79
+ } );
80
+
81
+ it( 'measures a translucent role as it paints over the fill', () => {
82
+ // Opaque, this label would read 16:1 on white; at 30% it paints a pale gray that fails AA.
83
+ const faintLabel: LabelRoles = {
84
+ label: color( '#1e1e1e', 0.3 ),
85
+ labelInverse: color( '#f0f0f0' ),
86
+ };
87
+
88
+ expect( pickLabelTextColorForFill( WHITE, faintLabel, 'label-inverse' ) ).toBe( 'black' );
89
+ } );
90
+
91
+ it( 'never picks a fully transparent role', () => {
92
+ const clearLabel: LabelRoles = {
93
+ label: color( '#000000', 0 ),
94
+ labelInverse: color( '#f0f0f0' ),
95
+ };
96
+
97
+ expect( pickLabelTextColorForFill( WHITE, clearLabel, 'label' ) ).toBe( 'black' );
98
+ } );
99
+
100
+ it( 'treats two different translucent roles as two colors', () => {
101
+ const translucent: LabelRoles = {
102
+ label: color( '#000000', 0.87 ),
103
+ labelInverse: color( '#ffffff', 0.9 ),
104
+ };
105
+
106
+ expect( pickLabelTextColorForFill( MID_BLUE, translucent, 'label' ) ).toBe( 'label-inverse' );
107
+ } );
108
+
109
+ it( 'uses a readable role when the other cannot be read, else black or white', () => {
110
+ const unreadableInverse: LabelRoles = {
111
+ label: color( '#1e1e1e' ),
112
+ labelInverse: unreadable( 'rgb(255 255 255)' ),
113
+ };
114
+
115
+ expect( pickLabelTextColorForFill( WHITE, unreadableInverse, 'label-inverse' ) ).toBe(
116
+ 'label'
117
+ );
118
+ expect( pickLabelTextColorForFill( MID_BLUE, unreadableInverse, 'label' ) ).toBe( 'white' );
119
+ } );
120
+
121
+ it.each( [
122
+ [ 'readable', color( '#767676' ) ],
123
+ [ 'unreadable', unreadable( 'oklch(55% 0 0)' ) ],
124
+ ] )( 'never falls back when both roles are the same %s color', ( _name, role ) => {
125
+ const pinned: LabelRoles = { label: role, labelInverse: { ...role } };
126
+
127
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label' ) ).toBe( 'label' );
128
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label-inverse' ) ).toBe(
129
+ 'label-inverse'
130
+ );
131
+ } );
132
+
133
+ describe.each( [
134
+ [ 'white', '#ffffff' ],
135
+ [ 'dark', '#1e1e1e' ],
136
+ ] )( 'across a heatmap scale on a %s background', ( _name, background ) => {
137
+ const painted: Record< LabelTextColor, number > = {
138
+ label: rgbLuminance( hexToRgb( '#1e1e1e' ) ),
139
+ 'label-inverse': rgbLuminance( hexToRgb( '#f0f0f0' ) ),
140
+ black: 0,
141
+ white: 1,
142
+ };
143
+ // The Storybook custom accent, then the wp-admin scheme colors.
144
+ const primaries = [
145
+ '#4a19ab',
146
+ '#2271b1',
147
+ '#007cba',
148
+ '#3858e9',
149
+ '#437aa8',
150
+ '#916745',
151
+ '#646c3e',
152
+ '#cf4339',
153
+ '#567958',
154
+ '#ad631e',
155
+ ];
156
+
157
+ it.each( primaries )( 'reaches AA on every intensity of %s', primary => {
158
+ for ( let step = 0; step <= 200; step++ ) {
159
+ const fill = blendRgb(
160
+ hexToRgb( primary ),
161
+ hexToRgb( background ),
162
+ 0.15 + 0.85 * ( step / 200 )
163
+ );
164
+ const choice = pickLabelTextColorForFill( fill, DEFAULT_ROLES, 'label' );
165
+ // Checked on the 8-bit channels the browser paints as well as the exact mix.
166
+ const [ r, g, b ] = fill.map( Math.round );
167
+ const painted8Bit: Rgb = [ r, g, b ];
168
+
169
+ for ( const shown of [ fill, painted8Bit ] ) {
170
+ expect(
171
+ luminanceContrastRatio( rgbLuminance( shown ), painted[ choice ] )
172
+ ).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
173
+ }
174
+ }
175
+ } );
176
+ } );
177
+ } );
178
+
179
+ describe( 'pickLabelTextColor', () => {
180
+ it( 'reads the fill from any color syntax', () => {
181
+ expect( pickLabelTextColor( 'rgb(255, 255, 255)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
182
+ 'label'
183
+ );
184
+ } );
185
+
186
+ it( 'keeps the default role for a fill it cannot read', () => {
187
+ expect( pickLabelTextColor( 'var(--not-set)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
188
+ 'label-inverse'
189
+ );
190
+ } );
191
+ } );
@@ -3,6 +3,7 @@ import { useContext } from 'react';
3
3
  import { ChartScopeContext } from '../../../../providers/chart-scope';
4
4
  import { withResponsive } from '../index';
5
5
  import type { BaseChartProps } from '../../../../types';
6
+ import type { JSX } from 'react';
6
7
 
7
8
  // Mock the useParentSize hook
8
9
  jest.mock( '@visx/responsive', () => ( {
@@ -1,6 +1,7 @@
1
1
  import { useParentSize } from '@visx/responsive';
2
2
  import clsx from 'clsx';
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
+ import { useIsomorphicLayoutEffect } from '../../../hooks';
4
5
  import { ChartScopeContext } from '../../../providers/chart-scope';
5
6
  import styles from './with-responsive.module.scss';
6
7
  import type { BaseChartProps } from '../../../types';
@@ -33,10 +34,6 @@ export type ResponsiveConfig = {
33
34
  resizeDebounceTime?: number;
34
35
  };
35
36
 
36
- // useLayoutEffect on the client (so containment is resolved before paint, no flash),
37
- // useEffect on the server to avoid React's SSR warning.
38
- const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
39
-
40
37
  /**
41
38
  * A higher-order component that provides responsive dimensions
42
39
  * to the wrapped chart component using useParentSize from `@visx/responsive`.
@@ -19,7 +19,7 @@ export type FlattenedTooltipData = {
19
19
  dataPointIndex: number;
20
20
  };
21
21
 
22
- // Enhanced tooltip with keyboard navigation and accessibility
22
+ // The XY chart tooltip, as LineChart, AreaChart and BarChart render it: place it inside a visx `XYChart`, with `useKeyboardNavigation` for keyboard access.
23
23
  interface AccessibleTooltipProps extends Omit<
24
24
  XyChartTooltipProps< DataPointDate >,
25
25
  'renderTooltip'
@@ -1,17 +1,21 @@
1
1
  // `.surface` is the look alone, for a chart that positions the box itself.
2
+ // The box carries the chart scope class inside TooltipTheme, so these roles
3
+ // resolve for the dark tooltip theme, as in @wordpress/ui's Tooltip popup.
2
4
  .surface,
3
5
  .tooltip {
4
- padding: var(--wpds-dimension-padding-sm);
5
- // No WPDS token fits a translucent dark tooltip surface:
6
- // bg-interactive-neutral-strong is opaque, and there is no
7
- // white-on-dark content foreground token. The role carries the
8
- // hardcoded translucent value as its fallback.
9
- background-color: var(--a8c-charts-color-tooltip-surface, rgba(0, 0, 0, 0.85));
10
- color: #fff;
6
+ padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm);
7
+ background-color: var(--a8c-charts-color-surface);
8
+ color: var(--a8c-charts-color-label);
11
9
  border-radius: var(--wpds-border-radius-md);
12
- font-size: var(--wpds-typography-font-size-md);
10
+ font-family: var(--wpds-typography-font-family-body);
11
+ font-size: var(--wpds-typography-font-size-sm);
12
+ line-height: 1.4;
13
13
  box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
14
14
  pointer-events: none;
15
+
16
+ @media (forced-colors: active) {
17
+ border: var(--wpds-border-width-xs) solid CanvasText;
18
+ }
15
19
  }
16
20
 
17
21
  .tooltip {
@@ -1,7 +1,8 @@
1
1
  import { formatNumber } from '@automattic/number-formatters';
2
2
  import clsx from 'clsx';
3
- import { useStandaloneScopeClass } from '../../providers/chart-scope';
3
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
4
4
  import styles from './base-tooltip.module.scss';
5
+ import { TooltipTheme } from './private/tooltip-theme';
5
6
  import type { CSSProperties, ComponentType, ReactNode } from 'react';
6
7
 
7
8
  type TooltipData = {
@@ -59,20 +60,21 @@ export const BaseTooltip = ( {
59
60
  renderContainer = true,
60
61
  }: BaseTooltipProps ) => {
61
62
  const content = children || ( data && <Component data={ data } className={ className } /> );
62
- const standaloneScopeClass = useStandaloneScopeClass();
63
63
 
64
64
  if ( ! renderContainer ) {
65
65
  return content;
66
66
  }
67
67
 
68
68
  return (
69
- <div
70
- className={ clsx( standaloneScopeClass, styles.tooltip ) }
71
- style={ { top, left, ...style } }
72
- role="tooltip"
73
- >
74
- { content }
75
- </div>
69
+ <TooltipTheme>
70
+ <div
71
+ className={ clsx( CHART_SCOPE_CLASS, styles.tooltip ) }
72
+ style={ { top, left, ...style } }
73
+ role="tooltip"
74
+ >
75
+ { content }
76
+ </div>
77
+ </TooltipTheme>
76
78
  );
77
79
  };
78
80
 
@@ -1,5 +1,9 @@
1
- import { Tooltip, defaultStyles } from '@visx/tooltip';
1
+ import { Tooltip } from '@visx/tooltip';
2
+ import clsx from 'clsx';
2
3
  import { useLayoutEffect, useRef, useState } from 'react';
4
+ import { TOOLTIP_SCOPE_CLASS } from '../../../styles/chart-scope-class';
5
+ import styles from '../base-tooltip.module.scss';
6
+ import { TooltipTheme } from './tooltip-theme';
3
7
  import type { TooltipPlacement } from '../../../visx/types';
4
8
  import type { TooltipProps } from '@visx/tooltip';
5
9
 
@@ -27,6 +31,15 @@ const isClipping = ( element: Element ) => {
27
31
  return [ overflow, overflowX, overflowY ].some( value => value && value !== 'visible' );
28
32
  };
29
33
 
34
+ // The positioned wrapper the box renders into, past the tooltip theme's `display: contents` element, which has no box to measure.
35
+ const findLayoutParent = ( node: Element ): Element | null => {
36
+ let element = node.parentElement;
37
+ while ( element && getComputedStyle( element ).display === 'contents' ) {
38
+ element = element.parentElement;
39
+ }
40
+ return element;
41
+ };
42
+
30
43
  // The first ancestor that cuts its overflow off. The box may leave the chart
31
44
  // wrapper but never this element, which is what a body-level portal used to
32
45
  // guarantee by never being inside one.
@@ -136,8 +149,9 @@ export const getBoundedPosition = ( {
136
149
  * @param props.top - Anchor y, in wrapper coordinates.
137
150
  * @param props.offsetLeft - Gap between the anchor and the box, horizontally.
138
151
  * @param props.offsetTop - Gap between the anchor and the box, vertically.
139
- * @param props.style - Box styles; visx's defaults unless `unstyled`.
140
- * @param props.unstyled - Skip `style` and leave the box bare.
152
+ * @param props.style - Inline overrides on the surface; ignored when `unstyled`.
153
+ * @param props.unstyled - Drop the surface and `style`, leaving the box bare.
154
+ * @param props.className - Extra classes beside the surface.
141
155
  * @param props.children - Box content.
142
156
  * @param props.placement - Below-axis, beside without vertical flipping, or automatic flipping.
143
157
  * @return The tooltip box.
@@ -147,8 +161,9 @@ export const BoundedTooltip = ( {
147
161
  top = 0,
148
162
  offsetLeft = DEFAULT_OFFSET,
149
163
  offsetTop = DEFAULT_OFFSET,
150
- style = defaultStyles,
164
+ style,
151
165
  unstyled = false,
166
+ className,
152
167
  children,
153
168
  placement = 'auto',
154
169
  ...rest
@@ -159,7 +174,7 @@ export const BoundedTooltip = ( {
159
174
 
160
175
  useLayoutEffect( () => {
161
176
  const node = nodeRef.current;
162
- const wrapper = node?.parentElement;
177
+ const wrapper = node && findLayoutParent( node );
163
178
  if ( ! node || ! wrapper ) {
164
179
  return;
165
180
  }
@@ -198,10 +213,11 @@ export const BoundedTooltip = ( {
198
213
  const x = position?.x ?? left + offsetLeft;
199
214
  const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
200
215
 
201
- return (
216
+ const box = (
202
217
  <Tooltip
203
218
  ref={ nodeRef }
204
219
  data-testid="bounded-tooltip"
220
+ className={ clsx( ! unstyled && [ TOOLTIP_SCOPE_CLASS, styles.surface ], className ) }
205
221
  style={ {
206
222
  position: 'absolute',
207
223
  left: 0,
@@ -220,9 +236,10 @@ export const BoundedTooltip = ( {
220
236
  left: left - x - POINTER_HEIGHT,
221
237
  top: -POINTER_HEIGHT,
222
238
  width: POINTER_HEIGHT * 2,
223
- height: POINTER_HEIGHT,
239
+ // One extra pixel overlaps the box, so anti-aliasing leaves no seam between them.
240
+ height: POINTER_HEIGHT + 1,
224
241
  background: 'inherit',
225
- clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
242
+ clipPath: `polygon(50% 0, 100% ${ POINTER_HEIGHT }px, 100% 100%, 0 100%, 0 ${ POINTER_HEIGHT }px)`,
226
243
  pointerEvents: 'none',
227
244
  } }
228
245
  />
@@ -230,4 +247,6 @@ export const BoundedTooltip = ( {
230
247
  { children }
231
248
  </Tooltip>
232
249
  );
250
+
251
+ return unstyled ? box : <TooltipTheme>{ box }</TooltipTheme>;
233
252
  };
@@ -0,0 +1,37 @@
1
+ import { color as d3Color } from '@visx/vendor/d3-color';
2
+ import { ThemeProvider } from '@wordpress/theme';
3
+ import { useMemo } from 'react';
4
+ import { useChartScopeElement } from '../../../providers/chart-scope';
5
+ import { resolveCssVariable } from '../../../utils/resolve-css-var';
6
+ import type { ReactNode } from 'react';
7
+
8
+ const DEFAULT_SURFACE = '#1e1e1e';
9
+ const TOOLTIP_SURFACE = `var(--a8c-charts-color-tooltip-surface, ${ DEFAULT_SURFACE })`;
10
+
11
+ // `ThemeProvider` throws on a seed that is not an opaque sRGB color, which would unmount the chart.
12
+ const toSeed = ( value: string | null ) => {
13
+ const parsed = value ? d3Color( value ) : null;
14
+ return parsed && parsed.opacity === 1 ? parsed.formatHex() : DEFAULT_SURFACE;
15
+ };
16
+
17
+ /**
18
+ * Themes the tooltip box for its dark surface, as `@wordpress/ui`'s Tooltip themes its popup. The box re-declares the catalog under this theme, so every role it reads is tuned for the surface, whatever theme the chart is in.
19
+ *
20
+ * @param props - Component props.
21
+ * @param props.children - The tooltip box.
22
+ * @return The themed box.
23
+ */
24
+ export const TooltipTheme = ( { children }: { children: ReactNode } ) => {
25
+ const scopeElement = useChartScopeElement();
26
+ const color = useMemo(
27
+ () => ( { background: toSeed( resolveCssVariable( TOOLTIP_SURFACE, scopeElement ) ) } ),
28
+ [ scopeElement ]
29
+ );
30
+
31
+ // Core's `wp-theme` in WordPress 7.0 exports only `privateApis`.
32
+ if ( ! ThemeProvider ) {
33
+ return <>{ children }</>;
34
+ }
35
+
36
+ return <ThemeProvider color={ color }>{ children }</ThemeProvider>;
37
+ };
@@ -126,6 +126,87 @@ describe( 'BoundedTooltip', () => {
126
126
  jest.restoreAllMocks();
127
127
  } );
128
128
 
129
+ test( 'draws the package surface, not the visx default box', () => {
130
+ render(
131
+ <BoundedTooltip left={ 10 } top={ 10 } data-testid="box">
132
+ content
133
+ </BoundedTooltip>
134
+ );
135
+ const box = screen.getByTestId( 'box' );
136
+ expect( box ).toHaveClass( 'surface' );
137
+ expect( box ).not.toHaveStyle( { backgroundColor: 'white' } );
138
+ // toHaveStyle can't assert an absent inline style; box.style reads the empty string directly.
139
+ // eslint-disable-next-line jest-dom/prefer-to-have-style
140
+ expect( box.style.boxShadow ).toBe( '' );
141
+ // eslint-disable-next-line jest-dom/prefer-to-have-style
142
+ expect( box.style.padding ).toBe( '' );
143
+ } );
144
+
145
+ test( 'keeps a caller class beside the surface', () => {
146
+ render(
147
+ <BoundedTooltip left={ 10 } top={ 10 } className="extra" data-testid="box">
148
+ content
149
+ </BoundedTooltip>
150
+ );
151
+ expect( screen.getByTestId( 'box' ) ).toHaveClass( 'surface', 'extra' );
152
+ } );
153
+
154
+ test( 'drops the surface when unstyled', () => {
155
+ render(
156
+ <BoundedTooltip left={ 10 } top={ 10 } unstyled data-testid="box">
157
+ content
158
+ </BoundedTooltip>
159
+ );
160
+ expect( screen.getByTestId( 'box' ) ).not.toHaveClass( 'surface' );
161
+ expect( screen.getByTestId( 'box' ) ).not.toHaveClass( 'a8c-charts-tooltip-scope' );
162
+ } );
163
+
164
+ test( 're-declares the chart catalog on a styled box under its own class, out of reach of page-wide chart overrides', () => {
165
+ render(
166
+ <BoundedTooltip left={ 10 } top={ 10 } data-testid="box">
167
+ content
168
+ </BoundedTooltip>
169
+ );
170
+ const box = screen.getByTestId( 'box' );
171
+ expect( box ).toHaveClass( 'a8c-charts-tooltip-scope', 'surface' );
172
+ expect( box ).not.toHaveClass( 'a8c-charts-scope' );
173
+ } );
174
+
175
+ test( 'measures against the positioned wrapper past a display: contents element', () => {
176
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
177
+ this: Element
178
+ ) {
179
+ if ( this.classList.contains( 'visx-tooltip' ) ) {
180
+ return rect( 0, 0, 208, 36 );
181
+ }
182
+ return this.getAttribute( 'data-testid' ) === 'contents'
183
+ ? rect( 0, 0, 0, 0 )
184
+ : rect( 0, 0, 600, 200 );
185
+ } );
186
+
187
+ render(
188
+ <div style={ { position: 'relative', overflow: 'hidden' } }>
189
+ <div data-testid="contents" style={ { display: 'contents' } }>
190
+ <BoundedTooltip left={ 300 } top={ 50 } unstyled data-testid="box">
191
+ content
192
+ </BoundedTooltip>
193
+ </div>
194
+ </div>
195
+ );
196
+
197
+ // Measured against the zero-size contents element, the box would flip to (82, 4).
198
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(310px, 60px)' } );
199
+ } );
200
+
201
+ test( 'lets an inline style override the surface', () => {
202
+ render(
203
+ <BoundedTooltip left={ 10 } top={ 10 } style={ { background: 'red' } } data-testid="box">
204
+ content
205
+ </BoundedTooltip>
206
+ );
207
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { background: 'red' } );
208
+ } );
209
+
129
210
  test( 'keeps the box inside an overflow-hidden ancestor that is wider than the wrapper', () => {
130
211
  // Card at x=400 (400 wide, overflow hidden); plot wrapper at x=440, 346 wide.
131
212
  jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
@@ -209,7 +290,7 @@ describe( 'BoundedTooltip', () => {
209
290
  left: `${ pointerLeft }px`,
210
291
  top: '-6px',
211
292
  width: '12px',
212
- height: '6px',
293
+ height: '7px',
213
294
  } );
214
295
  expect( screen.getByTestId( 'tooltip-axis-pointer' ) ).toHaveAttribute(
215
296
  'aria-hidden',
@@ -36,6 +36,11 @@ describe( 'BaseTooltip', () => {
36
36
  } );
37
37
  } );
38
38
 
39
+ test( 'renders the standalone container on the tooltip chrome', () => {
40
+ render( <BaseTooltip { ...defaultProps } /> );
41
+ expect( screen.getByRole( 'tooltip' ) ).toHaveClass( 'tooltip' );
42
+ } );
43
+
39
44
  test( 'handles missing valueDisplay', () => {
40
45
  const propsWithoutDisplay = {
41
46
  ...defaultProps,
@@ -0,0 +1,61 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { resolveCssVariable } from '../../../utils/resolve-css-var';
3
+ import { TooltipTheme } from '../private/tooltip-theme';
4
+
5
+ jest.mock( '@wordpress/theme', () => ( { ...jest.requireActual( '@wordpress/theme' ) } ) );
6
+ jest.mock( '../../../utils/resolve-css-var', () => ( { resolveCssVariable: jest.fn() } ) );
7
+
8
+ const theme = jest.requireMock( '@wordpress/theme' );
9
+ const { ThemeProvider } = jest.requireActual( '@wordpress/theme' );
10
+
11
+ const spyOnThemeProvider = () =>
12
+ jest.spyOn( theme, 'ThemeProvider' ).mockImplementation( ( { children } ) => children );
13
+
14
+ describe( 'TooltipTheme', () => {
15
+ afterEach( () => {
16
+ jest.restoreAllMocks();
17
+ theme.ThemeProvider = ThemeProvider;
18
+ } );
19
+
20
+ test( 'renders the box unthemed when core exports no public ThemeProvider', () => {
21
+ theme.ThemeProvider = undefined;
22
+ jest.mocked( resolveCssVariable ).mockReturnValue( '#1e1e1e' );
23
+
24
+ render( <TooltipTheme>box</TooltipTheme> );
25
+
26
+ expect( screen.getByText( 'box' ) ).toBeInTheDocument();
27
+ } );
28
+
29
+ test.each( [ 'rgba(0, 0, 0, 0.85)', 'not-a-color', null ] )(
30
+ 'seeds the default surface when the surface role resolves to %p',
31
+ value => {
32
+ const provider = spyOnThemeProvider();
33
+ jest.mocked( resolveCssVariable ).mockReturnValue( value );
34
+
35
+ render( <TooltipTheme>box</TooltipTheme> );
36
+
37
+ expect( provider.mock.calls[ 0 ][ 0 ] ).toMatchObject( {
38
+ color: { background: '#1e1e1e' },
39
+ } );
40
+ }
41
+ );
42
+
43
+ test( 'seeds an opaque surface role as hex', () => {
44
+ const provider = spyOnThemeProvider();
45
+ jest.mocked( resolveCssVariable ).mockReturnValue( 'rgb(255, 255, 255)' );
46
+
47
+ render( <TooltipTheme>box</TooltipTheme> );
48
+
49
+ expect( provider.mock.calls[ 0 ][ 0 ] ).toMatchObject( {
50
+ color: { background: '#ffffff' },
51
+ } );
52
+ } );
53
+
54
+ test( 'renders with the real ThemeProvider when the surface role is translucent', () => {
55
+ jest.mocked( resolveCssVariable ).mockReturnValue( 'rgba(0, 0, 0, 0.85)' );
56
+
57
+ render( <TooltipTheme>box</TooltipTheme> );
58
+
59
+ expect( screen.getByText( 'box' ) ).toBeInTheDocument();
60
+ } );
61
+ } );