@automattic/charts 3.2.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. 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, defaultTheme } from '../../../providers';
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 renderWithTheme = ( props = {}, themeName = 'default' ) => {
17
- const theme = THEME_MAP[ themeName ];
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
- return render(
30
- <GlobalChartsProvider theme={ theme }>
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
- renderUnwrappedWithTheme();
26
+ renderUnwrapped();
39
27
  expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
40
28
  } );
41
29
 
42
30
  test( 'applies custom className', () => {
43
- renderUnwrappedWithTheme( { className: 'custom-class' } );
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
- renderWithTheme();
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
- renderUnwrappedWithTheme( { data: [] } );
45
+ renderUnwrapped( { data: [] } );
58
46
  expect( screen.getByTestId( 'sparkline-empty' ) ).toBeInTheDocument();
59
47
  } );
60
48
 
61
49
  test( 'handles single data point', () => {
62
- renderUnwrappedWithTheme( { data: [ 42 ] } );
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
- renderUnwrappedWithTheme( { data: [ 10, 20 ] } );
59
+ renderUnwrapped( { data: [ 10, 20 ] } );
72
60
  expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
73
61
  } );
74
62
 
75
63
  test( 'handles negative values', () => {
76
- renderUnwrappedWithTheme( { data: [ -10, -5, 0, 5, 10 ] } );
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
- renderUnwrappedWithTheme( { data: [ 50, 50, 50, 50 ] } );
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 } = renderUnwrappedWithTheme( { color: '#ff0000' } );
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 } = renderUnwrappedWithTheme( { strokeWidth: 4 } );
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 } = renderUnwrappedWithTheme();
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 } = renderUnwrappedWithTheme( { withGradientFill: false } );
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 } = renderUnwrappedWithTheme( {
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 } = renderUnwrappedWithTheme();
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 } = renderUnwrappedWithTheme( { width: 200, height: 80 } );
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
- renderUnwrappedWithTheme( { margin: { top: 10, right: 10, bottom: 10, left: 10 } } );
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( 'Theme Integration', () => {
153
- test( 'uses default theme colors', () => {
154
- const { container } = renderUnwrappedWithTheme( {}, 'default' );
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
- renderUnwrappedWithTheme( { data: largeData } );
151
+ renderUnwrapped( { data: largeData } );
179
152
  expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
180
153
  } );
181
154
 
182
155
  test( 'handles small datasets', () => {
183
- renderUnwrappedWithTheme( { data: [ 1, 2 ] } );
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
- renderUnwrappedWithTheme( { data: [ 1, 1000, 50, 500 ] } );
161
+ renderUnwrapped( { data: [ 1, 1000, 50, 500 ] } );
189
162
  expect( screen.getByTestId( 'sparkline' ) ).toBeInTheDocument();
190
163
  } );
191
164
  } );
@@ -6,7 +6,7 @@ export type GradientConfig = {
6
6
  */
7
7
  from?: string;
8
8
  /**
9
- * End color for gradient (defaults to theme backgroundColor)
9
+ * End color for gradient (defaults to `--a8c-charts-color-background`)
10
10
  */
11
11
  to?: string;
12
12
  /**
@@ -3,14 +3,14 @@
3
3
  }
4
4
 
5
5
  .legend-item {
6
- font-size: var(--wpds-typography-font-size-md, 13px);
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, 200ms)
13
- var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
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, 4px);
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, 600);
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 { Tooltip, TooltipContext } from '@visx/xychart';
1
+ import { TooltipContext } from '@visx/xychart';
2
2
  import clsx from 'clsx';
3
3
  import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
- import { useGlobalChartsTheme } from '../../providers';
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 crosshair is painted in a portal outside the scope, so it needs a resolved color; see TOKENS.md#the-svg-bridge.
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 = gridStroke ? resolveCssVariable( gridStroke, scopeElement ) : null;
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
- }, [ gridStroke, scopeElement ] );
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( CHART_SCOPE_CLASS, keyboardFocusedClassName ) }
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={ CHART_SCOPE_CLASS } role="tooltip" aria-live="polite">
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
- <Tooltip
178
+ <XyChartTooltip
178
179
  { ...props }
179
180
  verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
180
181
  horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
@@ -279,6 +280,3 @@ export const useKeyboardNavigation = ( {
279
280
  onChartKeyDown,
280
281
  };
281
282
  };
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, 8px);
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, 4px);
10
- font-size: var(--wpds-typography-font-size-md, 13px);
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,172 @@
1
+ import { Tooltip, defaultStyles } from '@visx/tooltip';
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
+ import type { TooltipProps } from '@visx/tooltip';
4
+
5
+ type Bounds = { left: number; top: number; right: number; bottom: number };
6
+ type Size = { width: number; height: number };
7
+
8
+ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPositionStyle' > & {
9
+ /** Anchor, in the coordinates of the positioned wrapper the box renders into. */
10
+ left?: number;
11
+ top?: number;
12
+ };
13
+
14
+ const DEFAULT_OFFSET = 10;
15
+
16
+ const isClipping = ( element: Element ) => {
17
+ const { overflow, overflowX, overflowY } = getComputedStyle( element );
18
+ return [ overflow, overflowX, overflowY ].some( value => value && value !== 'visible' );
19
+ };
20
+
21
+ // The first ancestor that cuts its overflow off. The box may leave the chart
22
+ // wrapper but never this element, which is what a body-level portal used to
23
+ // guarantee by never being inside one.
24
+ const findClippingAncestor = ( wrapper: Element ): Element | null => {
25
+ let element = wrapper.parentElement;
26
+ while ( element && element !== document.body ) {
27
+ if ( isClipping( element ) ) {
28
+ return element;
29
+ }
30
+ element = element.parentElement;
31
+ }
32
+ return null;
33
+ };
34
+
35
+ /**
36
+ * Where the box goes, in wrapper coordinates. Below and to the right of the
37
+ * anchor; flipped to the other side when that side clips less, as visx's
38
+ * `TooltipWithBounds` decides it; then clamped so the box stays inside
39
+ * `bounds` whenever it fits at all.
40
+ *
41
+ * @param params - Anchor, offsets, the box size and the bounds to keep inside.
42
+ * @param params.left - Anchor x, in wrapper coordinates.
43
+ * @param params.top - Anchor y, in wrapper coordinates.
44
+ * @param params.offsetLeft - Gap between the anchor and the box, horizontally.
45
+ * @param params.offsetTop - Gap between the anchor and the box, vertically.
46
+ * @param params.box - Rendered size of the box.
47
+ * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
48
+ * @return The box's top-left corner, rounded to whole pixels.
49
+ */
50
+ export const getBoundedPosition = ( {
51
+ left,
52
+ top,
53
+ offsetLeft,
54
+ offsetTop,
55
+ box,
56
+ bounds,
57
+ }: {
58
+ left: number;
59
+ top: number;
60
+ offsetLeft: number;
61
+ offsetTop: number;
62
+ box: Size;
63
+ bounds: Bounds;
64
+ } ) => {
65
+ const rightX = left + offsetLeft;
66
+ const leftX = left - offsetLeft - box.width;
67
+ const rightOverflow = rightX + box.width - bounds.right;
68
+ const leftOverflow = bounds.left - leftX;
69
+ let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
70
+
71
+ const downY = top + offsetTop;
72
+ const upY = top - offsetTop - box.height;
73
+ const downOverflow = downY + box.height - bounds.bottom;
74
+ const upOverflow = bounds.top - upY;
75
+ let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
76
+
77
+ x = Math.min( Math.max( x, bounds.left ), Math.max( bounds.left, bounds.right - box.width ) );
78
+ y = Math.min( Math.max( y, bounds.top ), Math.max( bounds.top, bounds.bottom - box.height ) );
79
+
80
+ return { x: Math.round( x ), y: Math.round( y ) };
81
+ };
82
+
83
+ /**
84
+ * visx's `Tooltip`, positioned like its `TooltipWithBounds` but kept inside the
85
+ * nearest clipping ancestor — or the viewport when there is none — rather than
86
+ * inside its own parent. Rendered in-tree, a tooltip's parent is the chart
87
+ * wrapper, which is often narrower than the box; measuring against the parent
88
+ * alone would let the box spill into an `overflow: hidden` card and be cut off.
89
+ *
90
+ * Re-measures on every render, so a box whose content changes width between
91
+ * two hovers is placed for its current size.
92
+ *
93
+ * @param props - visx `Tooltip` props.
94
+ * @param props.left - Anchor x, in wrapper coordinates.
95
+ * @param props.top - Anchor y, in wrapper coordinates.
96
+ * @param props.offsetLeft - Gap between the anchor and the box, horizontally.
97
+ * @param props.offsetTop - Gap between the anchor and the box, vertically.
98
+ * @param props.style - Box styles; visx's defaults unless `unstyled`.
99
+ * @param props.unstyled - Skip `style` and leave the box bare.
100
+ * @param props.children - Box content.
101
+ * @return The tooltip box.
102
+ */
103
+ export const BoundedTooltip = ( {
104
+ left = 0,
105
+ top = 0,
106
+ offsetLeft = DEFAULT_OFFSET,
107
+ offsetTop = DEFAULT_OFFSET,
108
+ style = defaultStyles,
109
+ unstyled = false,
110
+ children,
111
+ ...rest
112
+ }: BoundedTooltipProps ) => {
113
+ const nodeRef = useRef< HTMLDivElement >( null );
114
+ const clipRef = useRef< { wrapper: Element; clip: Element | null } | null >( null );
115
+ const [ position, setPosition ] = useState< { x: number; y: number } | null >( null );
116
+
117
+ useLayoutEffect( () => {
118
+ const node = nodeRef.current;
119
+ const wrapper = node?.parentElement;
120
+ if ( ! node || ! wrapper ) {
121
+ return;
122
+ }
123
+ if ( clipRef.current?.wrapper !== wrapper ) {
124
+ clipRef.current = { wrapper, clip: findClippingAncestor( wrapper ) };
125
+ }
126
+ const own = node.getBoundingClientRect();
127
+ const wrapperRect = wrapper.getBoundingClientRect();
128
+ const clipRect = clipRef.current.clip?.getBoundingClientRect() ?? {
129
+ left: 0,
130
+ top: 0,
131
+ right: window.innerWidth,
132
+ bottom: window.innerHeight,
133
+ };
134
+ const next = getBoundedPosition( {
135
+ left,
136
+ top,
137
+ offsetLeft,
138
+ offsetTop,
139
+ box: { width: own.width, height: own.height },
140
+ bounds: {
141
+ left: clipRect.left - wrapperRect.left,
142
+ top: clipRect.top - wrapperRect.top,
143
+ right: clipRect.right - wrapperRect.left,
144
+ bottom: clipRect.bottom - wrapperRect.top,
145
+ },
146
+ } );
147
+ setPosition( current =>
148
+ current && current.x === next.x && current.y === next.y ? current : next
149
+ );
150
+ // `children` re-runs the measurement when the content, and so the box size, changes.
151
+ }, [ left, top, offsetLeft, offsetTop, children ] );
152
+
153
+ const x = position?.x ?? left + offsetLeft;
154
+ const y = position?.y ?? top + offsetTop;
155
+
156
+ return (
157
+ <Tooltip
158
+ ref={ nodeRef }
159
+ data-testid="bounded-tooltip"
160
+ style={ {
161
+ position: 'absolute',
162
+ left: 0,
163
+ top: 0,
164
+ transform: `translate(${ x }px, ${ y }px)`,
165
+ ...( ! unstyled && style ),
166
+ } }
167
+ { ...rest }
168
+ >
169
+ { children }
170
+ </Tooltip>
171
+ );
172
+ };
@@ -42,12 +42,11 @@ const openTooltip = async () => {
42
42
  screen.getByRole( 'grid', { name: /line chart/i } ).focus();
43
43
  await user.keyboard( '{ArrowRight}' );
44
44
 
45
- // eslint-disable-next-line testing-library/no-node-access -- visx owns the crosshair and hardcodes its class name, so there is no attribute to reach it by.
46
- return document.querySelector( '.visx-crosshair-vertical line' );
45
+ return screen.getByTestId( 'xy-chart-tooltip-crosshair-vertical' );
47
46
  };
48
47
 
49
48
  describe( 'AccessibleTooltip', () => {
50
- // The crosshair is painted in a portal outside the scope element; see TOKENS.md#the-svg-bridge.
49
+ // The stroke is read at the scope element, not inherited through the DOM; see TOKENS.md#the-svg-bridge.
51
50
  it( 'reads the grid role from the scope element', async () => {
52
51
  const scope = document.createElement( 'div' );
53
52
  scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
@@ -0,0 +1,129 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { BoundedTooltip, getBoundedPosition } from '../private/bounded-tooltip';
3
+
4
+ const BOX = { width: 208, height: 36 };
5
+ const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
6
+
7
+ describe( 'getBoundedPosition', () => {
8
+ test( 'sits below and to the right of the anchor when that fits', () => {
9
+ expect(
10
+ getBoundedPosition( {
11
+ left: 40,
12
+ top: 20,
13
+ ...OFFSETS,
14
+ box: BOX,
15
+ bounds: { left: 0, top: 0, right: 600, bottom: 300 },
16
+ } )
17
+ ).toEqual( { x: 50, y: 30 } );
18
+ } );
19
+
20
+ test( 'flips to the other side when the near side clips more', () => {
21
+ expect(
22
+ getBoundedPosition( {
23
+ left: 500,
24
+ top: 280,
25
+ ...OFFSETS,
26
+ box: BOX,
27
+ bounds: { left: 0, top: 0, right: 600, bottom: 300 },
28
+ } )
29
+ ).toEqual( { x: 282, y: 234 } );
30
+ } );
31
+
32
+ test( 'pins the box to the edge when neither side fits, instead of spilling', () => {
33
+ // A 208px box mid-way across a 346px plot: neither side fits, so the box
34
+ // keeps the near side and parks at the right edge instead of spilling.
35
+ expect(
36
+ getBoundedPosition( {
37
+ left: 173,
38
+ top: 50,
39
+ ...OFFSETS,
40
+ box: BOX,
41
+ bounds: { left: 0, top: 0, right: 346, bottom: 200 },
42
+ } )
43
+ ).toEqual( { x: 138, y: 60 } );
44
+ } );
45
+
46
+ test( 'may leave the wrapper as long as it stays inside the clipping ancestor', () => {
47
+ // The wrapper starts 40px inside its clipping card, so the box can reach
48
+ // 40px past the wrapper's left edge, and stops there.
49
+ expect(
50
+ getBoundedPosition( {
51
+ left: 173,
52
+ top: 50,
53
+ ...OFFSETS,
54
+ box: BOX,
55
+ bounds: { left: -40, top: -60, right: 360, bottom: 200 },
56
+ } )
57
+ ).toEqual( { x: -40, y: 60 } );
58
+ } );
59
+ } );
60
+
61
+ const rect = ( left: number, top: number, width: number, height: number ) =>
62
+ ( {
63
+ left,
64
+ top,
65
+ width,
66
+ height,
67
+ right: left + width,
68
+ bottom: top + height,
69
+ x: left,
70
+ y: top,
71
+ toJSON: () => ( {} ),
72
+ } ) as DOMRect;
73
+
74
+ describe( 'BoundedTooltip', () => {
75
+ afterEach( () => {
76
+ jest.restoreAllMocks();
77
+ } );
78
+
79
+ test( 'keeps the box inside an overflow-hidden ancestor that is wider than the wrapper', () => {
80
+ // Card at x=400 (400 wide, overflow hidden); plot wrapper at x=440, 346 wide.
81
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
82
+ this: Element
83
+ ) {
84
+ if ( this.classList.contains( 'visx-tooltip' ) ) {
85
+ return rect( 0, 0, 208, 36 );
86
+ }
87
+ if ( this.getAttribute( 'data-testid' ) === 'card' ) {
88
+ return rect( 400, 100, 400, 400 );
89
+ }
90
+ return rect( 440, 160, 346, 200 );
91
+ } );
92
+
93
+ render(
94
+ <div data-testid="card" style={ { overflow: 'hidden' } }>
95
+ <div style={ { position: 'relative' } }>
96
+ <BoundedTooltip left={ 173 } top={ 50 } data-testid="box">
97
+ content
98
+ </BoundedTooltip>
99
+ </div>
100
+ </div>
101
+ );
102
+
103
+ // Neither side of the anchor fits inside the card, so the box stops at
104
+ // the card's left edge, 40px outside the wrapper.
105
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(-40px, 60px)' } );
106
+ } );
107
+
108
+ test( 'falls back to the viewport when nothing clips', () => {
109
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
110
+ this: Element
111
+ ) {
112
+ return this.classList.contains( 'visx-tooltip' )
113
+ ? rect( 0, 0, 208, 36 )
114
+ : rect( 900, 0, 346, 200 );
115
+ } );
116
+
117
+ render(
118
+ <div style={ { position: 'relative' } }>
119
+ <BoundedTooltip left={ 173 } top={ 50 } data-testid="box">
120
+ content
121
+ </BoundedTooltip>
122
+ </div>
123
+ );
124
+
125
+ // JSDOM's viewport is 1024px wide and the wrapper starts at 900: the box
126
+ // flips left of the anchor and stops at the viewport's right edge.
127
+ expect( screen.getByTestId( 'box' ) ).toHaveStyle( { transform: 'translate(-84px, 60px)' } );
128
+ } );
129
+ } );