@automattic/charts 4.6.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 (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +12 -10
  3. package/dist/index.cjs +244 -145
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -20
  6. package/dist/index.d.cts +10 -10
  7. package/dist/index.d.ts +10 -10
  8. package/dist/index.js +245 -146
  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/heatmap-chart/heatmap-chart.module.scss +10 -3
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
  18. package/src/charts/heatmap-chart/types.ts +2 -6
  19. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  20. package/src/charts/line-chart/line-chart.module.scss +0 -5
  21. package/src/charts/line-chart/line-chart.tsx +8 -40
  22. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  23. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  24. package/src/charts/pie-chart/pie-chart.tsx +22 -76
  25. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  26. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  27. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  28. package/src/charts/private/label-text-color.ts +116 -0
  29. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  30. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  31. package/src/charts/private/test/label-text-color.test.ts +191 -0
  32. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  33. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  34. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  35. package/src/components/tooltip/base-tooltip.module.scss +12 -8
  36. package/src/components/tooltip/base-tooltip.tsx +11 -9
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
  38. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  40. package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
  41. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  42. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
  43. package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
  44. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  45. package/src/hooks/index.ts +1 -0
  46. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  47. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  48. package/src/hooks/use-xychart-theme.ts +4 -12
  49. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
  50. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  51. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  52. package/src/styles/chart-scope-class.ts +5 -0
  53. package/src/styles/chart-scope.scss +9 -6
  54. package/src/styles/test/chart-scope.test.ts +3 -3
  55. package/src/utils/color-utils.ts +2 -1
  56. package/src/visx/types.ts +1 -1
  57. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
@@ -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
+ } );
@@ -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;