@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.
- package/CHANGELOG.md +24 -2
- package/TOKENS.md +12 -10
- package/dist/index.cjs +248 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -20
- package/dist/index.d.cts +22 -11
- package/dist/index.d.ts +22 -11
- package/dist/index.js +249 -147
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +4 -2
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -1
- package/src/charts/conversion-funnel-chart/types.ts +3 -0
- package/src/charts/geo-chart/geo-chart.tsx +4 -0
- package/src/charts/geo-chart/index.ts +7 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
- package/src/charts/geo-chart/types.ts +12 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
- package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
- package/src/charts/heatmap-chart/types.ts +2 -6
- package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
- package/src/charts/line-chart/line-chart.module.scss +0 -5
- package/src/charts/line-chart/line-chart.tsx +8 -40
- package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
- package/src/charts/pie-chart/pie-chart.module.scss +9 -0
- package/src/charts/pie-chart/pie-chart.tsx +22 -76
- package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
- package/src/charts/private/chart-composition/chart-html.tsx +1 -1
- package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
- package/src/charts/private/label-text-color.ts +116 -0
- package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +191 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/components/tooltip/base-tooltip.module.scss +12 -8
- package/src/components/tooltip/base-tooltip.tsx +11 -9
- package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
- package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
- package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
- package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
- package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
- package/src/components/trend-indicator/trend-indicator.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-xychart-theme.ts +4 -12
- package/src/index.ts +7 -1
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
- package/src/providers/chart-context/private/perceptual-color.ts +45 -4
- package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
- package/src/styles/chart-scope-class.ts +5 -0
- package/src/styles/chart-scope.scss +9 -6
- package/src/styles/test/chart-scope.test.ts +3 -3
- package/src/utils/color-utils.ts +2 -1
- package/src/visx/types.ts +1 -1
- 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,
|
|
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
|
-
//
|
|
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
|
-
|
|
6
|
-
|
|
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-
|
|
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 {
|
|
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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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 -
|
|
140
|
-
* @param props.unstyled -
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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
|
+
} );
|