@automattic/charts 3.2.0 → 4.1.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 +47 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1483 -664
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +93 -29
- package/dist/index.d.cts +233 -247
- package/dist/index.d.ts +233 -247
- package/dist/index.js +1484 -667
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +2 -1
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +8 -2
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +128 -26
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +17 -3
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +21 -17
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +8 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +79 -166
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +157 -0
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +56 -5
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -2,51 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
import { render, screen } from '@testing-library/react';
|
|
4
4
|
import { Sparkline, SparklineUnresponsive } from '../';
|
|
5
|
-
import { GlobalChartsProvider
|
|
6
|
-
import { customTheme } from '../../../stories/theme-config';
|
|
7
|
-
|
|
8
|
-
const THEME_MAP = {
|
|
9
|
-
default: defaultTheme,
|
|
10
|
-
custom: customTheme,
|
|
11
|
-
};
|
|
5
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
12
6
|
|
|
13
7
|
describe( 'Sparkline', () => {
|
|
14
8
|
const defaultData = [ 10, 25, 15, 30, 22, 35 ];
|
|
15
9
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return render(
|
|
20
|
-
<GlobalChartsProvider theme={ theme }>
|
|
10
|
+
const renderResponsive = ( props = {} ) =>
|
|
11
|
+
render(
|
|
12
|
+
<GlobalChartsProvider>
|
|
21
13
|
<Sparkline data={ defaultData } { ...props } />
|
|
22
14
|
</GlobalChartsProvider>
|
|
23
15
|
);
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const renderUnwrappedWithTheme = ( props = {}, themeName = 'default' ) => {
|
|
27
|
-
const theme = THEME_MAP[ themeName ];
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
const renderUnwrapped = ( props = {} ) =>
|
|
18
|
+
render(
|
|
19
|
+
<GlobalChartsProvider>
|
|
31
20
|
<SparklineUnresponsive data={ defaultData } { ...props } />
|
|
32
21
|
</GlobalChartsProvider>
|
|
33
22
|
);
|
|
34
|
-
};
|
|
35
23
|
|
|
36
24
|
describe( 'Basic Rendering', () => {
|
|
37
25
|
test( 'renders sparkline with valid data', () => {
|
|
38
|
-
|
|
26
|
+
renderUnwrapped();
|
|
39
27
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
40
28
|
} );
|
|
41
29
|
|
|
42
30
|
test( 'applies custom className', () => {
|
|
43
|
-
|
|
31
|
+
renderUnwrapped( { className: 'custom-class' } );
|
|
44
32
|
const sparkline = screen.getByTestId( 'sparkline' );
|
|
45
33
|
expect( sparkline ).toHaveClass( 'custom-class' );
|
|
46
34
|
} );
|
|
47
35
|
|
|
48
36
|
test( 'renders responsive variant', () => {
|
|
49
|
-
|
|
37
|
+
renderResponsive();
|
|
50
38
|
// Responsive variant wraps in a div, so sparkline should exist
|
|
51
39
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
52
40
|
} );
|
|
@@ -54,12 +42,12 @@ describe( 'Sparkline', () => {
|
|
|
54
42
|
|
|
55
43
|
describe( 'Edge Cases', () => {
|
|
56
44
|
test( 'handles empty data array', () => {
|
|
57
|
-
|
|
45
|
+
renderUnwrapped( { data: [] } );
|
|
58
46
|
expect( screen.getByTestId( 'sparkline-empty' ) ).toBeInTheDocument();
|
|
59
47
|
} );
|
|
60
48
|
|
|
61
49
|
test( 'handles single data point', () => {
|
|
62
|
-
|
|
50
|
+
renderUnwrapped( { data: [ 42 ] } );
|
|
63
51
|
const singlePoint = screen.getByTestId( 'sparkline-single-point' );
|
|
64
52
|
expect( singlePoint ).toBeInTheDocument();
|
|
65
53
|
// Should render a circle
|
|
@@ -68,51 +56,51 @@ describe( 'Sparkline', () => {
|
|
|
68
56
|
} );
|
|
69
57
|
|
|
70
58
|
test( 'handles two data points', () => {
|
|
71
|
-
|
|
59
|
+
renderUnwrapped( { data: [ 10, 20 ] } );
|
|
72
60
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
73
61
|
} );
|
|
74
62
|
|
|
75
63
|
test( 'handles negative values', () => {
|
|
76
|
-
|
|
64
|
+
renderUnwrapped( { data: [ -10, -5, 0, 5, 10 ] } );
|
|
77
65
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
78
66
|
} );
|
|
79
67
|
|
|
80
68
|
test( 'handles flat line (all same values)', () => {
|
|
81
|
-
|
|
69
|
+
renderUnwrapped( { data: [ 50, 50, 50, 50 ] } );
|
|
82
70
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
83
71
|
} );
|
|
84
72
|
} );
|
|
85
73
|
|
|
86
74
|
describe( 'Styling Props', () => {
|
|
87
75
|
test( 'applies custom color', () => {
|
|
88
|
-
const { container } =
|
|
76
|
+
const { container } = renderUnwrapped( { color: '#ff0000' } );
|
|
89
77
|
// Check that SVG contains a path with the custom color
|
|
90
78
|
const path = container.querySelector( 'path[stroke="#ff0000"]' );
|
|
91
79
|
expect( path ).toBeInTheDocument();
|
|
92
80
|
} );
|
|
93
81
|
|
|
94
82
|
test( 'applies custom stroke width', () => {
|
|
95
|
-
const { container } =
|
|
83
|
+
const { container } = renderUnwrapped( { strokeWidth: 4 } );
|
|
96
84
|
const path = container.querySelector( 'path[stroke-width="4"]' );
|
|
97
85
|
expect( path ).toBeInTheDocument();
|
|
98
86
|
} );
|
|
99
87
|
|
|
100
88
|
test( 'renders with gradient fill by default', () => {
|
|
101
|
-
const { container } =
|
|
89
|
+
const { container } = renderUnwrapped();
|
|
102
90
|
// Check for LinearGradient in SVG
|
|
103
91
|
const gradient = container.querySelector( 'linearGradient' );
|
|
104
92
|
expect( gradient ).toBeInTheDocument();
|
|
105
93
|
} );
|
|
106
94
|
|
|
107
95
|
test( 'disables gradient fill when withGradientFill is false', () => {
|
|
108
|
-
const { container } =
|
|
96
|
+
const { container } = renderUnwrapped( { withGradientFill: false } );
|
|
109
97
|
// Should not have gradient
|
|
110
98
|
const gradient = container.querySelector( 'linearGradient' );
|
|
111
99
|
expect( gradient ).not.toBeInTheDocument();
|
|
112
100
|
} );
|
|
113
101
|
|
|
114
102
|
test( 'applies custom gradient config', () => {
|
|
115
|
-
const { container } =
|
|
103
|
+
const { container } = renderUnwrapped( {
|
|
116
104
|
gradient: {
|
|
117
105
|
from: '#00ff00',
|
|
118
106
|
to: '#0000ff',
|
|
@@ -130,43 +118,28 @@ describe( 'Sparkline', () => {
|
|
|
130
118
|
|
|
131
119
|
describe( 'Dimensions', () => {
|
|
132
120
|
test( 'applies default dimensions', () => {
|
|
133
|
-
const { container } =
|
|
121
|
+
const { container } = renderUnwrapped();
|
|
134
122
|
const svg = container.querySelector( 'svg' );
|
|
135
123
|
expect( svg ).toHaveAttribute( 'width', '100' );
|
|
136
124
|
expect( svg ).toHaveAttribute( 'height', '40' );
|
|
137
125
|
} );
|
|
138
126
|
|
|
139
127
|
test( 'applies custom dimensions', () => {
|
|
140
|
-
const { container } =
|
|
128
|
+
const { container } = renderUnwrapped( { width: 200, height: 80 } );
|
|
141
129
|
const svg = container.querySelector( 'svg' );
|
|
142
130
|
expect( svg ).toHaveAttribute( 'width', '200' );
|
|
143
131
|
expect( svg ).toHaveAttribute( 'height', '80' );
|
|
144
132
|
} );
|
|
145
133
|
|
|
146
134
|
test( 'applies custom margin', () => {
|
|
147
|
-
|
|
135
|
+
renderUnwrapped( { margin: { top: 10, right: 10, bottom: 10, left: 10 } } );
|
|
148
136
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
149
137
|
} );
|
|
150
138
|
} );
|
|
151
139
|
|
|
152
|
-
describe( '
|
|
153
|
-
test( '
|
|
154
|
-
const { container } =
|
|
155
|
-
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
156
|
-
// Should render with theme color if no color prop provided
|
|
157
|
-
const path = container.querySelector( 'path[stroke]' );
|
|
158
|
-
expect( path ).toBeInTheDocument();
|
|
159
|
-
} );
|
|
160
|
-
|
|
161
|
-
test( 'uses custom theme colors', () => {
|
|
162
|
-
const { container } = renderUnwrappedWithTheme( {}, 'custom' );
|
|
163
|
-
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
164
|
-
const path = container.querySelector( 'path[stroke]' );
|
|
165
|
-
expect( path ).toBeInTheDocument();
|
|
166
|
-
} );
|
|
167
|
-
|
|
168
|
-
test( 'color prop overrides theme color', () => {
|
|
169
|
-
const { container } = renderUnwrappedWithTheme( { color: '#custom' }, 'default' );
|
|
140
|
+
describe( 'Color', () => {
|
|
141
|
+
test( 'color prop overrides the palette default', () => {
|
|
142
|
+
const { container } = renderUnwrapped( { color: '#custom' } );
|
|
170
143
|
const path = container.querySelector( 'path[stroke="#custom"]' );
|
|
171
144
|
expect( path ).toBeInTheDocument();
|
|
172
145
|
} );
|
|
@@ -175,17 +148,17 @@ describe( 'Sparkline', () => {
|
|
|
175
148
|
describe( 'Data Transformation', () => {
|
|
176
149
|
test( 'handles large datasets', () => {
|
|
177
150
|
const largeData = Array.from( { length: 100 }, ( _, i ) => i * 2 );
|
|
178
|
-
|
|
151
|
+
renderUnwrapped( { data: largeData } );
|
|
179
152
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
180
153
|
} );
|
|
181
154
|
|
|
182
155
|
test( 'handles small datasets', () => {
|
|
183
|
-
|
|
156
|
+
renderUnwrapped( { data: [ 1, 2 ] } );
|
|
184
157
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
185
158
|
} );
|
|
186
159
|
|
|
187
160
|
test( 'handles data with large range', () => {
|
|
188
|
-
|
|
161
|
+
renderUnwrapped( { data: [ 1, 1000, 50, 500 ] } );
|
|
189
162
|
expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
|
|
190
163
|
} );
|
|
191
164
|
} );
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
}
|
|
4
4
|
|
|
5
5
|
.legend-item {
|
|
6
|
-
font-size: var(--wpds-typography-font-size-md
|
|
6
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
7
7
|
|
|
8
8
|
&--interactive {
|
|
9
9
|
cursor: pointer;
|
|
10
10
|
user-select: none;
|
|
11
11
|
transition:
|
|
12
|
-
opacity var(--wpds-motion-duration-md
|
|
13
|
-
var(--wpds-motion-easing-subtle
|
|
12
|
+
opacity var(--wpds-motion-duration-md)
|
|
13
|
+
var(--wpds-motion-easing-subtle);
|
|
14
14
|
|
|
15
15
|
&:hover {
|
|
16
16
|
opacity: 0.8;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
&:focus {
|
|
20
20
|
outline: 2px solid currentColor;
|
|
21
21
|
outline-offset: 2px;
|
|
22
|
-
border-radius: var(--wpds-border-radius-md
|
|
22
|
+
border-radius: var(--wpds-border-radius-md);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
&:focus:not(:focus-visible) {
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
.legend-item-label {
|
|
40
|
+
color: var(--a8c-charts-color-label);
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
.legend-item-text {
|
|
40
44
|
|
|
41
45
|
&--wrap {
|
|
@@ -54,6 +58,6 @@
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
.legend-item-value {
|
|
57
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
61
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
58
62
|
flex-shrink: 0; // Prevent value from shrinking when text is ellipsized
|
|
59
63
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TooltipContext } from '@visx/xychart';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
import { useChartScopeElement } from '../../providers/chart-scope';
|
|
6
|
-
import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
|
|
4
|
+
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
5
|
+
import { useChartScopeElement, useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
7
6
|
import { resolveCssVariable } from '../../utils';
|
|
7
|
+
import { XyChartTooltip } from './xy-chart-tooltip';
|
|
8
8
|
import type { SeriesData, DataPointDate } from '../../types';
|
|
9
9
|
import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
|
|
10
10
|
import type { ReactNode } from 'react';
|
|
@@ -54,15 +54,16 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
54
54
|
} ) => {
|
|
55
55
|
const tooltipContext = useContext( TooltipContext );
|
|
56
56
|
const scopeElement = useChartScopeElement();
|
|
57
|
-
const gridStroke = useGlobalChartsTheme().gridStyles?.stroke;
|
|
58
57
|
|
|
59
|
-
// The
|
|
58
|
+
// The stroke is read at the scope element, which a consumer can set outside the chart's own ancestors; see TOKENS.md#the-svg-bridge.
|
|
60
59
|
const crosshairStroke = useMemo( () => {
|
|
61
|
-
const stroke =
|
|
60
|
+
const stroke = resolveCssVariable( CATALOG_POINTERS.grid, scopeElement );
|
|
62
61
|
|
|
63
62
|
// Passing `stroke: undefined` would erase the crosshair: it overrides visx's own value, and SVG's initial `stroke` is `none`.
|
|
64
63
|
return stroke ? { stroke } : undefined;
|
|
65
|
-
}, [
|
|
64
|
+
}, [ scopeElement ] );
|
|
65
|
+
|
|
66
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
66
67
|
|
|
67
68
|
const tooltipData = useMemo( () => {
|
|
68
69
|
if ( mode !== 'individual' ) return [];
|
|
@@ -156,7 +157,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
156
157
|
tabIndex={ -1 }
|
|
157
158
|
role="tooltip"
|
|
158
159
|
aria-atomic="true"
|
|
159
|
-
className={ clsx(
|
|
160
|
+
className={ clsx( standaloneScopeClass, keyboardFocusedClassName ) }
|
|
160
161
|
data-testid={ `chart-tooltip-${ selectedIndex }` }
|
|
161
162
|
key={ `chart-tooltip-${ selectedIndex }` }
|
|
162
163
|
>
|
|
@@ -166,15 +167,15 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
return (
|
|
169
|
-
<div className={
|
|
170
|
+
<div className={ standaloneScopeClass } role="tooltip" aria-live="polite">
|
|
170
171
|
{ tooltipContent }
|
|
171
172
|
</div>
|
|
172
173
|
);
|
|
173
174
|
};
|
|
174
|
-
}, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName ] );
|
|
175
|
+
}, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName, standaloneScopeClass ] );
|
|
175
176
|
|
|
176
177
|
return (
|
|
177
|
-
<
|
|
178
|
+
<XyChartTooltip
|
|
178
179
|
{ ...props }
|
|
179
180
|
verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
|
|
180
181
|
horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
|
|
@@ -199,6 +200,7 @@ interface UseKeyboardNavigationProps {
|
|
|
199
200
|
* keyboard selection the way it treats a click.
|
|
200
201
|
*/
|
|
201
202
|
onActivate?: ( index: number ) => void;
|
|
203
|
+
preventTooltipScroll?: boolean;
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
export const useKeyboardNavigation = ( {
|
|
@@ -209,15 +211,20 @@ export const useKeyboardNavigation = ( {
|
|
|
209
211
|
chartRef,
|
|
210
212
|
totalPoints,
|
|
211
213
|
onActivate,
|
|
214
|
+
preventTooltipScroll = false,
|
|
212
215
|
}: UseKeyboardNavigationProps ) => {
|
|
213
216
|
// Focus the tooltip as soon as it is rendered
|
|
214
217
|
const tooltipRef = useCallback(
|
|
215
218
|
( element: HTMLDivElement | null ) => {
|
|
216
219
|
if ( element && selectedIndex !== undefined ) {
|
|
217
|
-
|
|
220
|
+
if ( preventTooltipScroll ) {
|
|
221
|
+
element.focus( { preventScroll: true } );
|
|
222
|
+
} else {
|
|
223
|
+
element.focus();
|
|
224
|
+
}
|
|
218
225
|
}
|
|
219
226
|
},
|
|
220
|
-
[ selectedIndex ]
|
|
227
|
+
[ preventTooltipScroll, selectedIndex ]
|
|
221
228
|
);
|
|
222
229
|
|
|
223
230
|
// On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
|
|
@@ -279,6 +286,3 @@ export const useKeyboardNavigation = ( {
|
|
|
279
286
|
onChartKeyDown,
|
|
280
287
|
};
|
|
281
288
|
};
|
|
282
|
-
|
|
283
|
-
// Re-export the base Tooltip for backwards compatibility
|
|
284
|
-
export { Tooltip };
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
.tooltip {
|
|
2
|
-
padding: var(--wpds-dimension-padding-sm
|
|
2
|
+
padding: var(--wpds-dimension-padding-sm);
|
|
3
3
|
// No WPDS token fits a translucent dark tooltip surface:
|
|
4
4
|
// bg-interactive-neutral-strong is opaque, and there is no
|
|
5
5
|
// white-on-dark content foreground token. The role carries the
|
|
6
6
|
// hardcoded translucent value as its fallback.
|
|
7
7
|
background-color: var(--a8c-charts-color-tooltip-surface, rgba(0, 0, 0, 0.85));
|
|
8
8
|
color: #fff;
|
|
9
|
-
border-radius: var(--wpds-border-radius-md
|
|
10
|
-
font-size: var(--wpds-typography-font-size-md
|
|
9
|
+
border-radius: var(--wpds-border-radius-md);
|
|
10
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
11
11
|
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);
|
|
12
12
|
position: absolute;
|
|
13
13
|
pointer-events: none;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Tooltip, defaultStyles } from '@visx/tooltip';
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import type { TooltipPlacement } from '../../../visx/types';
|
|
4
|
+
import type { TooltipProps } from '@visx/tooltip';
|
|
5
|
+
|
|
6
|
+
type Bounds = { left: number; top: number; right: number; bottom: number };
|
|
7
|
+
type Size = { width: number; height: number };
|
|
8
|
+
|
|
9
|
+
export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPositionStyle' > & {
|
|
10
|
+
/** Anchor, in the coordinates of the positioned wrapper the box renders into. */
|
|
11
|
+
left?: number;
|
|
12
|
+
top?: number;
|
|
13
|
+
placement?: TooltipPlacement;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_OFFSET = 10;
|
|
17
|
+
const POINTER_HEIGHT = 6;
|
|
18
|
+
|
|
19
|
+
const clamp = ( position: number, min: number, max: number, size: number ) =>
|
|
20
|
+
Math.min( Math.max( position, min ), Math.max( min, max - size ) );
|
|
21
|
+
|
|
22
|
+
const isClipping = ( element: Element ) => {
|
|
23
|
+
const { overflow, overflowX, overflowY } = getComputedStyle( element );
|
|
24
|
+
return [ overflow, overflowX, overflowY ].some( value => value && value !== 'visible' );
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The first ancestor that cuts its overflow off. The box may leave the chart
|
|
28
|
+
// wrapper but never this element, which is what a body-level portal used to
|
|
29
|
+
// guarantee by never being inside one.
|
|
30
|
+
const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
31
|
+
let element = wrapper.parentElement;
|
|
32
|
+
while ( element && element !== document.body ) {
|
|
33
|
+
if ( isClipping( element ) ) {
|
|
34
|
+
return element;
|
|
35
|
+
}
|
|
36
|
+
element = element.parentElement;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the box goes, in wrapper coordinates. Automatic placement flips to
|
|
43
|
+
* the side that clips less, then clamps inside `bounds`; below-axis placement
|
|
44
|
+
* is centered on the anchor and clamped horizontally.
|
|
45
|
+
*
|
|
46
|
+
* The flip measures against the visible part of the wrapper, its intersection
|
|
47
|
+
* with `bounds`. Only the clamp is bound by the page: a box that fits inside the
|
|
48
|
+
* chart on one side stays there, so it does not reach past the chart into
|
|
49
|
+
* siblings that paint over it.
|
|
50
|
+
*
|
|
51
|
+
* @param params - Anchor, offsets, the box size and the rects to keep inside.
|
|
52
|
+
* @param params.left - Anchor x, in wrapper coordinates.
|
|
53
|
+
* @param params.top - Anchor y, in wrapper coordinates.
|
|
54
|
+
* @param params.offsetLeft - Gap between the anchor and the box, horizontally.
|
|
55
|
+
* @param params.offsetTop - Gap between the anchor and the box, vertically.
|
|
56
|
+
* @param params.box - Rendered size of the box.
|
|
57
|
+
* @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
|
|
58
|
+
* @param params.placement - Fixed below-axis placement or automatic flipping.
|
|
59
|
+
* @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
|
|
60
|
+
* @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
|
|
61
|
+
*/
|
|
62
|
+
export const getBoundedPosition = ( {
|
|
63
|
+
left,
|
|
64
|
+
top,
|
|
65
|
+
offsetLeft,
|
|
66
|
+
offsetTop,
|
|
67
|
+
box,
|
|
68
|
+
wrapper,
|
|
69
|
+
bounds,
|
|
70
|
+
placement = 'auto',
|
|
71
|
+
}: {
|
|
72
|
+
left: number;
|
|
73
|
+
top: number;
|
|
74
|
+
offsetLeft: number;
|
|
75
|
+
offsetTop: number;
|
|
76
|
+
box: Size;
|
|
77
|
+
wrapper: Bounds;
|
|
78
|
+
bounds: Bounds;
|
|
79
|
+
placement?: BoundedTooltipProps[ 'placement' ];
|
|
80
|
+
} ) => {
|
|
81
|
+
if ( placement === 'below-axis' ) {
|
|
82
|
+
return {
|
|
83
|
+
x: Math.round( clamp( left - box.width / 2, bounds.left, bounds.right, box.width ) ),
|
|
84
|
+
y: top + POINTER_HEIGHT,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The part of the wrapper that is not cut off: the flip keeps the box inside it.
|
|
89
|
+
const fit = {
|
|
90
|
+
left: Math.max( wrapper.left, bounds.left ),
|
|
91
|
+
top: Math.max( wrapper.top, bounds.top ),
|
|
92
|
+
right: Math.min( wrapper.right, bounds.right ),
|
|
93
|
+
bottom: Math.min( wrapper.bottom, bounds.bottom ),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const rightX = left + offsetLeft;
|
|
97
|
+
const leftX = left - offsetLeft - box.width;
|
|
98
|
+
const rightOverflow = rightX + box.width - fit.right;
|
|
99
|
+
const leftOverflow = fit.left - leftX;
|
|
100
|
+
let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
|
|
101
|
+
|
|
102
|
+
const downY = top + offsetTop;
|
|
103
|
+
const upY = top - offsetTop - box.height;
|
|
104
|
+
const downOverflow = downY + box.height - fit.bottom;
|
|
105
|
+
const upOverflow = fit.top - upY;
|
|
106
|
+
let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
|
|
107
|
+
|
|
108
|
+
x = clamp( x, bounds.left, bounds.right, box.width );
|
|
109
|
+
y = clamp( y, bounds.top, bounds.bottom, box.height );
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
x: Math.round( x ),
|
|
113
|
+
y: Math.round( y ),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* visx's `Tooltip`, positioned like its `TooltipWithBounds` but kept inside the
|
|
119
|
+
* nearest clipping ancestor — or the viewport when there is none — rather than
|
|
120
|
+
* inside its own parent. Rendered in-tree, a tooltip's parent is the chart
|
|
121
|
+
* wrapper, which is often narrower than the box; measuring against the parent
|
|
122
|
+
* alone would let the box spill into an `overflow: hidden` card and be cut off.
|
|
123
|
+
*
|
|
124
|
+
* Re-measures on every render, so a box whose content changes width between
|
|
125
|
+
* two hovers is placed for its current size. See `getBoundedPosition` for the
|
|
126
|
+
* below-axis exception.
|
|
127
|
+
*
|
|
128
|
+
* @param props - visx `Tooltip` props.
|
|
129
|
+
* @param props.left - Anchor x, in wrapper coordinates.
|
|
130
|
+
* @param props.top - Anchor y, in wrapper coordinates.
|
|
131
|
+
* @param props.offsetLeft - Gap between the anchor and the box, horizontally.
|
|
132
|
+
* @param props.offsetTop - Gap between the anchor and the box, vertically.
|
|
133
|
+
* @param props.style - Box styles; visx's defaults unless `unstyled`.
|
|
134
|
+
* @param props.unstyled - Skip `style` and leave the box bare.
|
|
135
|
+
* @param props.children - Box content.
|
|
136
|
+
* @param props.placement - Fixed below-axis placement or automatic flipping.
|
|
137
|
+
* @return The tooltip box.
|
|
138
|
+
*/
|
|
139
|
+
export const BoundedTooltip = ( {
|
|
140
|
+
left = 0,
|
|
141
|
+
top = 0,
|
|
142
|
+
offsetLeft = DEFAULT_OFFSET,
|
|
143
|
+
offsetTop = DEFAULT_OFFSET,
|
|
144
|
+
style = defaultStyles,
|
|
145
|
+
unstyled = false,
|
|
146
|
+
children,
|
|
147
|
+
placement = 'auto',
|
|
148
|
+
...rest
|
|
149
|
+
}: BoundedTooltipProps ) => {
|
|
150
|
+
const nodeRef = useRef< HTMLDivElement >( null );
|
|
151
|
+
const clipRef = useRef< { wrapper: Element; clip: Element | null } | null >( null );
|
|
152
|
+
const [ position, setPosition ] = useState< { x: number; y: number } | null >( null );
|
|
153
|
+
|
|
154
|
+
useLayoutEffect( () => {
|
|
155
|
+
const node = nodeRef.current;
|
|
156
|
+
const wrapper = node?.parentElement;
|
|
157
|
+
if ( ! node || ! wrapper ) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if ( clipRef.current?.wrapper !== wrapper ) {
|
|
161
|
+
clipRef.current = { wrapper, clip: findClippingAncestor( wrapper ) };
|
|
162
|
+
}
|
|
163
|
+
const own = node.getBoundingClientRect();
|
|
164
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
165
|
+
const clipRect = clipRef.current.clip?.getBoundingClientRect() ?? {
|
|
166
|
+
left: 0,
|
|
167
|
+
top: 0,
|
|
168
|
+
right: window.innerWidth,
|
|
169
|
+
bottom: window.innerHeight,
|
|
170
|
+
};
|
|
171
|
+
const next = getBoundedPosition( {
|
|
172
|
+
left,
|
|
173
|
+
top,
|
|
174
|
+
offsetLeft,
|
|
175
|
+
offsetTop,
|
|
176
|
+
placement,
|
|
177
|
+
box: { width: own.width, height: own.height },
|
|
178
|
+
wrapper: { left: 0, top: 0, right: wrapperRect.width, bottom: wrapperRect.height },
|
|
179
|
+
bounds: {
|
|
180
|
+
left: clipRect.left - wrapperRect.left,
|
|
181
|
+
top: clipRect.top - wrapperRect.top,
|
|
182
|
+
right: clipRect.right - wrapperRect.left,
|
|
183
|
+
bottom: clipRect.bottom - wrapperRect.top,
|
|
184
|
+
},
|
|
185
|
+
} );
|
|
186
|
+
setPosition( current =>
|
|
187
|
+
current && current.x === next.x && current.y === next.y ? current : next
|
|
188
|
+
);
|
|
189
|
+
// `children` re-runs the measurement when the content, and so the box size, changes.
|
|
190
|
+
}, [ left, top, offsetLeft, offsetTop, children, placement ] );
|
|
191
|
+
|
|
192
|
+
const x = position?.x ?? left + offsetLeft;
|
|
193
|
+
const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<Tooltip
|
|
197
|
+
ref={ nodeRef }
|
|
198
|
+
data-testid="bounded-tooltip"
|
|
199
|
+
style={ {
|
|
200
|
+
position: 'absolute',
|
|
201
|
+
left: 0,
|
|
202
|
+
top: 0,
|
|
203
|
+
transform: `translate(${ x }px, ${ y }px)`,
|
|
204
|
+
...( ! unstyled && style ),
|
|
205
|
+
} }
|
|
206
|
+
{ ...rest }
|
|
207
|
+
>
|
|
208
|
+
{ placement === 'below-axis' && (
|
|
209
|
+
<span
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
data-testid="tooltip-axis-pointer"
|
|
212
|
+
style={ {
|
|
213
|
+
position: 'absolute',
|
|
214
|
+
left: left - x - POINTER_HEIGHT,
|
|
215
|
+
top: -POINTER_HEIGHT,
|
|
216
|
+
width: POINTER_HEIGHT * 2,
|
|
217
|
+
height: POINTER_HEIGHT,
|
|
218
|
+
background: 'inherit',
|
|
219
|
+
clipPath: 'polygon(50% 0, 100% 100%, 0 100%)',
|
|
220
|
+
pointerEvents: 'none',
|
|
221
|
+
} }
|
|
222
|
+
/>
|
|
223
|
+
) }
|
|
224
|
+
{ children }
|
|
225
|
+
</Tooltip>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
@@ -6,7 +6,7 @@ import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
|
|
8
8
|
// A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
|
|
9
|
-
const renderChart = ( scope?: HTMLElement ) => {
|
|
9
|
+
const renderChart = ( scope?: HTMLElement, tooltipPlacement?: 'auto' | 'below-axis' ) => {
|
|
10
10
|
const chart = (
|
|
11
11
|
<LineChartUnresponsive
|
|
12
12
|
width={ 500 }
|
|
@@ -22,6 +22,7 @@ const renderChart = ( scope?: HTMLElement ) => {
|
|
|
22
22
|
},
|
|
23
23
|
] }
|
|
24
24
|
withTooltips
|
|
25
|
+
tooltipPlacement={ tooltipPlacement }
|
|
25
26
|
withTooltipCrosshairs={ { showVertical: true } }
|
|
26
27
|
withGradientFill={ false }
|
|
27
28
|
/>
|
|
@@ -42,12 +43,11 @@ const openTooltip = async () => {
|
|
|
42
43
|
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
43
44
|
await user.keyboard( '{ArrowRight}' );
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
return document.querySelector( '.visx-crosshair-vertical line' );
|
|
46
|
+
return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
describe( 'AccessibleTooltip', () => {
|
|
50
|
-
// The
|
|
50
|
+
// The stroke is read at the scope element, not inherited through the DOM; see TOKENS.md#the-svg-bridge.
|
|
51
51
|
it( 'reads the grid role from the scope element', async () => {
|
|
52
52
|
const scope = document.createElement( 'div' );
|
|
53
53
|
scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
|
|
@@ -60,6 +60,32 @@ describe( 'AccessibleTooltip', () => {
|
|
|
60
60
|
document.body.removeChild( scope );
|
|
61
61
|
} );
|
|
62
62
|
|
|
63
|
+
it( 'keeps the existing focus scrolling for automatic tooltips', async () => {
|
|
64
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
65
|
+
try {
|
|
66
|
+
renderChart();
|
|
67
|
+
await openTooltip();
|
|
68
|
+
|
|
69
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
70
|
+
expect( focus ).toHaveBeenLastCalledWith();
|
|
71
|
+
} finally {
|
|
72
|
+
focus.mockRestore();
|
|
73
|
+
}
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'focuses below-axis keyboard tooltips without scrolling their ancestors', async () => {
|
|
77
|
+
const focus = jest.spyOn( HTMLElement.prototype, 'focus' );
|
|
78
|
+
try {
|
|
79
|
+
renderChart( undefined, 'below-axis' );
|
|
80
|
+
await openTooltip();
|
|
81
|
+
|
|
82
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
|
|
83
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
84
|
+
} finally {
|
|
85
|
+
focus.mockRestore();
|
|
86
|
+
}
|
|
87
|
+
} );
|
|
88
|
+
|
|
63
89
|
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
64
90
|
renderChart();
|
|
65
91
|
|