@automattic/charts 1.7.0 → 1.8.1

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 (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.cjs +347 -61
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +6 -4
  5. package/dist/index.d.cts +16 -0
  6. package/dist/index.d.ts +16 -0
  7. package/dist/index.js +348 -62
  8. package/dist/index.js.map +1 -1
  9. package/package.json +2 -2
  10. package/src/charts/bar-chart/bar-chart.tsx +210 -49
  11. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +70 -0
  12. package/src/charts/bar-chart/private/comparison-bars.tsx +155 -0
  13. package/src/charts/bar-chart/private/comparison-constants.ts +33 -0
  14. package/src/charts/bar-chart/private/index.ts +9 -0
  15. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +47 -0
  16. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +183 -0
  17. package/src/charts/bar-chart/private/use-bar-chart-options.ts +49 -5
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +329 -1
  19. package/src/charts/geo-chart/geo-chart.tsx +5 -9
  20. package/src/charts/pie-chart/pie-chart.module.scss +0 -4
  21. package/src/charts/pie-chart/pie-chart.tsx +3 -8
  22. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +0 -5
  23. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +3 -8
  24. package/src/charts/private/center/center.module.scss +4 -0
  25. package/src/charts/private/center/center.tsx +33 -0
  26. package/src/charts/private/center/index.ts +2 -0
  27. package/src/charts/private/center/test/center.test.tsx +60 -0
  28. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +0 -2
  29. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +2 -4
  30. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  31. package/src/providers/chart-context/test/chart-context.test.tsx +13 -1
  32. package/src/providers/chart-context/themes.ts +8 -0
  33. package/src/providers/chart-context/types.ts +2 -0
  34. package/src/types.ts +16 -0
  35. package/src/utils/get-styles.ts +36 -13
  36. package/src/utils/index.ts +6 -1
  37. package/src/utils/test/get-styles.test.ts +47 -1
@@ -0,0 +1,47 @@
1
+ import { computeComparisonRect, getValueScaleBaseline } from '../comparison-bars-geometry';
2
+
3
+ describe( 'getValueScaleBaseline', () => {
4
+ it( 'clamps to the bottom (range max) when 0 is below the domain (vertical, descending range)', () => {
5
+ // vertical linear scale: range [200, 0] (bottom -> top), domain [10, 100] (zero excluded)
6
+ const scale = ( ( v: number ) => 200 - ( ( v - 10 ) / 90 ) * 200 ) as never;
7
+ ( scale as { range: () => number[] } ).range = () => [ 200, 0 ];
8
+ // scale(0) = 200 - (-10/90)*200 ≈ 222 -> clamped to 200
9
+ expect( getValueScaleBaseline( scale ) ).toBe( 200 );
10
+ } );
11
+
12
+ it( 'clamps to the end (range max) when 0 is outside an ascending range scale', () => {
13
+ // horizontal linear scale: range [0, 200], domain [-100, -10] (zero excluded)
14
+ const scale = ( ( v: number ) => ( ( v + 100 ) / 90 ) * 200 ) as never;
15
+ ( scale as { range: () => number[] } ).range = () => [ 0, 200 ];
16
+ // scale(0) = (100/90)*200 ≈ 222 -> clamped to 200
17
+ expect( getValueScaleBaseline( scale ) ).toBe( 200 );
18
+ } );
19
+ } );
20
+
21
+ describe( 'computeComparisonRect', () => {
22
+ const base = {
23
+ bandPosition: 100,
24
+ slotOffset: 10,
25
+ slotThickness: 40,
26
+ valuePosition: 50,
27
+ baseline: 200,
28
+ widthFactor: 1.5,
29
+ };
30
+
31
+ it( 'centers a 150% wide shadow on the primary slot (vertical)', () => {
32
+ const r = computeComparisonRect( { ...base, horizontal: false } );
33
+ // slotStart=110, center=110+20=130, shadowThickness=60, shadowStart=130-30=100
34
+ expect( r ).toEqual( { x: 100, y: 50, width: 60, height: 150 } );
35
+ } );
36
+
37
+ it( 'centers a 150% tall shadow on the primary slot (horizontal)', () => {
38
+ const r = computeComparisonRect( {
39
+ ...base,
40
+ horizontal: true,
41
+ valuePosition: 150,
42
+ baseline: 0,
43
+ } );
44
+ // slotStart=110, center=130, shadowThickness=60, shadowStart=100
45
+ expect( r ).toEqual( { x: 0, y: 100, width: 150, height: 60 } );
46
+ } );
47
+ } );
@@ -0,0 +1,183 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { scaleBand, scaleLinear } from '@visx/scale';
3
+ import { DataContext } from '@visx/xychart';
4
+ import { ComparisonBars } from '../comparison-bars';
5
+ import type { DataPointDate, SeriesData } from '../../../../types';
6
+ import type { ComparisonSeriesEntry } from '../comparison-bars';
7
+
8
+ // Build the band scale over two categories (matches how BarGroup sets it up)
9
+ const categories = [ 'Jan', 'Feb' ];
10
+ const xScale = scaleBand( {
11
+ domain: categories,
12
+ range: [ 0, 200 ],
13
+ padding: 0.1,
14
+ } );
15
+
16
+ // Simple linear value scale: domain 0..100 -> range 200..0 (SVG coords, descending)
17
+ const yScale = scaleLinear( { domain: [ 0, 100 ], range: [ 200, 0 ] } );
18
+
19
+ // The primaryKeys in this test
20
+ const primaryKeys = [ 'current' ];
21
+ const groupPadding = 0.1;
22
+
23
+ // Build the same groupScale so tests can compute expected width
24
+ const groupScale = scaleBand( {
25
+ domain: primaryKeys,
26
+ range: [ 0, xScale.bandwidth() ],
27
+ padding: groupPadding,
28
+ } );
29
+
30
+ // Minimal DataContext value
31
+ const dataContextValue = {
32
+ xScale: xScale as never,
33
+ yScale: yScale as never,
34
+ horizontal: false,
35
+ // Other DataContext fields the component doesn't use
36
+ width: 200,
37
+ height: 200,
38
+ margin: { top: 0, right: 0, bottom: 0, left: 0 },
39
+ dataRegistry: {} as never,
40
+ registerData: () => undefined,
41
+ unregisterData: () => undefined,
42
+ setDimensions: () => undefined,
43
+ innerWidth: 200,
44
+ innerHeight: 200,
45
+ theme: {} as never,
46
+ colorScale: {} as never,
47
+ };
48
+
49
+ const seriesData: SeriesData = {
50
+ label: 'Comparison',
51
+ data: [
52
+ { label: 'Jan', value: 40 } as DataPointDate,
53
+ { label: 'Feb', value: 70 } as DataPointDate,
54
+ ] as DataPointDate[],
55
+ options: { type: 'comparison' },
56
+ };
57
+
58
+ const comparisonEntries: ComparisonSeriesEntry[] = [
59
+ { series: seriesData, index: 0, primaryKey: 'current', primaryIndex: 0 },
60
+ ];
61
+
62
+ const getElementStyles = () => ( {
63
+ color: '#f00',
64
+ barStyles: { widthFactor: 1.5, opacity: 0.5 },
65
+ lineStyles: {} as never,
66
+ glyph: undefined as never,
67
+ shapeStyles: {} as never,
68
+ } );
69
+
70
+ const resolveFill = () => '#f00';
71
+
72
+ const xAccessor = ( d: DataPointDate ) => d.label;
73
+ const yAccessor = ( d: DataPointDate ) => d.value ?? undefined;
74
+
75
+ describe( 'ComparisonBars', () => {
76
+ it( 'renders two shadow rects for two data points', () => {
77
+ render(
78
+ <svg>
79
+ <DataContext.Provider value={ dataContextValue }>
80
+ <ComparisonBars
81
+ comparisonEntries={ comparisonEntries }
82
+ primaryKeys={ primaryKeys }
83
+ groupPadding={ groupPadding }
84
+ horizontal={ false }
85
+ xAccessor={ xAccessor }
86
+ yAccessor={ yAccessor }
87
+ getElementStyles={ getElementStyles }
88
+ resolveFill={ resolveFill }
89
+ />
90
+ </DataContext.Provider>
91
+ </svg>
92
+ );
93
+
94
+ const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
95
+ expect( rects ).toHaveLength( 2 );
96
+ expect( rects[ 0 ] ).toHaveAttribute( 'fill', '#f00' );
97
+ expect( rects[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
98
+ // width == widthFactor (1.5) * groupScale.bandwidth()
99
+ expect( Number( rects[ 0 ].getAttribute( 'width' ) ) ).toBeCloseTo(
100
+ 1.5 * groupScale.bandwidth()
101
+ );
102
+ } );
103
+
104
+ it( 'wraps rects in a g with correct class and pointerEvents', () => {
105
+ render(
106
+ <svg>
107
+ <DataContext.Provider value={ dataContextValue }>
108
+ <ComparisonBars
109
+ comparisonEntries={ comparisonEntries }
110
+ primaryKeys={ primaryKeys }
111
+ groupPadding={ groupPadding }
112
+ horizontal={ false }
113
+ xAccessor={ xAccessor }
114
+ yAccessor={ yAccessor }
115
+ getElementStyles={ getElementStyles }
116
+ resolveFill={ resolveFill }
117
+ />
118
+ </DataContext.Provider>
119
+ </svg>
120
+ );
121
+
122
+ const g = screen.getByTestId( 'bar-chart-comparison-bars' );
123
+ expect( g ).toHaveClass( 'bar-chart__comparison-bars', { exact: true } );
124
+ expect( g ).toHaveAttribute( 'pointer-events', 'none' );
125
+ } );
126
+
127
+ it( 'shadow rect is horizontally centered on the primary bar slot at widthFactor × slot width', () => {
128
+ render(
129
+ <svg>
130
+ <DataContext.Provider value={ dataContextValue }>
131
+ <ComparisonBars
132
+ comparisonEntries={ comparisonEntries }
133
+ primaryKeys={ primaryKeys }
134
+ groupPadding={ groupPadding }
135
+ horizontal={ false }
136
+ xAccessor={ xAccessor }
137
+ yAccessor={ yAccessor }
138
+ getElementStyles={ getElementStyles }
139
+ resolveFill={ resolveFill }
140
+ />
141
+ </DataContext.Provider>
142
+ </svg>
143
+ );
144
+
145
+ const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
146
+ const rect = rects[ 0 ]; // 'Jan' datum
147
+
148
+ const slotThickness = groupScale.bandwidth();
149
+ const expectedWidth = 1.5 * slotThickness; // shadow = widthFactor × slot width
150
+ // Shadow x = bandStart + slotOffset + slotThickness/2 - shadowWidth/2
151
+ const bandStart = Number( xScale( 'Jan' ) );
152
+ const slotOffset = Number( groupScale( 'current' ) );
153
+ const expectedX = bandStart + slotOffset + slotThickness / 2 - expectedWidth / 2;
154
+ const expectedCenterX = expectedX + expectedWidth / 2;
155
+ const primarySlotCenterX = bandStart + slotOffset + slotThickness / 2;
156
+
157
+ expect( Number( rect.getAttribute( 'width' ) ) ).toBeCloseTo( expectedWidth );
158
+ expect( Number( rect.getAttribute( 'x' ) ) ).toBeCloseTo( expectedX );
159
+ // Center of shadow matches center of primary slot
160
+ expect( expectedCenterX ).toBeCloseTo( primarySlotCenterX );
161
+ } );
162
+
163
+ it( 'renders nothing when primaryKeys is empty', () => {
164
+ render(
165
+ <svg>
166
+ <DataContext.Provider value={ dataContextValue }>
167
+ <ComparisonBars
168
+ comparisonEntries={ comparisonEntries }
169
+ primaryKeys={ [] }
170
+ groupPadding={ groupPadding }
171
+ horizontal={ false }
172
+ xAccessor={ xAccessor }
173
+ yAccessor={ yAccessor }
174
+ getElementStyles={ getElementStyles }
175
+ resolveFill={ resolveFill }
176
+ />
177
+ </DataContext.Provider>
178
+ </svg>
179
+ );
180
+
181
+ expect( screen.queryByTestId( 'bar-chart-comparison-bars' ) ).not.toBeInTheDocument();
182
+ } );
183
+ } );
@@ -5,6 +5,11 @@ import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
5
5
  import type { DataPointDate, BaseChartProps, SeriesData } from '../../../types';
6
6
  import type { TickFormatter } from '@visx/axis';
7
7
 
8
+ /** Outer padding of the category band scale (space at the chart edges). */
9
+ export const BASE_BAND_PADDING = 0.2;
10
+ /** Inner padding of the category band scale (the base gap between ticks). */
11
+ export const BASE_BAND_PADDING_INNER = 0.1;
12
+
8
13
  const formatDateTick = ( timestamp: number ) => {
9
14
  const date = new Date( timestamp );
10
15
  return date.toLocaleDateString( undefined, {
@@ -39,8 +44,8 @@ export function useBarChartOptions(
39
44
  const defaultOptions = useMemo( () => {
40
45
  const bandScale = {
41
46
  type: 'band' as const,
42
- padding: 0.2,
43
- paddingInner: 0.1,
47
+ padding: BASE_BAND_PADDING,
48
+ paddingInner: BASE_BAND_PADDING_INNER,
44
49
  };
45
50
  const linearScale = {
46
51
  type: 'linear' as const,
@@ -97,8 +102,47 @@ export function useBarChartOptions(
97
102
  yScale: baseYScale,
98
103
  } = defaultOptions[ orientationKey ];
99
104
 
100
- const xScale = { ...baseXScale, ...( options.xScale || {} ) };
101
- const yScale = { ...baseYScale, ...( options.yScale || {} ) };
105
+ // When comparison series are present, visx only sees primary BarSeries and computes
106
+ // a too-narrow domain. Compute an explicit domain spanning all series so comparison
107
+ // shadows aren't clipped. Skip when the user has already provided an explicit domain.
108
+ let valueScaleDomainOverride: { domain?: [ number, number ] } = {};
109
+ const hasComparisonSeries = data.some( s => s.options?.type === 'comparison' );
110
+ if ( hasComparisonSeries ) {
111
+ const valueAxisIsY = ! horizontal;
112
+ const userDomain = valueAxisIsY ? options.yScale?.domain : options.xScale?.domain;
113
+ if ( ! userDomain ) {
114
+ const allValues: number[] = [];
115
+ data.forEach( series => {
116
+ series.data.forEach( d => {
117
+ const enhanced = d as { visualValue?: number };
118
+ const v =
119
+ enhanced.visualValue !== undefined ? enhanced.visualValue : ( d.value as number );
120
+ if ( typeof v === 'number' && Number.isFinite( v ) ) {
121
+ allValues.push( v );
122
+ }
123
+ } );
124
+ } );
125
+ if ( allValues.length > 0 ) {
126
+ // Keep zero in the domain so bar length stays proportional to value — a
127
+ // non-zero baseline would exaggerate differences between periods. Math.max
128
+ // keeps zero on the far side too, so charts with negative values still span 0.
129
+ valueScaleDomainOverride = {
130
+ domain: [ Math.min( 0, ...allValues ), Math.max( 0, ...allValues ) ],
131
+ };
132
+ }
133
+ }
134
+ }
135
+
136
+ const xScale = {
137
+ ...baseXScale,
138
+ ...( options.xScale || {} ),
139
+ ...( horizontal ? valueScaleDomainOverride : {} ),
140
+ };
141
+ const yScale = {
142
+ ...baseYScale,
143
+ ...( options.yScale || {} ),
144
+ ...( ! horizontal ? valueScaleDomainOverride : {} ),
145
+ };
102
146
  const providedToolTipLabelFormatter = horizontal
103
147
  ? options.axis?.y?.tickFormat
104
148
  : options.axis?.x?.tickFormat;
@@ -137,5 +181,5 @@ export function useBarChartOptions(
137
181
  labelFormatter: providedToolTipLabelFormatter || defaultTooltipLabelFormatter,
138
182
  },
139
183
  };
140
- }, [ defaultOptions, options, horizontal ] );
184
+ }, [ defaultOptions, options, horizontal, data ] );
141
185
  }
@@ -1,7 +1,8 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { render, renderHook, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { GlobalChartsProvider } from '../../../providers';
4
4
  import BarChart from '../bar-chart';
5
+ import { useBarChartOptions } from '../private';
5
6
 
6
7
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
7
8
  const mockRefCallback = jest.fn();
@@ -233,6 +234,37 @@ describe( 'BarChart', () => {
233
234
  // Check that no pattern definitions container is present
234
235
  expect( screen.queryByTestId( 'bar-chart-patterns' ) ).not.toBeInTheDocument();
235
236
  } );
237
+
238
+ test( 'comparison shadow reuses the primary bar pattern when patterns are enabled', () => {
239
+ renderWithTheme( {
240
+ withPatterns: true,
241
+ data: [
242
+ {
243
+ label: 'This period',
244
+ group: 'views',
245
+ data: [
246
+ { label: 'Mon', value: 10 },
247
+ { label: 'Tue', value: 20 },
248
+ ],
249
+ },
250
+ {
251
+ label: 'Previous period',
252
+ group: 'views',
253
+ options: { type: 'comparison' as const },
254
+ data: [
255
+ { label: 'Mon', value: 15 },
256
+ { label: 'Tue', value: 25 },
257
+ ],
258
+ },
259
+ ],
260
+ } );
261
+
262
+ const shadow = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ )[ 0 ];
263
+ const shadowFill = shadow.getAttribute( 'fill' );
264
+ // Shadow is filled with a pattern, not a solid color, and it references the PRIMARY
265
+ // series' pattern (index 0) rather than the comparison series' own index.
266
+ expect( shadowFill ).toMatch( /^url\(#bar-pattern-.+-0\)$/ );
267
+ } );
236
268
  } );
237
269
 
238
270
  describe( 'Keyboard Navigation Accessibility', () => {
@@ -290,6 +322,8 @@ describe( 'BarChart', () => {
290
322
  await user.keyboard( '{ArrowRight}' );
291
323
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
292
324
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
325
+ // The category/value row joins with a space after the colon.
326
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Jan 1: 10' );
293
327
  expect( screen.queryByTestId( 'chart-tooltip-1' ) ).not.toBeInTheDocument();
294
328
 
295
329
  // Second tab should focus on the second tooltip.
@@ -346,6 +380,84 @@ describe( 'BarChart', () => {
346
380
  } );
347
381
  } );
348
382
 
383
+ describe( 'Comparison tooltip', () => {
384
+ test( 'tooltip shows both the primary and comparison values', async () => {
385
+ const user = userEvent.setup();
386
+ renderWithTheme( {
387
+ withTooltips: true,
388
+ data: [
389
+ {
390
+ label: 'This period',
391
+ group: 'views',
392
+ data: [
393
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
394
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
395
+ ],
396
+ },
397
+ {
398
+ label: 'Previous period',
399
+ group: 'views',
400
+ options: { type: 'comparison' as const },
401
+ data: [
402
+ { date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
403
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
404
+ ],
405
+ },
406
+ ],
407
+ } );
408
+
409
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
410
+ chart.focus();
411
+
412
+ await user.keyboard( '{ArrowRight}' );
413
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
414
+ // Both periods are shown, each as "label: value" with a space after the colon.
415
+ expect( tooltip ).toHaveTextContent( 'This period: 10' );
416
+ expect( tooltip ).toHaveTextContent( 'Previous period: 15' );
417
+ } );
418
+
419
+ test( 'keyboard navigation past the first slot stays on the primary bar, not the comparison series', async () => {
420
+ const user = userEvent.setup();
421
+ renderWithTheme( {
422
+ withTooltips: true,
423
+ data: [
424
+ {
425
+ label: 'This period',
426
+ group: 'views',
427
+ data: [
428
+ { label: 'Mon', value: 10 },
429
+ { label: 'Tue', value: 20 },
430
+ ],
431
+ },
432
+ {
433
+ label: 'Previous period',
434
+ group: 'views',
435
+ options: { type: 'comparison' as const },
436
+ data: [
437
+ { label: 'Mon', value: 15 },
438
+ { label: 'Tue', value: 25 },
439
+ ],
440
+ },
441
+ ],
442
+ } );
443
+
444
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
445
+ chart.focus();
446
+
447
+ // Slot 0 is the first primary bar (Mon); slot 1 must be the SECOND primary
448
+ // bar (Tue) — not the comparison series' first bar. The keyboard index space
449
+ // counts only primary bars, so the tooltip must too.
450
+ await user.keyboard( '{ArrowRight}' );
451
+ await user.keyboard( '{ArrowRight}' );
452
+ const tooltip = screen.getByTestId( 'chart-tooltip-1' );
453
+ expect( tooltip ).toHaveTextContent( 'Tue' );
454
+ expect( tooltip ).toHaveTextContent( 'This period' );
455
+ expect( tooltip ).toHaveTextContent( '20' );
456
+ expect( tooltip ).toHaveTextContent( 'Previous period' );
457
+ expect( tooltip ).toHaveTextContent( '25' );
458
+ } );
459
+ } );
460
+
349
461
  describe( 'Tab Key Navigation', () => {
350
462
  test( 'tab key exits navigation when reaching end of data points', async () => {
351
463
  const user = userEvent.setup();
@@ -754,6 +866,222 @@ describe( 'BarChart', () => {
754
866
  } );
755
867
  } );
756
868
 
869
+ describe( 'Comparison Series', () => {
870
+ it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
871
+ const data = [
872
+ {
873
+ label: 'This year',
874
+ group: 'views',
875
+ data: [
876
+ { label: 'Jan', value: 100 },
877
+ { label: 'Feb', value: 120 },
878
+ ],
879
+ },
880
+ {
881
+ label: 'Last year',
882
+ group: 'views',
883
+ options: { type: 'comparison' as const },
884
+ data: [
885
+ { label: 'Jan', value: 80 },
886
+ { label: 'Feb', value: 140 },
887
+ ],
888
+ },
889
+ ];
890
+ render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
891
+
892
+ // two comparison shadow rects (one per data point)
893
+ // Match individual rect testids like bar-chart-comparison-1-0 (not the group wrapper)
894
+ const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
895
+ expect( shadows ).toHaveLength( 2 );
896
+ expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
897
+
898
+ // primary bars still render for the single primary series (visx .visx-bar)
899
+ // eslint-disable-next-line testing-library/no-node-access
900
+ const bars = document.querySelectorAll( '.visx-bar' );
901
+ expect( bars.length ).toBeGreaterThanOrEqual( 2 );
902
+ } );
903
+
904
+ it( 'hides the comparison shadow group from assistive technology', () => {
905
+ const data = [
906
+ {
907
+ label: 'This year',
908
+ group: 'views',
909
+ data: [ { label: 'Jan', value: 100 } ],
910
+ },
911
+ {
912
+ label: 'Last year',
913
+ group: 'views',
914
+ options: { type: 'comparison' as const },
915
+ data: [ { label: 'Jan', value: 80 } ],
916
+ },
917
+ ];
918
+ render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
919
+
920
+ // The shadow is decorative: no keyboard/hover target and its value is surfaced
921
+ // through the tooltip, so it must not be announced as a separate element.
922
+ expect( screen.getByTestId( 'bar-chart-comparison-bars' ) ).toHaveAttribute(
923
+ 'aria-hidden',
924
+ 'true'
925
+ );
926
+ } );
927
+
928
+ it( 'renders comparison shadows in horizontal orientation', () => {
929
+ const data = [
930
+ {
931
+ label: 'This year',
932
+ group: 'views',
933
+ data: [
934
+ { label: 'Jan', value: 100 },
935
+ { label: 'Feb', value: 120 },
936
+ ],
937
+ },
938
+ {
939
+ label: 'Last year',
940
+ group: 'views',
941
+ options: { type: 'comparison' as const },
942
+ data: [
943
+ { label: 'Jan', value: 80 },
944
+ { label: 'Feb', value: 140 },
945
+ ],
946
+ },
947
+ ];
948
+ render( <BarChart data={ data } orientation="horizontal" width={ 400 } height={ 300 } /> );
949
+
950
+ const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
951
+ expect( shadows ).toHaveLength( 2 );
952
+ expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
953
+ } );
954
+
955
+ it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
956
+ // Comparison value 150 exceeds primary max 100.
957
+ // Without domain expansion the value-axis scale config has no explicit domain,
958
+ // so visx derives it from primary BarSeries only (max=100).
959
+ // With the fix, an explicit domain [min,150] is set on the value-axis scale config.
960
+ const data = [
961
+ {
962
+ label: 'This year',
963
+ group: 'views',
964
+ data: [ { label: 'Jan', value: 100 } ],
965
+ },
966
+ {
967
+ label: 'Last year',
968
+ group: 'views',
969
+ options: { type: 'comparison' as const },
970
+ data: [ { label: 'Jan', value: 150 } ],
971
+ },
972
+ ];
973
+
974
+ const { result } = renderHook( () => useBarChartOptions( data, false, {} ) );
975
+ const yScale = result.current.yScale as { domain?: number[] };
976
+ // domain must be set explicitly and its max must be >= 150
977
+ expect( yScale.domain ).toBeDefined();
978
+ expect( ( yScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 150 );
979
+ } );
980
+
981
+ it( 'keeps the value-axis baseline at zero so comparison bars encode magnitude truthfully', () => {
982
+ // All values are well above zero; the domain must still start at 0 so a bar's
983
+ // length stays proportional to its value (a non-zero baseline exaggerates differences).
984
+ const data = [
985
+ {
986
+ label: 'This period',
987
+ group: 'views',
988
+ data: [ { label: 'Mon', value: 420 } ],
989
+ },
990
+ {
991
+ label: 'Previous period',
992
+ group: 'views',
993
+ options: { type: 'comparison' as const },
994
+ data: [ { label: 'Mon', value: 510 } ],
995
+ },
996
+ ];
997
+
998
+ const { result } = renderHook( () => useBarChartOptions( data, false, {} ) );
999
+ const yScale = result.current.yScale as { domain?: number[] };
1000
+ expect( yScale.domain ).toBeDefined();
1001
+ expect( ( yScale.domain as number[] )[ 0 ] ).toBe( 0 );
1002
+ } );
1003
+
1004
+ it( 'zero-bases the value-axis domain in horizontal comparison charts', () => {
1005
+ const data = [
1006
+ {
1007
+ label: 'This period',
1008
+ group: 'views',
1009
+ data: [ { label: 'Mon', value: 420 } ],
1010
+ },
1011
+ {
1012
+ label: 'Previous period',
1013
+ group: 'views',
1014
+ options: { type: 'comparison' as const },
1015
+ data: [ { label: 'Mon', value: 510 } ],
1016
+ },
1017
+ ];
1018
+
1019
+ // In horizontal charts the value axis is x.
1020
+ const { result } = renderHook( () => useBarChartOptions( data, true, {} ) );
1021
+ const xScale = result.current.xScale as { domain?: number[] };
1022
+ expect( xScale.domain ).toBeDefined();
1023
+ expect( ( xScale.domain as number[] )[ 0 ] ).toBe( 0 );
1024
+ expect( ( xScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 510 );
1025
+ } );
1026
+
1027
+ it( 'counts only primary series for keyboard navigation when a comparison series is present', async () => {
1028
+ const user = userEvent.setup();
1029
+ // 2 primary + 1 comparison. totalPoints should be 2*2=4, not 3*2=6.
1030
+ // If comparison is counted, the 5th ArrowRight would reference a phantom series
1031
+ // and could throw or show an undefined tooltip key.
1032
+ const data = [
1033
+ {
1034
+ label: 'Series A',
1035
+ group: 'g',
1036
+ data: [
1037
+ { label: 'Jan', value: 10 },
1038
+ { label: 'Feb', value: 20 },
1039
+ ],
1040
+ },
1041
+ {
1042
+ label: 'Series B',
1043
+ group: 'g2',
1044
+ data: [
1045
+ { label: 'Jan', value: 15 },
1046
+ { label: 'Feb', value: 25 },
1047
+ ],
1048
+ },
1049
+ {
1050
+ label: 'Last year',
1051
+ group: 'g',
1052
+ options: { type: 'comparison' as const },
1053
+ data: [
1054
+ { label: 'Jan', value: 8 },
1055
+ { label: 'Feb', value: 18 },
1056
+ ],
1057
+ },
1058
+ ];
1059
+ render(
1060
+ <GlobalChartsProvider>
1061
+ <BarChart data={ data } width={ 400 } height={ 300 } withTooltips />
1062
+ </GlobalChartsProvider>
1063
+ );
1064
+
1065
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1066
+ chart.focus();
1067
+
1068
+ // Navigate through all 4 primary slots (2 series × 2 data points)
1069
+ for ( let i = 0; i < 4; i++ ) {
1070
+ await user.keyboard( '{ArrowRight}' );
1071
+ }
1072
+
1073
+ // If totalPoints was 6 (counted comparison), tooltip 4 would still show.
1074
+ // With correct totalPoints=4, navigation wraps and tooltip 0 is shown again,
1075
+ // not phantom slot 4 (which would reference data[2], a comparison series).
1076
+ // Either way, there must be no crash and navigation must stay in bounds.
1077
+ const tooltips = screen.queryAllByTestId( /^chart-tooltip-/ );
1078
+ expect( tooltips ).toHaveLength( 1 );
1079
+ // The visible tooltip must belong to a primary series
1080
+ const tooltipText = tooltips[ 0 ].textContent ?? '';
1081
+ expect( [ 'Series A', 'Series B' ].some( k => tooltipText.includes( k ) ) ).toBe( true );
1082
+ } );
1083
+ } );
1084
+
757
1085
  describe( 'Interactive Legend', () => {
758
1086
  it( 'filters series when interactive legend is enabled and series is toggled', async () => {
759
1087
  const user = userEvent.setup();