@automattic/charts 4.1.0 → 4.2.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 +28 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +1115 -471
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +39 -1
- package/dist/index.d.cts +116 -16
- package/dist/index.d.ts +116 -16
- package/dist/index.js +1115 -473
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/bar-chart/bar-chart.tsx +67 -17
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +2 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +15 -45
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +15 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -2
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +37 -1
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +88 -3
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +3 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
- package/src/charts/line-chart/types.ts +2 -2
- package/src/charts/private/time-axis-options.ts +1 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- package/src/components/legend/private/base-legend.tsx +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +146 -37
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +160 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +29 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -11
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- package/src/providers/chart-context/themes.ts +1 -0
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +36 -2
- package/src/types.ts +6 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
|
@@ -14,6 +14,9 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const DEFAULT_OFFSET = 10;
|
|
17
|
+
// Above anything else layered inside a chart, such as the zoom reset button (2).
|
|
18
|
+
// Chart wrappers isolate their stacking context, so it never reaches page chrome.
|
|
19
|
+
export const TOOLTIP_Z_INDEX = 3;
|
|
17
20
|
const POINTER_HEIGHT = 6;
|
|
18
21
|
|
|
19
22
|
const clamp = ( position: number, min: number, max: number, size: number ) =>
|
|
@@ -41,7 +44,8 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
|
41
44
|
/**
|
|
42
45
|
* Where the box goes, in wrapper coordinates. Automatic placement flips to
|
|
43
46
|
* the side that clips less, then clamps inside `bounds`; below-axis placement
|
|
44
|
-
* is centered on the anchor and clamped horizontally
|
|
47
|
+
* is centered on the anchor and clamped horizontally; beside placement preserves
|
|
48
|
+
* the anchor's y and only flips horizontally before bounds clamping.
|
|
45
49
|
*
|
|
46
50
|
* The flip measures against the visible part of the wrapper, its intersection
|
|
47
51
|
* with `bounds`. Only the clamp is bound by the page: a box that fits inside the
|
|
@@ -55,7 +59,7 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
|
55
59
|
* @param params.offsetTop - Gap between the anchor and the box, vertically.
|
|
56
60
|
* @param params.box - Rendered size of the box.
|
|
57
61
|
* @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
|
|
58
|
-
* @param params.placement -
|
|
62
|
+
* @param params.placement - Below-axis, beside without vertical flipping, or automatic flipping.
|
|
59
63
|
* @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
|
|
60
64
|
* @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
|
|
61
65
|
*/
|
|
@@ -103,7 +107,10 @@ export const getBoundedPosition = ( {
|
|
|
103
107
|
const upY = top - offsetTop - box.height;
|
|
104
108
|
const downOverflow = downY + box.height - fit.bottom;
|
|
105
109
|
const upOverflow = fit.top - upY;
|
|
106
|
-
let y =
|
|
110
|
+
let y = top;
|
|
111
|
+
if ( placement !== 'beside' ) {
|
|
112
|
+
y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
|
|
113
|
+
}
|
|
107
114
|
|
|
108
115
|
x = clamp( x, bounds.left, bounds.right, box.width );
|
|
109
116
|
y = clamp( y, bounds.top, bounds.bottom, box.height );
|
|
@@ -122,8 +129,7 @@ export const getBoundedPosition = ( {
|
|
|
122
129
|
* alone would let the box spill into an `overflow: hidden` card and be cut off.
|
|
123
130
|
*
|
|
124
131
|
* Re-measures on every render, so a box whose content changes width between
|
|
125
|
-
* two hovers is placed for its current size.
|
|
126
|
-
* below-axis exception.
|
|
132
|
+
* two hovers is placed for its current size.
|
|
127
133
|
*
|
|
128
134
|
* @param props - visx `Tooltip` props.
|
|
129
135
|
* @param props.left - Anchor x, in wrapper coordinates.
|
|
@@ -133,7 +139,7 @@ export const getBoundedPosition = ( {
|
|
|
133
139
|
* @param props.style - Box styles; visx's defaults unless `unstyled`.
|
|
134
140
|
* @param props.unstyled - Skip `style` and leave the box bare.
|
|
135
141
|
* @param props.children - Box content.
|
|
136
|
-
* @param props.placement -
|
|
142
|
+
* @param props.placement - Below-axis, beside without vertical flipping, or automatic flipping.
|
|
137
143
|
* @return The tooltip box.
|
|
138
144
|
*/
|
|
139
145
|
export const BoundedTooltip = ( {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
3
4
|
import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
|
|
4
5
|
import { GlobalChartsProvider } from '../../../providers';
|
|
5
6
|
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
7
|
+
import { useKeyboardNavigation } from '../accessible-tooltip';
|
|
6
8
|
import type { ReactNode } from 'react';
|
|
7
9
|
|
|
8
10
|
// 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.
|
|
@@ -86,9 +88,167 @@ describe( 'AccessibleTooltip', () => {
|
|
|
86
88
|
}
|
|
87
89
|
} );
|
|
88
90
|
|
|
91
|
+
it.each( [
|
|
92
|
+
[ 'Tab', '{Tab}' ],
|
|
93
|
+
[ 'Escape', '{Escape}' ],
|
|
94
|
+
] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
renderChart( undefined, 'below-axis' );
|
|
97
|
+
await openTooltip();
|
|
98
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
99
|
+
try {
|
|
100
|
+
await user.keyboard( keys );
|
|
101
|
+
expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
|
|
102
|
+
} finally {
|
|
103
|
+
focus.mockRestore();
|
|
104
|
+
}
|
|
105
|
+
} );
|
|
106
|
+
|
|
107
|
+
it.each( [
|
|
108
|
+
[ 'Tab', '{Tab}' ],
|
|
109
|
+
[ 'Escape', '{Escape}' ],
|
|
110
|
+
] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
|
|
111
|
+
const user = userEvent.setup();
|
|
112
|
+
renderChart();
|
|
113
|
+
await openTooltip();
|
|
114
|
+
const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
|
|
115
|
+
try {
|
|
116
|
+
await user.keyboard( keys );
|
|
117
|
+
expect( focus ).toHaveBeenCalledWith();
|
|
118
|
+
} finally {
|
|
119
|
+
focus.mockRestore();
|
|
120
|
+
}
|
|
121
|
+
} );
|
|
122
|
+
|
|
89
123
|
it( 'falls back to the catalog default when the role is unset', async () => {
|
|
90
124
|
renderChart();
|
|
91
125
|
|
|
92
126
|
await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
|
|
93
127
|
} );
|
|
94
128
|
} );
|
|
129
|
+
|
|
130
|
+
// Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
|
|
131
|
+
const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
|
|
132
|
+
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
133
|
+
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
134
|
+
const chartRef = useRef< HTMLDivElement >( null );
|
|
135
|
+
const { onChartKeyDown } = useKeyboardNavigation( {
|
|
136
|
+
selectedIndex,
|
|
137
|
+
setSelectedIndex,
|
|
138
|
+
isNavigating,
|
|
139
|
+
setIsNavigating,
|
|
140
|
+
chartRef,
|
|
141
|
+
totalPoints,
|
|
142
|
+
} );
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
|
|
147
|
+
<div ref={ chartRef } data-testid="selected-index">
|
|
148
|
+
{ selectedIndex ?? 'none' }
|
|
149
|
+
<button type="button">Inside</button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<button type="button">Outside</button>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe( 'useKeyboardNavigation', () => {
|
|
158
|
+
const navigate = async ( presses: number ) => {
|
|
159
|
+
const user = userEvent.setup();
|
|
160
|
+
const view = render( <NavigationHarness totalPoints={ 6 } /> );
|
|
161
|
+
|
|
162
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
163
|
+
for ( let i = 0; i < presses; i++ ) {
|
|
164
|
+
await user.keyboard( '{ArrowRight}' );
|
|
165
|
+
}
|
|
166
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
|
|
167
|
+
|
|
168
|
+
return { user, view };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
|
|
172
|
+
const { view } = await navigate( 6 );
|
|
173
|
+
|
|
174
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
175
|
+
|
|
176
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
|
|
177
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
// In range rather than past the end: the effect reconciles on any count change, not only an overflow.
|
|
181
|
+
it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
|
|
182
|
+
const { view } = await navigate( 2 );
|
|
183
|
+
|
|
184
|
+
screen.getByRole( 'button', { name: 'Outside' } ).focus();
|
|
185
|
+
view.rerender( <NavigationHarness totalPoints={ 3 } /> );
|
|
186
|
+
|
|
187
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'returns focus to the grid on Escape when there are no points', async () => {
|
|
191
|
+
const user = userEvent.setup();
|
|
192
|
+
render( <NavigationHarness totalPoints={ 0 } /> );
|
|
193
|
+
|
|
194
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
195
|
+
await user.keyboard( '{Escape}' );
|
|
196
|
+
|
|
197
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
198
|
+
} );
|
|
199
|
+
|
|
200
|
+
it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
|
|
201
|
+
const { view } = await navigate( 2 );
|
|
202
|
+
|
|
203
|
+
screen.getByRole( 'button', { name: 'Inside' } ).focus();
|
|
204
|
+
view.rerender( <NavigationHarness totalPoints={ 0 } /> );
|
|
205
|
+
|
|
206
|
+
expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
|
|
207
|
+
expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
|
|
208
|
+
} );
|
|
209
|
+
|
|
210
|
+
// The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
|
|
211
|
+
const recordPageKeyDown = () => {
|
|
212
|
+
const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
|
|
213
|
+
document.addEventListener( 'keydown', onKeyDown );
|
|
214
|
+
return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
it( 'leaves keys it does not handle available to the page', async () => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
220
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
224
|
+
await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
|
|
225
|
+
|
|
226
|
+
expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
|
|
227
|
+
for ( const [ event ] of onKeyDown.mock.calls ) {
|
|
228
|
+
expect( event.defaultPrevented ).toBe( false );
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
stop();
|
|
232
|
+
}
|
|
233
|
+
} );
|
|
234
|
+
|
|
235
|
+
// `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
|
|
236
|
+
it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
render( <NavigationHarness totalPoints={ 6 } /> );
|
|
239
|
+
const { onKeyDown, stop } = recordPageKeyDown();
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
screen.getByRole( 'grid', { name: 'Harness' } ).focus();
|
|
243
|
+
await user.keyboard( '{Escape}' );
|
|
244
|
+
|
|
245
|
+
expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
|
|
246
|
+
|
|
247
|
+
await user.keyboard( '{ArrowRight}{Escape}' );
|
|
248
|
+
|
|
249
|
+
expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
|
|
250
|
+
} finally {
|
|
251
|
+
stop();
|
|
252
|
+
}
|
|
253
|
+
} );
|
|
254
|
+
} );
|
|
@@ -5,6 +5,23 @@ const BOX = { width: 208, height: 36 };
|
|
|
5
5
|
const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
|
|
6
6
|
|
|
7
7
|
describe( 'getBoundedPosition', () => {
|
|
8
|
+
test.each( [
|
|
9
|
+
[ 40, 50 ],
|
|
10
|
+
[ 700, 425 ],
|
|
11
|
+
] )( 'keeps a beside tooltip at its anchor and flips horizontally at %s', ( left, x ) => {
|
|
12
|
+
expect(
|
|
13
|
+
getBoundedPosition( {
|
|
14
|
+
left,
|
|
15
|
+
top: -20,
|
|
16
|
+
...OFFSETS,
|
|
17
|
+
box: { width: 265, height: 382 },
|
|
18
|
+
wrapper: { left: 0, top: 0, right: 800, bottom: 300 },
|
|
19
|
+
bounds: { left: 0, top: -100, right: 800, bottom: 500 },
|
|
20
|
+
placement: 'beside',
|
|
21
|
+
} )
|
|
22
|
+
).toEqual( { x, y: -20 } );
|
|
23
|
+
} );
|
|
24
|
+
|
|
8
25
|
test( 'sits below and to the right of the anchor when that fits', () => {
|
|
9
26
|
expect(
|
|
10
27
|
getBoundedPosition( {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
|
|
2
|
+
import { BarSeries, buildChartTheme, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
|
|
3
3
|
import { useContext, useEffect } from 'react';
|
|
4
4
|
import { XyChartTooltip } from '../xy-chart-tooltip';
|
|
5
5
|
import type { XyChartTooltipProps } from '../../../visx/types';
|
|
6
|
-
import type { EventHandlerParams } from '@visx/xychart';
|
|
6
|
+
import type { EventHandlerParams, XYChartTheme } from '@visx/xychart';
|
|
7
7
|
|
|
8
8
|
type Datum = { x: number; y: number };
|
|
9
9
|
|
|
@@ -76,7 +76,8 @@ const renderChart = (
|
|
|
76
76
|
params: EventHandlerParams< Datum >[] = [
|
|
77
77
|
{ key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
|
|
78
78
|
],
|
|
79
|
-
margin = NO_MARGIN
|
|
79
|
+
margin = NO_MARGIN,
|
|
80
|
+
theme?: XYChartTheme
|
|
80
81
|
) =>
|
|
81
82
|
render(
|
|
82
83
|
<div data-testid="wrapper" style={ { position: 'relative' } }>
|
|
@@ -84,6 +85,7 @@ const renderChart = (
|
|
|
84
85
|
width={ 200 }
|
|
85
86
|
height={ 100 }
|
|
86
87
|
margin={ margin }
|
|
88
|
+
theme={ theme }
|
|
87
89
|
xScale={ { type: 'linear', domain: [ 0, 10 ] } }
|
|
88
90
|
yScale={ { type: 'linear', domain: [ 0, 100 ] } }
|
|
89
91
|
>
|
|
@@ -100,6 +102,23 @@ const renderChart = (
|
|
|
100
102
|
);
|
|
101
103
|
|
|
102
104
|
describe( 'XyChartTooltip', () => {
|
|
105
|
+
test.each( [
|
|
106
|
+
[ 'CanvasText', '0 1px 2px #22222255' ],
|
|
107
|
+
[ '#123456', '0 1px 2px #12345655' ],
|
|
108
|
+
] )( 'renders a valid shadow for htmlLabel color %s', async ( color, boxShadow ) => {
|
|
109
|
+
const theme = buildChartTheme( {
|
|
110
|
+
backgroundColor: '#ffffff',
|
|
111
|
+
colors: [ '#123456' ],
|
|
112
|
+
tickLength: 4,
|
|
113
|
+
gridColor: '#dddddd',
|
|
114
|
+
gridColorDark: '#222222',
|
|
115
|
+
htmlLabel: { color },
|
|
116
|
+
} );
|
|
117
|
+
renderChart( {}, undefined, undefined, theme );
|
|
118
|
+
|
|
119
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { boxShadow } );
|
|
120
|
+
} );
|
|
121
|
+
|
|
103
122
|
test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
|
|
104
123
|
const { container } = renderChart();
|
|
105
124
|
|
|
@@ -120,6 +139,13 @@ describe( 'XyChartTooltip', () => {
|
|
|
120
139
|
} );
|
|
121
140
|
} );
|
|
122
141
|
|
|
142
|
+
test( 'pins beside placement to the explicit SVG top anchor', async () => {
|
|
143
|
+
renderChart( { tooltipPlacement: 'beside', tooltipAnchorTop: 20, snapTooltipToDatumY: true } );
|
|
144
|
+
await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
|
|
145
|
+
transform: 'translate(133px, 20px)',
|
|
146
|
+
} );
|
|
147
|
+
} );
|
|
148
|
+
|
|
123
149
|
test( 'snaps the box to the nearest datum', async () => {
|
|
124
150
|
renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
|
|
125
151
|
|
|
@@ -2,7 +2,8 @@ import { Tooltip, defaultStyles } from '@visx/tooltip';
|
|
|
2
2
|
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
3
3
|
import { useCallback, useContext, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
-
import {
|
|
5
|
+
import { isValidHexColor } from '../../utils';
|
|
6
|
+
import { BoundedTooltip, TOOLTIP_Z_INDEX } from './private/bounded-tooltip';
|
|
6
7
|
import type {
|
|
7
8
|
CrosshairStyle,
|
|
8
9
|
RenderTooltipGlyphProps,
|
|
@@ -35,9 +36,6 @@ const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
|
|
|
35
36
|
};
|
|
36
37
|
const DEFAULT_GLYPH_RADIUS = 4;
|
|
37
38
|
const FALLBACK_COLOR = '#222';
|
|
38
|
-
// Above the chart's own overlays (the zoom reset button sits at 2). The chart
|
|
39
|
-
// wrapper isolates its stacking context, so this never competes with page chrome.
|
|
40
|
-
const DEFAULT_TOOLTIP_Z_INDEX = 3;
|
|
41
39
|
// Portal-era options, accepted for compatibility and dropped before the box
|
|
42
40
|
// renders: it sits in the chart wrapper and moves with it, and nothing
|
|
43
41
|
// measures it any more.
|
|
@@ -109,7 +107,8 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
109
107
|
horizontalCrosshairStyle,
|
|
110
108
|
detectBounds = true,
|
|
111
109
|
tooltipPlacement = 'auto',
|
|
112
|
-
|
|
110
|
+
tooltipAnchorTop,
|
|
111
|
+
zIndex = TOOLTIP_Z_INDEX,
|
|
113
112
|
style,
|
|
114
113
|
...rest
|
|
115
114
|
}: XyChartTooltipContentProps< Datum > ) => {
|
|
@@ -170,7 +169,9 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
170
169
|
const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
|
|
171
170
|
const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
|
|
172
171
|
// visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
|
|
173
|
-
const fallbackColor = showSeriesGlyphs
|
|
172
|
+
const fallbackColor = showSeriesGlyphs
|
|
173
|
+
? labelColor
|
|
174
|
+
: ( theme?.gridStyles?.stroke ?? labelColor );
|
|
174
175
|
let entries: Array< { key: string; datum: Datum; index: number } > = [];
|
|
175
176
|
if ( showSeriesGlyphs ) {
|
|
176
177
|
entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
|
|
@@ -213,14 +214,13 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
213
214
|
const marginTop = margin?.top ?? 0;
|
|
214
215
|
const marginLeft = margin?.left ?? 0;
|
|
215
216
|
|
|
216
|
-
const TooltipComponent =
|
|
217
|
-
detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
|
|
217
|
+
const TooltipComponent = detectBounds || tooltipPlacement !== 'auto' ? BoundedTooltip : Tooltip;
|
|
218
218
|
const boxStyle: CSSProperties = {
|
|
219
219
|
...defaultStyles,
|
|
220
220
|
zIndex,
|
|
221
221
|
backgroundColor: theme?.backgroundColor ?? 'white',
|
|
222
222
|
boxShadow: `0 1px 2px ${
|
|
223
|
-
theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
|
|
223
|
+
isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
|
|
224
224
|
}`,
|
|
225
225
|
...theme?.htmlLabel,
|
|
226
226
|
...style,
|
|
@@ -264,12 +264,12 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
264
264
|
top={
|
|
265
265
|
tooltipPlacement === 'below-axis'
|
|
266
266
|
? marginTop + innerHeight + ( margin?.bottom ?? 0 )
|
|
267
|
-
: tooltipTop
|
|
267
|
+
: ( tooltipAnchorTop ?? tooltipTop )
|
|
268
268
|
}
|
|
269
269
|
style={ boxStyle }
|
|
270
270
|
applyPositionStyle
|
|
271
271
|
{ ...tooltipProps }
|
|
272
|
-
{ ...( tooltipPlacement
|
|
272
|
+
{ ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
|
|
273
273
|
>
|
|
274
274
|
{ tooltipContent }
|
|
275
275
|
</TooltipComponent>,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderHook, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
2
|
import { useElementSize } from '../use-element-size';
|
|
3
3
|
|
|
4
4
|
// Mock ResizeObserver
|
|
@@ -66,7 +66,7 @@ describe( 'useElementSize', () => {
|
|
|
66
66
|
const [ refCallback ] = result.current;
|
|
67
67
|
|
|
68
68
|
// Attach the element
|
|
69
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
69
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
70
70
|
|
|
71
71
|
await waitFor( () => {
|
|
72
72
|
expect( result.current[ 1 ] ).toBe( 300 );
|
|
@@ -84,7 +84,7 @@ describe( 'useElementSize', () => {
|
|
|
84
84
|
const { result } = renderHook( () => useElementSize() );
|
|
85
85
|
const [ refCallback ] = result.current;
|
|
86
86
|
|
|
87
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
87
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
88
88
|
|
|
89
89
|
await waitFor( () => {
|
|
90
90
|
expect( result.current[ 1 ] ).toBe( 0 );
|
|
@@ -100,7 +100,7 @@ describe( 'useElementSize', () => {
|
|
|
100
100
|
const { result } = renderHook( () => useElementSize() );
|
|
101
101
|
const [ refCallback ] = result.current;
|
|
102
102
|
|
|
103
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
103
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
104
104
|
|
|
105
105
|
await waitFor( () => {
|
|
106
106
|
expect( result.current[ 1 ] ).toBe( 0 );
|
|
@@ -129,10 +129,10 @@ describe( 'useElementSize', () => {
|
|
|
129
129
|
const [ refCallback ] = result.current;
|
|
130
130
|
|
|
131
131
|
// Attach first element
|
|
132
|
-
refCallback( mockElement1 as unknown as HTMLDivElement );
|
|
132
|
+
act( () => refCallback( mockElement1 as unknown as HTMLDivElement ) );
|
|
133
133
|
|
|
134
134
|
// Attach second element
|
|
135
|
-
refCallback( mockElement2 as unknown as HTMLDivElement );
|
|
135
|
+
act( () => refCallback( mockElement2 as unknown as HTMLDivElement ) );
|
|
136
136
|
|
|
137
137
|
expect( disconnectSpy ).toHaveBeenCalled();
|
|
138
138
|
} );
|
|
@@ -155,10 +155,10 @@ describe( 'useElementSize', () => {
|
|
|
155
155
|
const [ refCallback ] = result.current;
|
|
156
156
|
|
|
157
157
|
// Attach element
|
|
158
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
158
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
159
159
|
|
|
160
160
|
// Remove element
|
|
161
|
-
refCallback( null as unknown as HTMLDivElement );
|
|
161
|
+
act( () => refCallback( null as unknown as HTMLDivElement ) );
|
|
162
162
|
|
|
163
163
|
expect( disconnectSpy ).toHaveBeenCalled();
|
|
164
164
|
} );
|
|
@@ -180,7 +180,7 @@ describe( 'useElementSize', () => {
|
|
|
180
180
|
const { result } = renderHook( () => useElementSize() );
|
|
181
181
|
const [ refCallback ] = result.current;
|
|
182
182
|
|
|
183
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
183
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
184
184
|
|
|
185
185
|
expect( globalThis.ResizeObserver ).toHaveBeenCalled();
|
|
186
186
|
expect( observeSpy ).toHaveBeenCalledWith( mockElement );
|
|
@@ -207,7 +207,7 @@ describe( 'useElementSize', () => {
|
|
|
207
207
|
const { result } = renderHook( () => useElementSize() );
|
|
208
208
|
const [ refCallback ] = result.current;
|
|
209
209
|
|
|
210
|
-
refCallback( mockSpanElement as unknown as HTMLDivElement );
|
|
210
|
+
act( () => refCallback( mockSpanElement as unknown as HTMLDivElement ) );
|
|
211
211
|
|
|
212
212
|
await waitFor( () => {
|
|
213
213
|
expect( result.current[ 1 ] ).toBe( 75 );
|
|
@@ -236,7 +236,7 @@ describe( 'useElementSize', () => {
|
|
|
236
236
|
const { result } = renderHook( () => useElementSize() );
|
|
237
237
|
const [ refCallback ] = result.current;
|
|
238
238
|
|
|
239
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
239
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
240
240
|
|
|
241
241
|
await waitFor( () => {
|
|
242
242
|
expect( result.current[ 1 ] ).toBe( 100 );
|
|
@@ -244,7 +244,7 @@ describe( 'useElementSize', () => {
|
|
|
244
244
|
} );
|
|
245
245
|
|
|
246
246
|
// Simulate resize
|
|
247
|
-
resizeCallback();
|
|
247
|
+
act( () => resizeCallback() );
|
|
248
248
|
|
|
249
249
|
await waitFor( () => {
|
|
250
250
|
expect( result.current[ 1 ] ).toBe( 200 );
|
|
@@ -269,7 +269,7 @@ describe( 'useElementSize', () => {
|
|
|
269
269
|
const { result, unmount } = renderHook( () => useElementSize() );
|
|
270
270
|
const [ refCallback ] = result.current;
|
|
271
271
|
|
|
272
|
-
refCallback( mockElement as unknown as HTMLDivElement );
|
|
272
|
+
act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
|
|
273
273
|
unmount();
|
|
274
274
|
|
|
275
275
|
// The observer should be cleaned up when the hook unmounts
|
|
@@ -23,7 +23,7 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
23
23
|
// One resolver for all three, so they share a single getComputedStyle call.
|
|
24
24
|
const resolve = createCssVariableResolver( scopeElement );
|
|
25
25
|
const resolveColor = ( value?: string ): string | undefined =>
|
|
26
|
-
value ? resolve( value ) ?? value : value;
|
|
26
|
+
value ? ( resolve( value ) ?? value ) : value;
|
|
27
27
|
|
|
28
28
|
const seriesColors: string[] = JSON.parse( seriesColorKey );
|
|
29
29
|
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,9 @@ export {
|
|
|
10
10
|
HeatmapChart,
|
|
11
11
|
HeatmapChartUnresponsive,
|
|
12
12
|
buildCalendarHeatmapData,
|
|
13
|
+
buildMonthCalendarHeatmapData,
|
|
13
14
|
useCalendarHeatmapData,
|
|
15
|
+
useMonthCalendarHeatmapData,
|
|
14
16
|
} from './charts/heatmap-chart';
|
|
15
17
|
export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
|
|
16
18
|
export { LineChart, LineChartUnresponsive } from './charts/line-chart';
|
|
@@ -95,7 +97,7 @@ export type { LegendProps, BaseLegendProps, ChartLegendOptions } from './compone
|
|
|
95
97
|
|
|
96
98
|
// Previously available via '@automattic/charts/bar-chart', '@automattic/charts/line-chart', etc.
|
|
97
99
|
export type { AreaChartProps } from './charts/area-chart';
|
|
98
|
-
export type { BarChartProps } from './charts/bar-chart';
|
|
100
|
+
export type { BarChartProps, BandHighlightSelection } from './charts/bar-chart';
|
|
99
101
|
export type {
|
|
100
102
|
BarListChartProps,
|
|
101
103
|
RenderLabelProps,
|
|
@@ -112,10 +114,14 @@ export type {
|
|
|
112
114
|
export type {
|
|
113
115
|
HeatmapChartProps,
|
|
114
116
|
HeatmapColumn,
|
|
117
|
+
HeatmapColumnGroup,
|
|
115
118
|
HeatmapCell,
|
|
116
119
|
HeatmapTooltipData,
|
|
117
120
|
CalendarHeatmapOptions,
|
|
118
121
|
CalendarHeatmapResult,
|
|
122
|
+
MonthCalendarHeatmapOptions,
|
|
123
|
+
MonthCalendarHeatmapRange,
|
|
124
|
+
MonthCalendarHeatmapResult,
|
|
119
125
|
} from './charts/heatmap-chart';
|
|
120
126
|
export type { LeaderboardChartProps } from './charts/leaderboard-chart';
|
|
121
127
|
export type {
|
|
@@ -42,9 +42,7 @@
|
|
|
42
42
|
// The literal is load-bearing: a `fill` reading a fallback-less var() on an
|
|
43
43
|
// undeclared property drops the declaration and paints black instead.
|
|
44
44
|
--a8c-charts-color-label-background: transparent;
|
|
45
|
-
//
|
|
46
|
-
// derived from it, so setting the broad role moves every label at once while
|
|
47
|
-
// setting this one moves only the axis.
|
|
45
|
+
// See TOKENS.md's "Broad roles and narrow ones" for label inheritance.
|
|
48
46
|
--a8c-charts-color-label-axis: var(--a8c-charts-color-label);
|
|
49
47
|
--a8c-charts-color-annotation: var(--wpds-color-foreground-content-neutral);
|
|
50
48
|
--a8c-charts-color-trend-up: var(--wpds-color-foreground-content-success-weak);
|
|
@@ -86,3 +84,13 @@
|
|
|
86
84
|
--a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
|
|
87
85
|
--a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
|
|
88
86
|
}
|
|
87
|
+
|
|
88
|
+
@media (forced-colors: active) {
|
|
89
|
+
|
|
90
|
+
:where(.a8c-charts-scope) {
|
|
91
|
+
--a8c-charts-color-label-axis: CanvasText;
|
|
92
|
+
--a8c-charts-color-grid: GrayText;
|
|
93
|
+
--a8c-charts-color-axis-x: GrayText;
|
|
94
|
+
--a8c-charts-color-tick-x: GrayText;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { compileString } from 'sass-embedded';
|
|
3
4
|
|
|
4
5
|
const stylesheet = readFileSync( join( __dirname, '..', 'chart-scope.scss' ), 'utf8' );
|
|
5
6
|
const tokensDoc = readFileSync( join( __dirname, '..', '..', '..', 'TOKENS.md' ), 'utf8' );
|
|
@@ -19,7 +20,7 @@ type Entry = {
|
|
|
19
20
|
* @return Every declared `--a8c-charts-*` property, keyed by name.
|
|
20
21
|
*/
|
|
21
22
|
function parseStylesheet(): Map< string, Entry > {
|
|
22
|
-
const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.
|
|
23
|
+
const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.indexOf( '}' ) );
|
|
23
24
|
const entries = new Map< string, Entry >();
|
|
24
25
|
|
|
25
26
|
for ( const declaration of body
|
|
@@ -100,7 +101,7 @@ function parseTokensDoc(): Map< string, Entry > {
|
|
|
100
101
|
const token = /(--wp[\w-]+)/.exec( mapping );
|
|
101
102
|
|
|
102
103
|
entries.set( role, {
|
|
103
|
-
reads: unmapped ? null : derives?.[ 1 ] ?? token?.[ 1 ] ?? null,
|
|
104
|
+
reads: unmapped ? null : ( derives?.[ 1 ] ?? token?.[ 1 ] ?? null ),
|
|
104
105
|
fallback: fallback === '—' ? null : normalize( fallback ),
|
|
105
106
|
} );
|
|
106
107
|
}
|
|
@@ -112,6 +113,39 @@ const declared = parseStylesheet();
|
|
|
112
113
|
const documented = parseTokensDoc();
|
|
113
114
|
|
|
114
115
|
describe( 'chart scope catalog', () => {
|
|
116
|
+
it( 'uses system colors for axis text and visible lines only under forced colors', () => {
|
|
117
|
+
const style = document.createElement( 'style' );
|
|
118
|
+
style.textContent = compileString( stylesheet ).css;
|
|
119
|
+
document.head.appendChild( style );
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const mediaRules = Array.from( style.sheet!.cssRules ).filter(
|
|
123
|
+
( rule ): rule is CSSMediaRule => rule.type === CSSRule.MEDIA_RULE
|
|
124
|
+
);
|
|
125
|
+
expect( mediaRules ).toHaveLength( 1 );
|
|
126
|
+
expect( mediaRules[ 0 ].conditionText ).toBe( '(forced-colors: active)' );
|
|
127
|
+
expect( mediaRules[ 0 ].cssRules ).toHaveLength( 1 );
|
|
128
|
+
|
|
129
|
+
const rule = mediaRules[ 0 ].cssRules[ 0 ] as CSSStyleRule;
|
|
130
|
+
expect( rule.selectorText ).toBe( ':where(.a8c-charts-scope)' );
|
|
131
|
+
expect(
|
|
132
|
+
Object.fromEntries(
|
|
133
|
+
Array.from( rule.style ).map( property => [
|
|
134
|
+
property,
|
|
135
|
+
rule.style.getPropertyValue( property ).trim().toLowerCase(),
|
|
136
|
+
] )
|
|
137
|
+
)
|
|
138
|
+
).toEqual( {
|
|
139
|
+
'--a8c-charts-color-label-axis': 'canvastext',
|
|
140
|
+
'--a8c-charts-color-grid': 'graytext',
|
|
141
|
+
'--a8c-charts-color-axis-x': 'graytext',
|
|
142
|
+
'--a8c-charts-color-tick-x': 'graytext',
|
|
143
|
+
} );
|
|
144
|
+
} finally {
|
|
145
|
+
style.remove();
|
|
146
|
+
}
|
|
147
|
+
} );
|
|
148
|
+
|
|
115
149
|
it( 'documents every declared role, and declares every documented one', () => {
|
|
116
150
|
expect( [ ...documented.keys() ].sort() ).toEqual( [ ...declared.keys() ].sort() );
|
|
117
151
|
} );
|