@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
@@ -0,0 +1,189 @@
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 { getHeatmapScale } from '../../heatmap-chart/private/heatmap-scale';
9
+ import {
10
+ pickLabelTextColor,
11
+ pickLabelTextColorForFill,
12
+ resolveLabelRoles,
13
+ } from '../label-text-color';
14
+ import type { Rgb } from '../../../providers/chart-context/private/perceptual-color';
15
+ import type { LabelRoles, LabelTextColor, ResolvedRole } from '../label-text-color';
16
+
17
+ const color = ( hex: string, alpha = 1 ): ResolvedRole => ( {
18
+ kind: 'color',
19
+ rgb: hexToRgb( hex ),
20
+ alpha,
21
+ } );
22
+
23
+ const unreadable = ( raw: string | null ): ResolvedRole => ( { kind: 'unreadable', raw } );
24
+
25
+ const DEFAULT_ROLES: LabelRoles = { label: color( '#1e1e1e' ), labelInverse: color( '#f0f0f0' ) };
26
+
27
+ const WHITE = hexToRgb( '#ffffff' );
28
+ const MID_BLUE = hexToRgb( '#3858e9' );
29
+
30
+ describe( 'resolveLabelRoles', () => {
31
+ const resolverFor =
32
+ ( label: string | null, labelInverse: string | null ) =>
33
+ ( value: string ): string | null =>
34
+ value.includes( '--a8c-charts-color-label-inverse' ) ? labelInverse : label;
35
+
36
+ it( 'resolves opaque roles in any syntax d3 reads', () => {
37
+ expect( resolveLabelRoles( resolverFor( '#1E1E1E', 'rgb(240, 240, 240)' ) ) ).toEqual(
38
+ DEFAULT_ROLES
39
+ );
40
+ } );
41
+
42
+ it( "keeps a role's alpha", () => {
43
+ expect( resolveLabelRoles( resolverFor( 'rgba(30, 30, 30, 0.87)', '#f0f0f0' ) ).label ).toEqual(
44
+ color( '#1e1e1e', 0.87 )
45
+ );
46
+ } );
47
+
48
+ it( 'reads transparent as a role with no alpha', () => {
49
+ expect( resolveLabelRoles( resolverFor( 'transparent', '#f0f0f0' ) ).label ).toEqual(
50
+ color( '#000000', 0 )
51
+ );
52
+ } );
53
+
54
+ it( 'keeps the raw value of a role it cannot read', () => {
55
+ expect( resolveLabelRoles( resolverFor( null, 'oklch(98% 0 0)' ) ) ).toEqual( {
56
+ label: unreadable( null ),
57
+ labelInverse: unreadable( 'oklch(98% 0 0)' ),
58
+ } );
59
+ } );
60
+ } );
61
+
62
+ describe( 'pickLabelTextColorForFill', () => {
63
+ it( 'keeps the default role before the roles are read', () => {
64
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label' ) ).toBe( 'label' );
65
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label-inverse' ) ).toBe( 'label-inverse' );
66
+ } );
67
+
68
+ it( 'picks the role that contrasts more when it reaches AA', () => {
69
+ expect( pickLabelTextColorForFill( WHITE, DEFAULT_ROLES, 'label-inverse' ) ).toBe( 'label' );
70
+ expect( pickLabelTextColorForFill( MID_BLUE, DEFAULT_ROLES, 'label' ) ).toBe( 'label-inverse' );
71
+ } );
72
+
73
+ it( 'falls back to black or white when neither role reaches AA', () => {
74
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), DEFAULT_ROLES, 'label' ) ).toBe(
75
+ 'black'
76
+ );
77
+ expect( pickLabelTextColorForFill( hexToRgb( '#4663ea' ), DEFAULT_ROLES, 'label' ) ).toBe(
78
+ 'white'
79
+ );
80
+ } );
81
+
82
+ it( 'measures a translucent role as it paints over the fill', () => {
83
+ // Opaque, this label would read 16:1 on white; at 30% it paints a pale gray that fails AA.
84
+ const faintLabel: LabelRoles = {
85
+ label: color( '#1e1e1e', 0.3 ),
86
+ labelInverse: color( '#f0f0f0' ),
87
+ };
88
+
89
+ expect( pickLabelTextColorForFill( WHITE, faintLabel, 'label-inverse' ) ).toBe( 'black' );
90
+ } );
91
+
92
+ it( 'never picks a fully transparent role', () => {
93
+ const clearLabel: LabelRoles = {
94
+ label: color( '#000000', 0 ),
95
+ labelInverse: color( '#f0f0f0' ),
96
+ };
97
+
98
+ expect( pickLabelTextColorForFill( WHITE, clearLabel, 'label' ) ).toBe( 'black' );
99
+ } );
100
+
101
+ it( 'treats two different translucent roles as two colors', () => {
102
+ const translucent: LabelRoles = {
103
+ label: color( '#000000', 0.87 ),
104
+ labelInverse: color( '#ffffff', 0.9 ),
105
+ };
106
+
107
+ expect( pickLabelTextColorForFill( MID_BLUE, translucent, 'label' ) ).toBe( 'label-inverse' );
108
+ } );
109
+
110
+ it( 'uses a readable role when the other cannot be read, else black or white', () => {
111
+ const unreadableInverse: LabelRoles = {
112
+ label: color( '#1e1e1e' ),
113
+ labelInverse: unreadable( 'rgb(255 255 255)' ),
114
+ };
115
+
116
+ expect( pickLabelTextColorForFill( WHITE, unreadableInverse, 'label-inverse' ) ).toBe(
117
+ 'label'
118
+ );
119
+ expect( pickLabelTextColorForFill( MID_BLUE, unreadableInverse, 'label' ) ).toBe( 'white' );
120
+ } );
121
+
122
+ it.each( [
123
+ [ 'readable', color( '#767676' ) ],
124
+ [ 'unreadable', unreadable( 'oklch(55% 0 0)' ) ],
125
+ ] )( 'never falls back when both roles are the same %s color', ( _name, role ) => {
126
+ const pinned: LabelRoles = { label: role, labelInverse: { ...role } };
127
+
128
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label' ) ).toBe( 'label' );
129
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label-inverse' ) ).toBe(
130
+ 'label-inverse'
131
+ );
132
+ } );
133
+
134
+ describe.each( [
135
+ [ 'white', '#ffffff', '#f0f0f0' ],
136
+ [ 'dark', '#242424', '#272727' ],
137
+ ] )( 'across a heatmap scale on a %s background', ( _name, background, emptyCell ) => {
138
+ const painted: Record< LabelTextColor, number > = {
139
+ label: rgbLuminance( hexToRgb( '#1e1e1e' ) ),
140
+ 'label-inverse': rgbLuminance( hexToRgb( '#f0f0f0' ) ),
141
+ black: 0,
142
+ white: 1,
143
+ };
144
+ // The Storybook custom accent, then the wp-admin scheme colors.
145
+ const primaries = [
146
+ '#4a19ab',
147
+ '#2271b1',
148
+ '#007cba',
149
+ '#3858e9',
150
+ '#437aa8',
151
+ '#916745',
152
+ '#646c3e',
153
+ '#cf4339',
154
+ '#567958',
155
+ '#ad631e',
156
+ ];
157
+
158
+ it.each( primaries )( 'reaches AA on every intensity of %s', primary => {
159
+ const { low, high } = getHeatmapScale( primary, background, emptyCell );
160
+ for ( let step = 0; step <= 200; step++ ) {
161
+ const fill = blendRgb( hexToRgb( high ), hexToRgb( low ), step / 200 );
162
+ const choice = pickLabelTextColorForFill( fill, DEFAULT_ROLES, 'label' );
163
+ // Checked on the 8-bit channels the browser paints as well as the exact mix.
164
+ const [ r, g, b ] = fill.map( Math.round );
165
+ const painted8Bit: Rgb = [ r, g, b ];
166
+
167
+ for ( const shown of [ fill, painted8Bit ] ) {
168
+ expect(
169
+ luminanceContrastRatio( rgbLuminance( shown ), painted[ choice ] )
170
+ ).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
171
+ }
172
+ }
173
+ } );
174
+ } );
175
+ } );
176
+
177
+ describe( 'pickLabelTextColor', () => {
178
+ it( 'reads the fill from any color syntax', () => {
179
+ expect( pickLabelTextColor( 'rgb(255, 255, 255)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
180
+ 'label'
181
+ );
182
+ } );
183
+
184
+ it( 'keeps the default role for a fill it cannot read', () => {
185
+ expect( pickLabelTextColor( 'var(--not-set)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
186
+ 'label-inverse'
187
+ );
188
+ } );
189
+ } );
@@ -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`.
@@ -1,35 +1,32 @@
1
- import { formatNumber } from '@automattic/number-formatters';
2
- import clsx from 'clsx';
3
- import { useStandaloneScopeClass } from '../../providers/chart-scope';
4
- import styles from './base-tooltip.module.scss';
1
+ import { LabelValueContent } from './private/label-value-content';
2
+ import { TooltipBox } from './tooltip-box';
3
+ import type { TooltipData } from './types';
5
4
  import type { CSSProperties, ComponentType, ReactNode } from 'react';
6
5
 
7
- type TooltipData = {
8
- label: string;
9
- value: number;
10
- valueDisplay?: string;
11
- };
12
-
13
6
  type TooltipComponentProps = {
14
7
  data: TooltipData;
15
8
  className?: string;
16
9
  };
17
10
 
18
11
  type TooltipCommonProps = {
19
- top: number;
20
- left: number;
12
+ /** @deprecated Position the box with `style`, or use `TooltipBox`. */
13
+ top?: number;
14
+ /** @deprecated Position the box with `style`, or use `TooltipBox`. */
15
+ left?: number;
21
16
  style?: CSSProperties;
22
17
  className?: string;
23
18
  /**
24
19
  * Whether to render the tooltip container div. When false, only renders the content.
25
- * Useful when the tooltip is rendered inside a portal or custom container.
20
+ * @deprecated Render the content directly; `TooltipBox` draws the box.
26
21
  * @default true
27
22
  */
28
23
  renderContainer?: boolean;
29
24
  };
30
25
 
31
26
  type DefaultDataTooltip = {
27
+ /** @deprecated Pass the content as children, or use `TooltipBox`. */
32
28
  data: TooltipData;
29
+ /** @deprecated Pass the content as children, or use `TooltipBox`. */
33
30
  component?: ComponentType< TooltipComponentProps >;
34
31
  children?: never;
35
32
  };
@@ -42,38 +39,42 @@ type CustomTooltip = {
42
39
 
43
40
  type BaseTooltipProps = TooltipCommonProps & ( DefaultDataTooltip | CustomTooltip );
44
41
 
45
- const DefaultTooltipContent = ( { data }: TooltipComponentProps ) => (
46
- <>
47
- { data?.label }: { data?.valueDisplay || formatNumber( data?.value ) }
48
- </>
49
- );
50
-
42
+ /**
43
+ * A tooltip box with children or `label: value` content. Prefer `TooltipBox`.
44
+ *
45
+ * @param props - Tooltip props.
46
+ * @param props.data - Data for the default content.
47
+ * @param props.top - Deprecated vertical position.
48
+ * @param props.left - Deprecated horizontal position.
49
+ * @param props.component - Deprecated content component.
50
+ * @param props.children - Tooltip content.
51
+ * @param props.className - Class name passed to the content component.
52
+ * @param props.style - Styles for the box.
53
+ * @param props.renderContainer - Deprecated; render the content alone when false.
54
+ * @return The tooltip.
55
+ */
51
56
  export const BaseTooltip = ( {
52
57
  data,
53
58
  top,
54
59
  left,
55
- component: Component = DefaultTooltipContent,
60
+ component: Component = LabelValueContent,
56
61
  children,
57
62
  className,
58
63
  style,
59
64
  renderContainer = true,
60
65
  }: BaseTooltipProps ) => {
61
66
  const content = children || ( data && <Component data={ data } className={ className } /> );
62
- const standaloneScopeClass = useStandaloneScopeClass();
63
67
 
64
68
  if ( ! renderContainer ) {
65
69
  return content;
66
70
  }
67
71
 
68
- return (
69
- <div
70
- className={ clsx( standaloneScopeClass, styles.tooltip ) }
71
- style={ { top, left, ...style } }
72
- role="tooltip"
73
- >
74
- { content }
75
- </div>
76
- );
72
+ const placement =
73
+ top === undefined && left === undefined
74
+ ? undefined
75
+ : ( { position: 'absolute', top, left, transform: 'translate(-50%, -100%)' } as const );
76
+
77
+ return <TooltipBox style={ { ...placement, ...style } }>{ content }</TooltipBox>;
77
78
  };
78
79
 
79
80
  export type { BaseTooltipProps, TooltipData };
@@ -1,4 +1,6 @@
1
1
  export { BaseTooltip } from './base-tooltip';
2
- export { AccessibleTooltip, useKeyboardNavigation } from './accessible-tooltip';
2
+ export { AccessibleTooltip, XYChartTooltip, useKeyboardNavigation } from './xy-chart-tooltip';
3
+ export { TooltipBox } from './tooltip-box';
3
4
  export type { BaseTooltipProps, TooltipData } from './base-tooltip';
5
+ export type { TooltipBoxProps } from './tooltip-box';
4
6
  export type { TooltipProps } from './types';
@@ -1,15 +1,19 @@
1
- import { Tooltip, defaultStyles } from '@visx/tooltip';
2
1
  import { useLayoutEffect, useRef, useState } from 'react';
2
+ import { TooltipBox } from '../tooltip-box';
3
3
  import type { TooltipPlacement } from '../../../visx/types';
4
- import type { TooltipProps } from '@visx/tooltip';
4
+ import type { TooltipBoxProps } from '../tooltip-box';
5
+ import type { ReactNode } from 'react';
5
6
 
6
7
  type Bounds = { left: number; top: number; right: number; bottom: number };
7
8
  type Size = { width: number; height: number };
8
9
 
9
- export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPositionStyle' > & {
10
+ export type BoundedTooltipProps = Omit< TooltipBoxProps, 'children' > & {
11
+ children?: ReactNode;
10
12
  /** Anchor, in the coordinates of the positioned wrapper the box renders into. */
11
13
  left?: number;
12
14
  top?: number;
15
+ offsetLeft?: number;
16
+ offsetTop?: number;
13
17
  placement?: TooltipPlacement;
14
18
  };
15
19
 
@@ -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.
@@ -122,7 +135,7 @@ export const getBoundedPosition = ( {
122
135
  };
123
136
 
124
137
  /**
125
- * visx's `Tooltip`, positioned like its `TooltipWithBounds` but kept inside the
138
+ * A `TooltipBox`, positioned like visx's `TooltipWithBounds` but kept inside the
126
139
  * nearest clipping ancestor — or the viewport when there is none — rather than
127
140
  * inside its own parent. Rendered in-tree, a tooltip's parent is the chart
128
141
  * wrapper, which is often narrower than the box; measuring against the parent
@@ -131,13 +144,14 @@ export const getBoundedPosition = ( {
131
144
  * Re-measures on every render, so a box whose content changes width between
132
145
  * two hovers is placed for its current size.
133
146
  *
134
- * @param props - visx `Tooltip` props.
147
+ * @param props - `TooltipBox` props plus placement.
135
148
  * @param props.left - Anchor x, in wrapper coordinates.
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
  }
@@ -199,9 +214,12 @@ export const BoundedTooltip = ( {
199
214
  const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
200
215
 
201
216
  return (
202
- <Tooltip
217
+ <TooltipBox
203
218
  ref={ nodeRef }
219
+ role="presentation"
204
220
  data-testid="bounded-tooltip"
221
+ unstyled={ unstyled }
222
+ className={ className }
205
223
  style={ {
206
224
  position: 'absolute',
207
225
  left: 0,
@@ -220,14 +238,15 @@ export const BoundedTooltip = ( {
220
238
  left: left - x - POINTER_HEIGHT,
221
239
  top: -POINTER_HEIGHT,
222
240
  width: POINTER_HEIGHT * 2,
223
- height: POINTER_HEIGHT,
241
+ // One extra pixel overlaps the box, so anti-aliasing leaves no seam between them.
242
+ height: POINTER_HEIGHT + 1,
224
243
  background: 'inherit',
225
- clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
244
+ clipPath: `polygon(50% 0, 100% ${ POINTER_HEIGHT }px, 100% 100%, 0 100%, 0 ${ POINTER_HEIGHT }px)`,
226
245
  pointerEvents: 'none',
227
246
  } }
228
247
  />
229
248
  ) }
230
249
  { children }
231
- </Tooltip>
250
+ </TooltipBox>
232
251
  );
233
252
  };
@@ -0,0 +1,8 @@
1
+ import { formatNumber } from '@automattic/number-formatters';
2
+ import type { TooltipData } from '../types';
3
+
4
+ export const LabelValueContent = ( { data }: { data: TooltipData } ) => (
5
+ <>
6
+ { data.label }: { data.valueDisplay || formatNumber( data.value ) }
7
+ </>
8
+ );
@@ -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
+ };