@automattic/charts 4.11.1 → 4.12.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 (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.cjs +856 -236
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +19 -13
  5. package/dist/index.d.cts +43 -4
  6. package/dist/index.d.ts +43 -4
  7. package/dist/index.js +855 -237
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/bar-chart/bar-chart.tsx +134 -138
  11. package/src/charts/bar-chart/index.ts +3 -0
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  13. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  15. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  16. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  17. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  18. package/src/charts/bar-chart/private/index.ts +7 -1
  19. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  21. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +29 -0
  22. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  23. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  24. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  25. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  26. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +89 -0
  28. package/src/charts/bar-chart/private/use-overlap-slots.ts +194 -0
  29. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  30. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  31. package/src/charts/bar-chart/test/bar-chart.test.tsx +66 -19
  32. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  33. package/src/charts/bar-chart/types.ts +3 -0
  34. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  35. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  36. package/src/charts/line-chart/line-chart.tsx +22 -17
  37. package/src/charts/line-chart/test/line-chart.test.tsx +165 -1
  38. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  39. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  40. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  41. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  42. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  43. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  44. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  45. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  46. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -2
  47. package/src/index.ts +8 -2
  48. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  49. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  50. package/src/providers/chart-context/themes.ts +0 -1
  51. package/src/providers/chart-context/types.ts +2 -0
  52. package/src/test-utils/selected-bar-ring.ts +14 -0
  53. package/src/types.ts +10 -2
  54. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
@@ -0,0 +1,194 @@
1
+ import { useMemo } from 'react';
2
+ import { COMPARISON_SLOT, PRIMARY_SLOT, getOverlapPlacements } from './overlap-bars';
3
+ import { isBarRendered } from './value-domain';
4
+ import type { SelectedBar } from './selected-bar-ring';
5
+ import type { SlotMember } from './slot-tooltip-members';
6
+ import type { DataPointDate, SeriesData } from '../../../types';
7
+
8
+ type SeriesEntry = { series: SeriesData; index: number };
9
+
10
+ export type OverlapSlots = {
11
+ /** The visible series, back to front. */
12
+ entries: SeriesEntry[];
13
+ keys: string[];
14
+ /** The slots of a category, in the order the band lays them out. */
15
+ slotKeys: string[];
16
+ slotOf: ( key: string ) => string;
17
+ thicknessOf: ( key: string ) => number;
18
+ /** The keyboard stops: every category a slot draws a bar at, category by category. */
19
+ positions: StopPosition[];
20
+ };
21
+
22
+ /** A slot at one category, named by its front-most bar there. */
23
+ export type StopPosition = {
24
+ slotKey: string;
25
+ key: string;
26
+ datum: DataPointDate;
27
+ dataPointIndex: number;
28
+ };
29
+
30
+ export type SelectedSlot = SelectedBar & { index: number; members: SlotMember[] };
31
+
32
+ /**
33
+ * Whether two data points sit at the same category: the same label, or the same date.
34
+ *
35
+ * @param point - A data point.
36
+ * @param category - The data point naming the category.
37
+ * @return Whether `point` is at `category`.
38
+ */
39
+ export function isSameCategory( point: DataPointDate, category: DataPointDate ): boolean {
40
+ return category.label != null
41
+ ? point.label === category.label
42
+ : !! category.date && !! point.date && point.date.getTime() === category.date.getTime();
43
+ }
44
+
45
+ /**
46
+ * Lays out the visible series of an overlap layout in slots and layers.
47
+ *
48
+ * @param enabled - Whether the chart draws an overlap layout.
49
+ * @param seriesWithVisibility - Every series, with its index and whether it is visible.
50
+ * @return The layout, or undefined when the chart is not an overlap layout.
51
+ */
52
+ export function useOverlapSlots(
53
+ enabled: boolean,
54
+ seriesWithVisibility: ( SeriesEntry & { isVisible: boolean } )[]
55
+ ): OverlapSlots | undefined {
56
+ return useMemo( () => {
57
+ if ( ! enabled ) {
58
+ return undefined;
59
+ }
60
+
61
+ const visible = seriesWithVisibility.filter( ( { isVisible } ) => isVisible );
62
+ const placements = getOverlapPlacements( visible.map( ( { series } ) => series ) );
63
+ const placed = visible
64
+ .map( ( { series, index }, position ) => ( {
65
+ entry: { series, index },
66
+ placement: placements[ position ],
67
+ } ) )
68
+ .sort( ( a, b ) => a.placement.layer - b.placement.layer );
69
+ const entries = placed.map( ( { entry } ) => entry );
70
+ const placementByKey = new Map(
71
+ placed.map( ( { entry, placement } ) => [ entry.series.label, placement ] )
72
+ );
73
+ const slotOf = ( key: string ): string => placementByKey.get( key )?.slot ?? PRIMARY_SLOT;
74
+ const thicknessOf = ( key: string ): number => placementByKey.get( key )?.thickness ?? 1;
75
+ const hasPrimaries = seriesWithVisibility.some(
76
+ ( { series } ) => series.options?.type !== 'comparison'
77
+ );
78
+ const showsComparisons = entries.some(
79
+ ( { series } ) => slotOf( series.label ) === COMPARISON_SLOT
80
+ );
81
+ const slotKeys = [
82
+ ...( hasPrimaries ? [ PRIMARY_SLOT ] : [] ),
83
+ ...( showsComparisons ? [ COMPARISON_SLOT ] : [] ),
84
+ ];
85
+
86
+ return {
87
+ entries,
88
+ keys: entries.map( ( { series } ) => series.label ),
89
+ slotKeys,
90
+ slotOf,
91
+ thicknessOf,
92
+ positions: getStopPositions( entries, slotKeys, slotOf ),
93
+ };
94
+ }, [ enabled, seriesWithVisibility ] );
95
+ }
96
+
97
+ /**
98
+ * The category a data point sits at, as a key two points share when `isSameCategory` holds.
99
+ *
100
+ * @param point - A data point.
101
+ * @return The category key.
102
+ */
103
+ function categoryKey( point: DataPointDate ): string {
104
+ return point.label != null ? `label:${ point.label }` : `date:${ point.date?.getTime() }`;
105
+ }
106
+
107
+ /**
108
+ * Lists a stop for every slot at every category that slot draws a bar at, category by category.
109
+ *
110
+ * @param entries - The visible series, back to front.
111
+ * @param slotKeys - The slots, in band order.
112
+ * @param slotOf - The slot a series sits in.
113
+ * @return The positions, in keyboard order.
114
+ */
115
+ function getStopPositions(
116
+ entries: SeriesEntry[],
117
+ slotKeys: string[],
118
+ slotOf: ( key: string ) => string
119
+ ): StopPosition[] {
120
+ const categories = new Map< string, Map< string, StopPosition > >();
121
+
122
+ entries.forEach( ( { series } ) => {
123
+ ( series.data as DataPointDate[] ).forEach( ( datum, dataPointIndex ) => {
124
+ const key = categoryKey( datum );
125
+ // A category with no reading still takes its place in the order, as it keeps its band.
126
+ const slots = categories.get( key ) ?? new Map< string, StopPosition >();
127
+ categories.set( key, slots );
128
+ if ( ! isBarRendered( datum ) ) {
129
+ return;
130
+ }
131
+ const slotKey = slotOf( series.label );
132
+ // Later entries are in front, so the last one at a category names its slot.
133
+ slots.set( slotKey, { slotKey, key: series.label, datum, dataPointIndex } );
134
+ } );
135
+ } );
136
+
137
+ const ordered = [ ...categories.values() ].filter( slots => slots.size > 0 );
138
+ const allDated = ordered.every( slots =>
139
+ [ ...slots.values() ].every( ( { datum } ) => datum.label == null && datum.date )
140
+ );
141
+ if ( allDated ) {
142
+ const time = ( slots: Map< string, StopPosition > ) =>
143
+ [ ...slots.values() ][ 0 ].datum.date?.getTime() ?? 0;
144
+ ordered.sort( ( a, b ) => time( a ) - time( b ) );
145
+ }
146
+
147
+ return ordered.flatMap( slots =>
148
+ slotKeys.flatMap( slotKey => {
149
+ const position = slots.get( slotKey );
150
+ return position ? [ position ] : [];
151
+ } )
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The slot a keyboard index selects, and every bar at its category.
157
+ *
158
+ * @param slots - The overlap layout.
159
+ * @param selectedIndex - The keyboard index into `slots.positions`.
160
+ * @return The selected slot, or undefined when nothing is selected or drawn there.
161
+ */
162
+ export function getSelectedSlot(
163
+ slots: OverlapSlots | undefined,
164
+ selectedIndex: number | undefined
165
+ ): SelectedSlot | undefined {
166
+ const position = selectedIndex === undefined ? undefined : slots?.positions[ selectedIndex ];
167
+
168
+ if ( ! slots || ! position ) {
169
+ return undefined;
170
+ }
171
+
172
+ const { slotKey, key: stopKey, datum: category, dataPointIndex } = position;
173
+ const members = slots.entries.flatMap( ( { series } ) => {
174
+ const datum = ( series.data as DataPointDate[] ).find( point =>
175
+ isSameCategory( point, category )
176
+ );
177
+ return datum ? [ { key: series.label, datum } ] : [];
178
+ } );
179
+ const slotBars = members.filter(
180
+ ( { key, datum } ) => isBarRendered( datum ) && slots.slotOf( key ) === slotKey
181
+ );
182
+
183
+ return {
184
+ data: slotBars.map( ( { datum } ) => datum ),
185
+ slotKey,
186
+ thickness: Math.max( ...slotBars.map( ( { key } ) => slots.thicknessOf( key ) ) ),
187
+ index: dataPointIndex,
188
+ // The stop goes last: the last bar shown becomes the tooltip's nearest datum.
189
+ members: [
190
+ ...members.filter( ( { key } ) => key !== stopKey ),
191
+ ...members.filter( ( { key } ) => key === stopKey ),
192
+ ],
193
+ };
194
+ }
@@ -28,16 +28,6 @@ export const getBarValue = ( point: DataPoint | DataPointDate | EnhancedDataPoin
28
28
  export const isBarRendered = ( point: DataPoint | DataPointDate | EnhancedDataPoint ): boolean =>
29
29
  Number.isFinite( getBarValue( point ) );
30
30
 
31
- /**
32
- * How many bars visx renders for a list of points.
33
- *
34
- * @param points - Data points handed to visx.
35
- * @return The number of points that get a bar.
36
- */
37
- export const countRenderedBars = (
38
- points: ReadonlyArray< DataPoint | DataPointDate | EnhancedDataPoint >
39
- ): number => points.filter( isBarRendered ).length;
40
-
41
31
  /**
42
32
  * An explicit domain for the value scale, or null to let visx fit one to the data.
43
33
  *
@@ -0,0 +1,347 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { GlobalChartsProvider } from '../../../providers';
4
+ import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
5
+ import { selectedBarRingBounds as selectedRing } from '../../../test-utils/selected-bar-ring';
6
+ import { BarChartUnresponsive } from '../bar-chart';
7
+ import { useBarColors, type GetBarColors } from '../use-bar-colors';
8
+ import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
9
+ import type { SeriesData } from '../../../types';
10
+ import type { BarChartProps } from '../types';
11
+
12
+ const mockRefCallback = jest.fn();
13
+ jest.mock( '../../../hooks/use-element-size', () => ( {
14
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
15
+ } ) );
16
+
17
+ const day = ( label: string, value: number ) => ( { label, value } );
18
+
19
+ const DATA: SeriesData[] = [
20
+ { label: 'Views', group: 'views', data: [ day( 'Mon', 100 ), day( 'Tue', 120 ) ] },
21
+ {
22
+ label: 'Previous views',
23
+ group: 'views',
24
+ options: { type: 'comparison' },
25
+ data: [ day( 'Mon', 80 ), day( 'Tue', 90 ) ],
26
+ },
27
+ { label: 'Visitors', group: 'visitors', data: [ day( 'Mon', 40 ), day( 'Tue', 50 ) ] },
28
+ {
29
+ label: 'Previous visitors',
30
+ group: 'visitors',
31
+ options: { type: 'comparison' },
32
+ data: [ day( 'Mon', 30 ), day( 'Tue', 35 ) ],
33
+ },
34
+ ];
35
+
36
+ // A shorter comparison period leaves the last category without a bar there.
37
+ const WITHOUT_LAST_PREVIOUS_VISITORS = DATA.map( series =>
38
+ series.label === 'Previous visitors' ? { ...series, data: series.data.slice( 0, 1 ) } : series
39
+ );
40
+
41
+ type Rect = { x: number; y: number; width: number; height: number };
42
+
43
+ // visx draws the bars and owns the pointer capture rect; neither accepts a test id.
44
+ /* eslint-disable testing-library/no-node-access */
45
+ const barRects = () =>
46
+ Array.from( screen.getByRole( 'application' ).querySelectorAll( '.visx-bar-group rect' ) );
47
+
48
+ const pointer = ( type: string, clientX: number, clientY = 150 ) =>
49
+ fireEvent(
50
+ screen.getByRole( 'application' ).querySelector( 'svg > rect[fill="transparent"]' ),
51
+ new MouseEvent( type, { bubbles: true, clientX, clientY } )
52
+ );
53
+ /* eslint-enable testing-library/no-node-access */
54
+
55
+ const geometry = ( element: Element ): Rect => ( {
56
+ x: Number( element.getAttribute( 'x' ) ),
57
+ y: Number( element.getAttribute( 'y' ) ),
58
+ width: Number( element.getAttribute( 'width' ) ),
59
+ height: Number( element.getAttribute( 'height' ) ),
60
+ } );
61
+
62
+ // Back to front, each series' bars in category order.
63
+ const BAR_ORDER = [ 'Views', 'Previous views', 'Visitors', 'Previous visitors' ];
64
+ const barOf = ( label: string, category = 0 ) =>
65
+ geometry( barRects()[ BAR_ORDER.indexOf( label ) * 2 + category ] );
66
+ const center = ( { x, width }: Rect ) => x + width / 2;
67
+
68
+ let context: GlobalChartsContextValue;
69
+ let getBarColors: GetBarColors;
70
+ const Grab = () => {
71
+ context = useGlobalChartsContext();
72
+ getBarColors = useBarColors();
73
+ return null;
74
+ };
75
+
76
+ const renderOverlap = ( props: Partial< BarChartProps > = {} ) =>
77
+ render(
78
+ <GlobalChartsProvider>
79
+ <Grab />
80
+ <BarChartUnresponsive
81
+ width={ 500 }
82
+ height={ 300 }
83
+ data={ DATA }
84
+ layout="overlap"
85
+ withTooltips
86
+ { ...props }
87
+ />
88
+ </GlobalChartsProvider>
89
+ );
90
+
91
+ describe( 'BarChart overlap layout', () => {
92
+ const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
93
+ beforeAll( () => {
94
+ // jsdom lacks the SVG transform used by visx to convert pointer coordinates.
95
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
96
+ configurable: true,
97
+ value: () => null,
98
+ } );
99
+ } );
100
+
101
+ afterAll( () => {
102
+ if ( screenTransform ) {
103
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
104
+ } else {
105
+ Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
106
+ }
107
+ } );
108
+
109
+ it( 'centers the later group, half as thick, on the earlier one, with the comparisons in a slot beside them', () => {
110
+ renderOverlap();
111
+
112
+ expect( barRects() ).toHaveLength( 8 );
113
+ const views = barOf( 'Views' );
114
+ const visitors = barOf( 'Visitors' );
115
+ const previousViews = barOf( 'Previous views' );
116
+
117
+ expect( visitors.width ).toBeCloseTo( views.width / 2 );
118
+ expect( center( visitors ) ).toBeCloseTo( center( views ) );
119
+ expect( center( barOf( 'Previous visitors' ) ) ).toBeCloseTo( center( previousViews ) );
120
+ expect( previousViews.x ).toBeGreaterThan( views.x + views.width );
121
+ } );
122
+
123
+ it( 'fills each bar opaque, in the colors useBarColors reports for the layout', () => {
124
+ renderOverlap();
125
+
126
+ const colors = getBarColors( DATA, { layout: 'overlap' } );
127
+ expect( new Set( colors ).size ).toBe( 4 );
128
+ BAR_ORDER.forEach( ( label, order ) => {
129
+ const bar = barRects()[ order * 2 ];
130
+ expect( bar ).toHaveAttribute( 'fill', colors[ DATA.findIndex( s => s.label === label ) ] );
131
+ expect( bar ).not.toHaveAttribute( 'opacity' );
132
+ } );
133
+ } );
134
+
135
+ it( 'lists its composition legend in slot order, its swatches colored as its bars', () => {
136
+ renderOverlap( { chartId: 'overlap-legend', children: <BarChartUnresponsive.Legend /> } );
137
+
138
+ const order = [ 0, 2, 1, 3 ];
139
+ expect( screen.getAllByTestId( 'legend-item' ).map( item => item.textContent ) ).toEqual(
140
+ order.map( index => DATA[ index ].label )
141
+ );
142
+ const colors = getBarColors( DATA, { layout: 'overlap' } );
143
+ expect(
144
+ context
145
+ .getChartData( 'overlap-legend' )
146
+ ?.legendItems.map( ( { label, color } ) => [ label, color ] )
147
+ ).toEqual( order.map( index => [ DATA[ index ].label, colors[ index ] ] ) );
148
+ } );
149
+
150
+ it( 'keeps a comparison in its slot while its primary is hidden', () => {
151
+ const view = renderOverlap( { chartId: 'all-shown' } );
152
+ const { x, width } = barOf( 'Previous views' );
153
+ view.unmount();
154
+
155
+ renderOverlap( { chartId: 'some-hidden', defaultHiddenSeries: [ 'Views', 'Visitors' ] } );
156
+ const bar = geometry( barRects()[ 0 ] );
157
+
158
+ expect( bar.x ).toBeCloseTo( x );
159
+ expect( bar.width ).toBeCloseTo( width );
160
+ } );
161
+
162
+ it( 'widens a group to the full slot while the group behind it is hidden, in its own color', () => {
163
+ const view = renderOverlap( { chartId: 'all-shown' } );
164
+ const views = barOf( 'Views' );
165
+ const visitorsFill = barRects()[ 4 ].getAttribute( 'fill' );
166
+ view.unmount();
167
+
168
+ renderOverlap( { chartId: 'some-hidden', defaultHiddenSeries: [ 'Views', 'Previous views' ] } );
169
+ const visitors = barRects()[ 0 ];
170
+
171
+ expect( geometry( visitors ).x ).toBeCloseTo( views.x );
172
+ expect( geometry( visitors ).width ).toBeCloseTo( views.width );
173
+ expect( visitors ).toHaveAttribute( 'fill', visitorsFill );
174
+ } );
175
+
176
+ it( 'lists only the hovered category, and reports the front bar of the hovered slot', async () => {
177
+ const onPointerUp = jest.fn();
178
+ renderOverlap( { onPointerUp } );
179
+
180
+ pointer( 'pointermove', center( barOf( 'Views' ) ) );
181
+ const tooltip = await screen.findByRole( 'tooltip' );
182
+ [ 'Views: 100', 'Previous views: 80', 'Visitors: 40', 'Previous visitors: 30' ].forEach( row =>
183
+ expect( tooltip ).toHaveTextContent( row )
184
+ );
185
+ expect( tooltip ).not.toHaveTextContent( '120' );
186
+
187
+ pointer( 'pointerup', center( barOf( 'Views' ) ) );
188
+ expect( onPointerUp ).toHaveBeenCalledWith( expect.objectContaining( { key: 'Visitors' } ) );
189
+ } );
190
+
191
+ it( "drops the previous category's bars when the pointer moves on", async () => {
192
+ renderOverlap( { data: WITHOUT_LAST_PREVIOUS_VISITORS } );
193
+
194
+ pointer( 'pointermove', center( barOf( 'Views' ) ) );
195
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent(
196
+ 'Previous visitors: 30'
197
+ );
198
+ pointer( 'pointermove', center( barOf( 'Views', 1 ) ) );
199
+
200
+ await waitFor( () =>
201
+ expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Views: 120' )
202
+ );
203
+ expect( screen.getByRole( 'tooltip' ) ).not.toHaveTextContent( 'Previous visitors' );
204
+ } );
205
+
206
+ it( 'continues the keyboard from the hovered category when the front series starts later', async () => {
207
+ const user = userEvent.setup();
208
+ renderOverlap( {
209
+ data: DATA.map( series =>
210
+ series.label === 'Visitors' ? { ...series, data: series.data.slice( 1 ) } : series
211
+ ),
212
+ } );
213
+
214
+ await user.tab();
215
+ await user.keyboard( '{ArrowRight}' );
216
+ // Back to front, the late Visitors series draws only its Tuesday bar.
217
+ pointer( 'pointermove', center( geometry( barRects()[ 4 ] ) ) );
218
+ await user.keyboard( '{ArrowRight}' );
219
+
220
+ expect( selectedRing().x ).toBeCloseTo( barOf( 'Previous views', 1 ).x );
221
+ } );
222
+
223
+ it( 'steps the keyboard one slot at a time, outlining the whole slot', async () => {
224
+ const user = userEvent.setup();
225
+ renderOverlap();
226
+
227
+ await user.tab();
228
+ await user.keyboard( '{ArrowRight}' );
229
+ const views = barOf( 'Views' );
230
+ const ring = selectedRing();
231
+ expect( ring.x ).toBeCloseTo( views.x );
232
+ expect( ring.width ).toBeCloseTo( views.width );
233
+ expect( ring.y ).toBeCloseTo( views.y );
234
+ expect( ring.height ).toBeCloseTo( views.height );
235
+
236
+ await user.keyboard( '{ArrowRight}' );
237
+ expect( selectedRing().x ).toBeCloseTo( barOf( 'Previous views' ).x );
238
+ } );
239
+
240
+ it( 'lists every bar at the selected category in the keyboard tooltip, and reaches a slot whose front bar a shorter series leaves out', async () => {
241
+ const user = userEvent.setup();
242
+ renderOverlap( { data: WITHOUT_LAST_PREVIOUS_VISITORS } );
243
+
244
+ await user.tab();
245
+ await user.keyboard( '{ArrowRight}' );
246
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Previous visitors: 30' );
247
+ await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
248
+
249
+ const tooltip = screen.getByTestId( 'chart-tooltip-3' );
250
+ [ 'Views: 120', 'Previous views: 90', 'Visitors: 50' ].forEach( row =>
251
+ expect( tooltip ).toHaveTextContent( row )
252
+ );
253
+ expect( tooltip ).not.toHaveTextContent( 'Previous visitors' );
254
+ expect( selectedRing().x ).toBeCloseTo( barOf( 'Previous views', 1 ).x );
255
+ } );
256
+
257
+ it( 'reaches every category a slot draws from the keyboard when its groups cover different categories', async () => {
258
+ const user = userEvent.setup();
259
+ const onDatumActivate = jest.fn();
260
+ renderOverlap( {
261
+ onDatumActivate,
262
+ data: [
263
+ { label: 'Views', group: 'views', data: [ day( 'Mon', 100 ), day( 'Tue', 120 ) ] },
264
+ { label: 'Visitors', group: 'visitors', data: [ day( 'Tue', 50 ), day( 'Wed', 60 ) ] },
265
+ ],
266
+ } );
267
+
268
+ await user.tab();
269
+ await user.keyboard( '{ArrowRight}{Enter}{ArrowRight}{Enter}{ArrowRight}{Enter}' );
270
+
271
+ expect(
272
+ onDatumActivate.mock.calls.map( ( [ { key, datum } ] ) => [ key, datum.label ] )
273
+ ).toEqual( [
274
+ [ 'Views', 'Mon' ],
275
+ [ 'Visitors', 'Tue' ],
276
+ [ 'Visitors', 'Wed' ],
277
+ ] );
278
+ } );
279
+
280
+ it( 'steps the keyboard through categories in band order when the back series has no reading at one', async () => {
281
+ const user = userEvent.setup();
282
+ const onDatumActivate = jest.fn();
283
+ renderOverlap( {
284
+ onDatumActivate,
285
+ data: [
286
+ {
287
+ label: 'Views',
288
+ group: 'views',
289
+ data: [ day( 'Mon', 100 ), { label: 'Tue', value: null }, day( 'Wed', 120 ) ],
290
+ },
291
+ {
292
+ label: 'Previous views',
293
+ group: 'views',
294
+ options: { type: 'comparison' },
295
+ data: [ day( 'Mon', 80 ), day( 'Tue', 90 ), day( 'Wed', 70 ) ],
296
+ },
297
+ ],
298
+ } );
299
+
300
+ await user.tab();
301
+ await user.keyboard( '{ArrowRight}{Enter}'.repeat( 5 ) );
302
+
303
+ expect(
304
+ onDatumActivate.mock.calls.map( ( [ { key, datum } ] ) => `${ key } ${ datum.label }` )
305
+ ).toEqual( [
306
+ 'Views Mon',
307
+ 'Previous views Mon',
308
+ 'Previous views Tue',
309
+ 'Views Wed',
310
+ 'Previous views Wed',
311
+ ] );
312
+ } );
313
+
314
+ it.each( [
315
+ [
316
+ 'keyboard',
317
+ async () => {
318
+ const user = userEvent.setup();
319
+ await user.tab();
320
+ await user.keyboard( '{ArrowRight}' );
321
+ return screen.getByTestId( 'chart-tooltip-0' );
322
+ },
323
+ ],
324
+ [
325
+ 'hover',
326
+ () => {
327
+ pointer( 'pointermove', center( barOf( 'Views' ) ) );
328
+ return screen.findByRole( 'tooltip' );
329
+ },
330
+ ],
331
+ ] )(
332
+ 'lists a series with no reading at the category as No data in the %s tooltip',
333
+ async ( _case, openTooltip ) => {
334
+ renderOverlap( {
335
+ data: DATA.map( series =>
336
+ series.label === 'Visitors'
337
+ ? { ...series, data: [ { label: 'Mon', value: null }, day( 'Tue', 50 ) ] }
338
+ : series
339
+ ),
340
+ } );
341
+
342
+ const tooltip = await openTooltip();
343
+ expect( tooltip ).toHaveTextContent( 'Views: 100' );
344
+ expect( tooltip ).toHaveTextContent( 'Visitors: No data' );
345
+ }
346
+ );
347
+ } );