@automattic/charts 4.11.1 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/index.cjs +1412 -787
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -25
- package/dist/index.d.cts +64 -120
- package/dist/index.d.ts +64 -120
- package/dist/index.js +1400 -765
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/area-chart/area-chart.tsx +16 -6
- package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
- package/src/charts/bar-chart/bar-chart.tsx +143 -142
- package/src/charts/bar-chart/index.ts +3 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
- package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
- package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
- package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
- package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
- package/src/charts/bar-chart/private/index.ts +7 -1
- package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +28 -0
- package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
- package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
- package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
- package/src/charts/bar-chart/private/use-bar-stops.ts +87 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -0
- package/src/charts/bar-chart/private/value-domain.ts +0 -10
- package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +181 -19
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
- package/src/charts/bar-chart/types.ts +3 -0
- package/src/charts/bar-chart/use-bar-colors.ts +46 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
- package/src/charts/conversion-funnel-chart/index.ts +2 -0
- package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
- package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
- package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
- package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
- package/src/charts/conversion-funnel-chart/types.ts +22 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
- package/src/charts/line-chart/line-chart.tsx +25 -18
- package/src/charts/line-chart/test/line-chart.test.tsx +240 -1
- package/src/charts/pie-chart/pie-chart.tsx +7 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
- package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
- package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
- package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
- package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
- package/src/components/tooltip/private/at-nearest-date.ts +49 -0
- package/src/components/tooltip/private/keyboard-stops.ts +67 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
- package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
- package/src/index.ts +10 -2
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
- package/src/providers/chart-context/themes.ts +0 -1
- package/src/providers/chart-context/types.ts +2 -0
- package/src/test-utils/selected-bar-ring.ts +14 -0
- package/src/types.ts +14 -5
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const getTargetIndex = (
|
|
4
|
+
key: string,
|
|
5
|
+
index: number,
|
|
6
|
+
count: number,
|
|
7
|
+
isRtl: boolean
|
|
8
|
+
): number | undefined => {
|
|
9
|
+
const nextKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
|
|
10
|
+
const previousKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
|
|
11
|
+
|
|
12
|
+
switch ( key ) {
|
|
13
|
+
case nextKey:
|
|
14
|
+
return Math.min( index + 1, count - 1 );
|
|
15
|
+
case previousKey:
|
|
16
|
+
return Math.max( index - 1, 0 );
|
|
17
|
+
case 'Home':
|
|
18
|
+
return 0;
|
|
19
|
+
case 'End':
|
|
20
|
+
return count - 1;
|
|
21
|
+
default:
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Roving tabindex for the funnel steps: one Tab stop, and arrow keys, Home and End move focus between steps.
|
|
28
|
+
*
|
|
29
|
+
* @param stepIds - Step ids in visual order.
|
|
30
|
+
* @param selectedStepId - The selected step, which holds the Tab stop until any step has taken focus.
|
|
31
|
+
* @return Tab index, element registration, focus and key handlers for each step.
|
|
32
|
+
*/
|
|
33
|
+
export const useRovingStepFocus = ( stepIds: string[], selectedStepId: string | null ) => {
|
|
34
|
+
const [ focusedStepId, setFocusedStepId ] = useState< string | null >( null );
|
|
35
|
+
const stepElements = useRef( new Map< string, HTMLElement >() );
|
|
36
|
+
|
|
37
|
+
const tabStopId =
|
|
38
|
+
[ focusedStepId, selectedStepId ].find( id => id !== null && stepIds.includes( id ) ) ??
|
|
39
|
+
stepIds[ 0 ];
|
|
40
|
+
|
|
41
|
+
const getTabIndex = useCallback(
|
|
42
|
+
( stepId: string ) => ( stepId === tabStopId ? 0 : -1 ),
|
|
43
|
+
[ tabStopId ]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const setStepElement = useCallback( ( stepId: string, element: HTMLElement | null ) => {
|
|
47
|
+
if ( element ) {
|
|
48
|
+
stepElements.current.set( stepId, element );
|
|
49
|
+
} else {
|
|
50
|
+
stepElements.current.delete( stepId );
|
|
51
|
+
}
|
|
52
|
+
}, [] );
|
|
53
|
+
|
|
54
|
+
const handleStepFocus = useCallback( ( stepId: string ) => {
|
|
55
|
+
setFocusedStepId( stepId );
|
|
56
|
+
}, [] );
|
|
57
|
+
|
|
58
|
+
const handleNavigationKey = useCallback(
|
|
59
|
+
( stepId: string, event: React.KeyboardEvent< HTMLElement > ) => {
|
|
60
|
+
if ( event.altKey || event.ctrlKey || event.metaKey ) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isRtl = getComputedStyle( event.currentTarget ).direction === 'rtl';
|
|
65
|
+
const targetIndex = getTargetIndex(
|
|
66
|
+
event.key,
|
|
67
|
+
stepIds.indexOf( stepId ),
|
|
68
|
+
stepIds.length,
|
|
69
|
+
isRtl
|
|
70
|
+
);
|
|
71
|
+
if ( targetIndex === undefined ) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
stepElements.current.get( stepIds[ targetIndex ] )?.focus();
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
[ stepIds ]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return { getTabIndex, setStepElement, handleStepFocus, handleNavigationKey };
|
|
83
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, isInaccessible, render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import ConversionFunnelChart from '../conversion-funnel-chart';
|
|
4
|
-
import type { FunnelStep } from '../types';
|
|
4
|
+
import type { ConversionFunnelChartProps, FunnelStep } from '../types';
|
|
5
5
|
|
|
6
6
|
let mockChartHeight = 0;
|
|
7
7
|
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
@@ -46,6 +46,36 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
|
|
|
46
46
|
return render( component );
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// jsdom's window never has focus, so without this the funnel never sees focus leave it.
|
|
50
|
+
beforeEach( () => {
|
|
51
|
+
jest.spyOn( document, 'hasFocus' ).mockReturnValue( true );
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
afterEach( () => {
|
|
55
|
+
jest.restoreAllMocks();
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
const focus = ( element: HTMLElement ) => act( () => element.focus() );
|
|
59
|
+
|
|
60
|
+
const stepName = ( label: string ) => new RegExp( `^${ label }, ` );
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shows a step's position and id.
|
|
64
|
+
*
|
|
65
|
+
* @param step - The step.
|
|
66
|
+
* @param index - Its position.
|
|
67
|
+
* @return The tooltip content.
|
|
68
|
+
*/
|
|
69
|
+
function getTooltipContent( step: FunnelStep, index: number ) {
|
|
70
|
+
return { value: `#${ index }`, rows: [ { label: 'Id', value: step.id } ] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const getEmptyTooltipContent = () => ( {} );
|
|
74
|
+
|
|
75
|
+
const renderNamedTooltip = ( { step }: { step: FunnelStep } ) => (
|
|
76
|
+
<span>Custom tooltip: { step.label }</span>
|
|
77
|
+
);
|
|
78
|
+
|
|
49
79
|
describe( 'ConversionFunnelChart', () => {
|
|
50
80
|
afterEach( () => {
|
|
51
81
|
mockChartHeight = 0;
|
|
@@ -159,58 +189,36 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
159
189
|
const user = userEvent.setup();
|
|
160
190
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
161
191
|
|
|
162
|
-
const cartBar = screen.getByRole( '
|
|
192
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
163
193
|
await user.click( cartBar );
|
|
164
194
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Rate appears once in header, and in tooltip it's combined with count text
|
|
169
|
-
expect( screen.getByText( '71.1%' ) ).toBeInTheDocument();
|
|
170
|
-
expect( screen.getByText( /71\.1% • .* items/ ) ).toBeInTheDocument();
|
|
171
|
-
} );
|
|
172
|
-
|
|
173
|
-
it( 'handles keyboard navigation with Enter key', async () => {
|
|
174
|
-
const user = userEvent.setup();
|
|
175
|
-
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
176
|
-
|
|
177
|
-
const cartBar = screen.getByRole( 'button', { name: /cart/i } );
|
|
178
|
-
cartBar.focus();
|
|
179
|
-
await user.keyboard( '{Enter}' );
|
|
180
|
-
|
|
181
|
-
// Check that component still works after keyboard interaction
|
|
182
|
-
// Keyboard navigation now shows tooltip, so label appears twice
|
|
183
|
-
expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
|
|
195
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent(
|
|
196
|
+
'Cart7,110 itemsConversion rate71.1%'
|
|
197
|
+
);
|
|
184
198
|
} );
|
|
185
199
|
|
|
186
|
-
it( '
|
|
187
|
-
const user = userEvent.setup();
|
|
200
|
+
it( 'selects a step and shows its tooltip when the step takes focus', () => {
|
|
188
201
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
189
202
|
|
|
190
|
-
|
|
191
|
-
cartBar.focus();
|
|
192
|
-
await user.keyboard( ' ' );
|
|
203
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
193
204
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
expect(
|
|
206
|
+
screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
|
|
207
|
+
).toBeInTheDocument();
|
|
208
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Cart' );
|
|
197
209
|
} );
|
|
198
210
|
|
|
199
|
-
it( '
|
|
211
|
+
it.each( [ '{Enter}', ' ' ] )( 'selects the focused step again with %s', async key => {
|
|
200
212
|
const user = userEvent.setup();
|
|
201
213
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
202
214
|
|
|
203
|
-
|
|
204
|
-
await user.
|
|
205
|
-
|
|
206
|
-
// Check that all bars are still accessible
|
|
207
|
-
const sessionsBar = screen.getByRole( 'button', { name: /sessions/i } );
|
|
208
|
-
const checkoutBar = screen.getByRole( 'button', { name: /checkout/i } );
|
|
209
|
-
const purchaseBar = screen.getByRole( 'button', { name: /purchase/i } );
|
|
215
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
216
|
+
await user.keyboard( `{Escape}${ key }` );
|
|
210
217
|
|
|
211
|
-
expect(
|
|
212
|
-
|
|
213
|
-
|
|
218
|
+
expect(
|
|
219
|
+
screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
|
|
220
|
+
).toBeInTheDocument();
|
|
221
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toBeInTheDocument();
|
|
214
222
|
} );
|
|
215
223
|
|
|
216
224
|
it( 'allows multiple bar interactions', async () => {
|
|
@@ -218,10 +226,10 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
218
226
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
219
227
|
|
|
220
228
|
// Click on different bars in sequence
|
|
221
|
-
const cartBar = screen.getByRole( '
|
|
229
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
222
230
|
await user.click( cartBar );
|
|
223
231
|
|
|
224
|
-
const sessionsBar = screen.getByRole( '
|
|
232
|
+
const sessionsBar = screen.getByRole( 'option', { name: /sessions/i } );
|
|
225
233
|
await user.click( sessionsBar );
|
|
226
234
|
|
|
227
235
|
// Component should still render correctly
|
|
@@ -236,7 +244,7 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
236
244
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
237
245
|
|
|
238
246
|
// First, click on a bar to select it
|
|
239
|
-
const cartBar = screen.getByRole( '
|
|
247
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
240
248
|
await user.click( cartBar );
|
|
241
249
|
|
|
242
250
|
// Verify the bar is selected (tooltip appears, so label appears twice)
|
|
@@ -254,7 +262,7 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
254
262
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
255
263
|
|
|
256
264
|
// First, click on a bar to select it
|
|
257
|
-
const cartBar = screen.getByRole( '
|
|
265
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
258
266
|
await user.click( cartBar );
|
|
259
267
|
|
|
260
268
|
// Verify the bar is selected (tooltip appears, so label appears twice)
|
|
@@ -268,48 +276,250 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
268
276
|
expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 1 );
|
|
269
277
|
} );
|
|
270
278
|
|
|
271
|
-
it( '
|
|
279
|
+
it( 'keeps the step selected when it is clicked again', async () => {
|
|
272
280
|
const user = userEvent.setup();
|
|
273
281
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
274
282
|
|
|
275
|
-
|
|
276
|
-
const cartBar = screen.getByRole( 'button', { name: /cart/i } );
|
|
283
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
277
284
|
await user.click( cartBar );
|
|
278
|
-
|
|
279
|
-
// Verify the bar is selected (tooltip appears, so label appears twice)
|
|
280
|
-
expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 2 );
|
|
281
|
-
|
|
282
|
-
// Click the same bar again to deselect
|
|
283
285
|
await user.click( cartBar );
|
|
284
286
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
+
expect(
|
|
288
|
+
screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
|
|
289
|
+
).toBeInTheDocument();
|
|
287
290
|
} );
|
|
288
291
|
} );
|
|
289
292
|
|
|
290
293
|
describe( 'Accessibility', () => {
|
|
291
|
-
it( '
|
|
294
|
+
it( 'lists the steps as options of a horizontal listbox, named with their rates', () => {
|
|
292
295
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
293
296
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
expect(
|
|
297
|
+
const listbox = screen.getByRole( 'listbox', { name: 'Funnel steps' } );
|
|
298
|
+
expect( listbox ).toHaveAttribute( 'aria-orientation', 'horizontal' );
|
|
299
|
+
expect(
|
|
300
|
+
screen.getAllByRole( 'option' ).map( option => option.getAttribute( 'aria-label' ) )
|
|
301
|
+
).toEqual( [ 'Sessions, 100%', 'Cart, 71.1%', 'Checkout, 52.5%', 'Purchase, 10.3%' ] );
|
|
302
|
+
} );
|
|
297
303
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
304
|
+
it( 'hides the visible step labels and rates, so the listbox holds only its options', () => {
|
|
305
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
306
|
+
|
|
307
|
+
expect( isInaccessible( screen.getByText( 'Cart' ) ) ).toBe( true );
|
|
308
|
+
expect( isInaccessible( screen.getByText( '71.1%' ) ) ).toBe( true );
|
|
303
309
|
} );
|
|
304
310
|
|
|
305
|
-
it( '
|
|
311
|
+
it( 'describes the selected step with its tooltip, leaving out the name', () => {
|
|
312
|
+
renderWithoutTheme(
|
|
313
|
+
<ConversionFunnelChart
|
|
314
|
+
mainRate={ 50 }
|
|
315
|
+
steps={ [ ...mockSteps.slice( 0, 2 ), { id: 'none', label: 'None', rate: 50 } ] }
|
|
316
|
+
/>
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
320
|
+
focus( cart );
|
|
321
|
+
expect( cart ).toHaveAccessibleDescription( '7,110 items Conversion rate 71.1%' );
|
|
322
|
+
|
|
323
|
+
const none = screen.getByRole( 'option', { name: stepName( 'None' ) } );
|
|
324
|
+
focus( none );
|
|
325
|
+
expect( none ).toHaveAccessibleDescription( 'Conversion rate 50%' );
|
|
326
|
+
expect( cart ).not.toHaveAttribute( 'aria-describedby' );
|
|
327
|
+
} );
|
|
328
|
+
|
|
329
|
+
it( 'shows and describes each step with getTooltipContent when given', () => {
|
|
330
|
+
renderWithoutTheme(
|
|
331
|
+
<ConversionFunnelChart { ...defaultProps } getTooltipContent={ getTooltipContent } />
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
335
|
+
focus( cart );
|
|
336
|
+
|
|
337
|
+
expect( cart ).toHaveAccessibleDescription( '#1 Id cart' );
|
|
338
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Cart#1Idcart' );
|
|
339
|
+
} );
|
|
340
|
+
|
|
341
|
+
it( 'leaves a step undescribed when its tooltip content is empty', () => {
|
|
342
|
+
renderWithoutTheme(
|
|
343
|
+
<ConversionFunnelChart { ...defaultProps } getTooltipContent={ getEmptyTooltipContent } />
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
347
|
+
focus( cart );
|
|
348
|
+
|
|
349
|
+
expect( cart ).not.toHaveAttribute( 'aria-describedby' );
|
|
350
|
+
} );
|
|
351
|
+
|
|
352
|
+
it( 'describes the selected step with the whole custom tooltip', () => {
|
|
353
|
+
renderWithoutTheme(
|
|
354
|
+
<ConversionFunnelChart { ...defaultProps } renderTooltip={ renderNamedTooltip } />
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
358
|
+
focus( cart );
|
|
359
|
+
|
|
360
|
+
expect( cart ).toHaveAccessibleDescription( 'Custom tooltip: Cart' );
|
|
361
|
+
} );
|
|
362
|
+
|
|
363
|
+
it( 'hides the tooltip itself from screen readers', () => {
|
|
306
364
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
307
365
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
366
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
367
|
+
|
|
368
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveAttribute( 'aria-hidden', 'true' );
|
|
369
|
+
} );
|
|
370
|
+
|
|
371
|
+
it( 'marks only the selected step as selected', async () => {
|
|
372
|
+
const user = userEvent.setup();
|
|
373
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
374
|
+
|
|
375
|
+
await user.click( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
376
|
+
|
|
377
|
+
expect(
|
|
378
|
+
screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
|
|
379
|
+
).toBeInTheDocument();
|
|
380
|
+
expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 3 );
|
|
381
|
+
} );
|
|
382
|
+
} );
|
|
383
|
+
|
|
384
|
+
describe( 'Keyboard navigation', () => {
|
|
385
|
+
const renderBetweenButtons = ( props: Partial< ConversionFunnelChartProps > = {} ) =>
|
|
386
|
+
renderWithoutTheme(
|
|
387
|
+
<>
|
|
388
|
+
<button>Before</button>
|
|
389
|
+
<ConversionFunnelChart { ...defaultProps } { ...props } />
|
|
390
|
+
<button>After</button>
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
it( 'enters the funnel on the first step and leaves it on the next Tab', async () => {
|
|
395
|
+
const user = userEvent.setup();
|
|
396
|
+
renderBetweenButtons();
|
|
397
|
+
|
|
398
|
+
screen.getByRole( 'button', { name: 'Before' } ).focus();
|
|
399
|
+
await user.tab();
|
|
400
|
+
expect( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) ).toHaveFocus();
|
|
401
|
+
|
|
402
|
+
await user.tab();
|
|
403
|
+
expect( screen.getByRole( 'button', { name: 'After' } ) ).toHaveFocus();
|
|
404
|
+
} );
|
|
405
|
+
|
|
406
|
+
it( 'returns to the last focused step', async () => {
|
|
407
|
+
const user = userEvent.setup();
|
|
408
|
+
renderBetweenButtons();
|
|
409
|
+
|
|
410
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
|
|
411
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
412
|
+
await user.tab();
|
|
413
|
+
await user.tab( { shift: true } );
|
|
414
|
+
|
|
415
|
+
expect( screen.getByRole( 'option', { name: stepName( 'Checkout' ) } ) ).toHaveFocus();
|
|
416
|
+
} );
|
|
417
|
+
|
|
418
|
+
it( 'selects the step on focus after a press that ended off the step', async () => {
|
|
419
|
+
const user = userEvent.setup();
|
|
420
|
+
renderBetweenButtons();
|
|
421
|
+
|
|
422
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
423
|
+
focus( cart );
|
|
424
|
+
await user.pointer( { keys: '[MouseLeft>]', target: cart } );
|
|
425
|
+
await user.pointer( { keys: '[/MouseLeft]', target: document.body } );
|
|
426
|
+
await user.click( screen.getByRole( 'button', { name: 'After' } ) );
|
|
427
|
+
await user.tab( { shift: true } );
|
|
428
|
+
|
|
429
|
+
expect(
|
|
430
|
+
screen.getByRole( 'option', { name: stepName( 'Cart' ), selected: true } )
|
|
431
|
+
).toHaveFocus();
|
|
432
|
+
} );
|
|
433
|
+
|
|
434
|
+
it( 'waits for the click to select a step pressed from another step', async () => {
|
|
435
|
+
const user = userEvent.setup();
|
|
436
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
437
|
+
|
|
438
|
+
await user.click( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
|
|
439
|
+
await user.pointer( {
|
|
440
|
+
keys: '[MouseLeft>]',
|
|
441
|
+
target: screen.getByRole( 'option', { name: stepName( 'Cart' ) } ),
|
|
312
442
|
} );
|
|
443
|
+
|
|
444
|
+
expect(
|
|
445
|
+
screen.getByRole( 'option', { name: stepName( 'Sessions' ), selected: true } )
|
|
446
|
+
).toBeInTheDocument();
|
|
447
|
+
} );
|
|
448
|
+
|
|
449
|
+
it( 'clears the selection when focus leaves the funnel', async () => {
|
|
450
|
+
const user = userEvent.setup();
|
|
451
|
+
renderBetweenButtons();
|
|
452
|
+
|
|
453
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
454
|
+
await user.tab();
|
|
455
|
+
|
|
456
|
+
expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 4 );
|
|
457
|
+
expect( screen.queryByTestId( 'funnel-tooltip' ) ).not.toBeInTheDocument();
|
|
458
|
+
} );
|
|
459
|
+
|
|
460
|
+
it( 'moves the selection and its tooltip with the arrow keys', async () => {
|
|
461
|
+
const user = userEvent.setup();
|
|
462
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
463
|
+
|
|
464
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
465
|
+
await user.keyboard( '{ArrowRight}' );
|
|
466
|
+
|
|
467
|
+
expect(
|
|
468
|
+
screen.getByRole( 'option', { name: stepName( 'Checkout' ), selected: true } )
|
|
469
|
+
).toHaveFocus();
|
|
470
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( 'Checkout' );
|
|
471
|
+
} );
|
|
472
|
+
|
|
473
|
+
it.each( [
|
|
474
|
+
[ 'Sessions', '{End}', 'Purchase' ],
|
|
475
|
+
[ 'Purchase', '{Home}', 'Sessions' ],
|
|
476
|
+
[ 'Purchase', '{ArrowRight}', 'Purchase' ],
|
|
477
|
+
[ 'Sessions', '{ArrowLeft}', 'Sessions' ],
|
|
478
|
+
] )( 'from %s, %s focuses %s', async ( from, keys, to ) => {
|
|
479
|
+
const user = userEvent.setup();
|
|
480
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
481
|
+
|
|
482
|
+
focus( screen.getByRole( 'option', { name: stepName( from ) } ) );
|
|
483
|
+
await user.keyboard( keys );
|
|
484
|
+
|
|
485
|
+
expect( screen.getByRole( 'option', { name: stepName( to ) } ) ).toHaveFocus();
|
|
486
|
+
} );
|
|
487
|
+
|
|
488
|
+
it( 'swaps Left and Right in a right-to-left layout', async () => {
|
|
489
|
+
const user = userEvent.setup();
|
|
490
|
+
renderWithoutTheme(
|
|
491
|
+
<ConversionFunnelChart { ...defaultProps } style={ { direction: 'rtl' } } />
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Sessions' ) } ) );
|
|
495
|
+
await user.keyboard( '{ArrowLeft}' );
|
|
496
|
+
|
|
497
|
+
expect( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) ).toHaveFocus();
|
|
498
|
+
} );
|
|
499
|
+
|
|
500
|
+
it( 'clears the selection on Escape and keeps focus on the step', async () => {
|
|
501
|
+
const user = userEvent.setup();
|
|
502
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
503
|
+
|
|
504
|
+
const cart = screen.getByRole( 'option', { name: stepName( 'Cart' ) } );
|
|
505
|
+
focus( cart );
|
|
506
|
+
await user.keyboard( '{Escape}' );
|
|
507
|
+
|
|
508
|
+
expect( cart ).toHaveFocus();
|
|
509
|
+
expect( screen.getAllByRole( 'option', { selected: false } ) ).toHaveLength( 4 );
|
|
510
|
+
} );
|
|
511
|
+
|
|
512
|
+
it( 'leaves Escape to the page when no step is selected', async () => {
|
|
513
|
+
const user = userEvent.setup();
|
|
514
|
+
const isPrevented = jest.fn( ( event: KeyboardEvent ) => event.defaultPrevented );
|
|
515
|
+
document.addEventListener( 'keydown', isPrevented );
|
|
516
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
517
|
+
|
|
518
|
+
focus( screen.getByRole( 'option', { name: stepName( 'Cart' ) } ) );
|
|
519
|
+
await user.keyboard( '{Escape}{Escape}' );
|
|
520
|
+
document.removeEventListener( 'keydown', isPrevented );
|
|
521
|
+
|
|
522
|
+
expect( isPrevented.mock.results.map( result => result.value ) ).toEqual( [ true, false ] );
|
|
313
523
|
} );
|
|
314
524
|
} );
|
|
315
525
|
|
|
@@ -337,13 +547,10 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
337
547
|
<ConversionFunnelChart mainRate={ 50 } steps={ stepsWithLargeCounts } />
|
|
338
548
|
);
|
|
339
549
|
|
|
340
|
-
const bar = screen.getByRole( '
|
|
550
|
+
const bar = screen.getByRole( 'option', { name: /test/i } );
|
|
341
551
|
await user.click( bar );
|
|
342
552
|
|
|
343
|
-
|
|
344
|
-
// After clicking, there will be multiple 'Test' texts (header + tooltip)
|
|
345
|
-
expect( screen.getAllByText( 'Test' ) ).toHaveLength( 2 );
|
|
346
|
-
expect( screen.getAllByText( '50%' ) ).toHaveLength( 2 );
|
|
553
|
+
expect( screen.getByTestId( 'funnel-tooltip' ) ).toHaveTextContent( '1,234,567 items' );
|
|
347
554
|
} );
|
|
348
555
|
|
|
349
556
|
it( 'handles steps without count in tooltip', async () => {
|
|
@@ -352,7 +559,7 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
352
559
|
|
|
353
560
|
renderWithoutTheme( <ConversionFunnelChart mainRate={ 75 } steps={ stepsWithoutCount } /> );
|
|
354
561
|
|
|
355
|
-
const bar = screen.getByRole( '
|
|
562
|
+
const bar = screen.getByRole( 'option', { name: /test/i } );
|
|
356
563
|
await user.click( bar );
|
|
357
564
|
|
|
358
565
|
// Should show rate in tooltip, but we have multiple 75% (main + step)
|
|
@@ -439,7 +646,7 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
439
646
|
<ConversionFunnelChart { ...defaultProps } renderTooltip={ customRenderTooltip } />
|
|
440
647
|
);
|
|
441
648
|
|
|
442
|
-
const cartBar = screen.getByRole( '
|
|
649
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
443
650
|
await user.click( cartBar );
|
|
444
651
|
|
|
445
652
|
expect( screen.getByTestId( 'custom-tooltip' ) ).toBeInTheDocument();
|
|
@@ -447,27 +654,22 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
447
654
|
expect( customRenderTooltip ).toHaveBeenCalledWith( {
|
|
448
655
|
step: expect.objectContaining( { id: 'cart', label: 'Cart' } ),
|
|
449
656
|
index: 1,
|
|
657
|
+
content: {
|
|
658
|
+
value: '7,110 items',
|
|
659
|
+
rows: [ { label: 'Conversion rate', value: '71.1%' } ],
|
|
660
|
+
},
|
|
450
661
|
top: expect.any( Number ),
|
|
451
662
|
left: expect.any( Number ),
|
|
452
663
|
} );
|
|
453
664
|
} );
|
|
454
665
|
|
|
455
|
-
it( 'exposes the tooltip content with role="tooltip"', async () => {
|
|
456
|
-
const user = userEvent.setup();
|
|
457
|
-
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
458
|
-
|
|
459
|
-
await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
|
|
460
|
-
|
|
461
|
-
expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Cart' );
|
|
462
|
-
} );
|
|
463
|
-
|
|
464
666
|
it( 'merges tooltipStyle onto the tooltip box', async () => {
|
|
465
667
|
const user = userEvent.setup();
|
|
466
668
|
renderWithoutTheme(
|
|
467
669
|
<ConversionFunnelChart { ...defaultProps } tooltipStyle={ { background: 'purple' } } />
|
|
468
670
|
);
|
|
469
671
|
|
|
470
|
-
await user.click( screen.getByRole( '
|
|
672
|
+
await user.click( screen.getByRole( 'option', { name: /cart/i } ) );
|
|
471
673
|
|
|
472
674
|
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'purple' } );
|
|
473
675
|
} );
|
|
@@ -480,11 +682,12 @@ describe( 'ConversionFunnelChart', () => {
|
|
|
480
682
|
<ConversionFunnelChart { ...defaultProps } renderTooltip={ customRenderTooltip } />
|
|
481
683
|
);
|
|
482
684
|
|
|
483
|
-
const cartBar = screen.getByRole( '
|
|
685
|
+
const cartBar = screen.getByRole( 'option', { name: /cart/i } );
|
|
484
686
|
await user.click( cartBar );
|
|
485
687
|
|
|
486
688
|
// Should only have one 'Cart' text (header only, no tooltip)
|
|
487
689
|
expect( screen.getAllByText( 'Cart' ) ).toHaveLength( 1 );
|
|
690
|
+
expect( cartBar ).not.toHaveAttribute( 'aria-describedby' );
|
|
488
691
|
expect( customRenderTooltip ).toHaveBeenCalled();
|
|
489
692
|
} );
|
|
490
693
|
|
|
@@ -536,16 +739,12 @@ const mockRects = () =>
|
|
|
536
739
|
} );
|
|
537
740
|
|
|
538
741
|
describe( 'ConversionFunnelChart tooltip position', () => {
|
|
539
|
-
afterEach( () => {
|
|
540
|
-
jest.restoreAllMocks();
|
|
541
|
-
} );
|
|
542
|
-
|
|
543
742
|
it( 'places the box relative to the chart root, at the click plus the offsets', async () => {
|
|
544
743
|
mockRects();
|
|
545
744
|
const user = userEvent.setup();
|
|
546
745
|
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
547
746
|
|
|
548
|
-
const bar = screen.getAllByRole( '
|
|
747
|
+
const bar = screen.getAllByRole( 'option' )[ 0 ];
|
|
549
748
|
await user.pointer( [
|
|
550
749
|
{ target: bar, coords: { clientX: 180, clientY: 140 } },
|
|
551
750
|
{ keys: '[MouseLeft]', target: bar, coords: { clientX: 180, clientY: 140 } },
|
|
@@ -559,4 +758,25 @@ describe( 'ConversionFunnelChart tooltip position', () => {
|
|
|
559
758
|
transform: 'translate(90px, 90px)',
|
|
560
759
|
} );
|
|
561
760
|
} );
|
|
761
|
+
|
|
762
|
+
it( 'keeps the selection and its place when the window loses focus and gets it back', async () => {
|
|
763
|
+
mockRects();
|
|
764
|
+
const user = userEvent.setup();
|
|
765
|
+
renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
|
|
766
|
+
|
|
767
|
+
const bar = screen.getAllByRole( 'option' )[ 0 ];
|
|
768
|
+
await user.pointer( [
|
|
769
|
+
{ target: bar, coords: { clientX: 180, clientY: 140 } },
|
|
770
|
+
{ keys: '[MouseLeft]', target: bar, coords: { clientX: 180, clientY: 140 } },
|
|
771
|
+
] );
|
|
772
|
+
const hasFocus = jest.spyOn( document, 'hasFocus' ).mockReturnValue( false );
|
|
773
|
+
act( () => bar.blur() );
|
|
774
|
+
hasFocus.mockReturnValue( true );
|
|
775
|
+
focus( bar );
|
|
776
|
+
|
|
777
|
+
expect( bar ).toHaveAttribute( 'aria-selected', 'true' );
|
|
778
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
779
|
+
transform: 'translate(90px, 90px)',
|
|
780
|
+
} );
|
|
781
|
+
} );
|
|
562
782
|
} );
|