@automattic/charts 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +38 -11
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -0,0 +1,185 @@
1
+ import { getBucketInfo, getBucketResolution } from '../bucket-info';
2
+ import type { SeriesData } from '../../types';
3
+
4
+ const at = ( hoursFromStart: number ) =>
5
+ new Date( Date.UTC( 2026, 7, 1 ) + hoursFromStart * 60 * 60 * 1000 );
6
+
7
+ const seriesOf = ( offsetsInHours: number[] ): SeriesData[] => [
8
+ {
9
+ label: 'views',
10
+ data: offsetsInHours.map( ( offset, index ) => ( { date: at( offset ), value: index } ) ),
11
+ },
12
+ ];
13
+
14
+ const seriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
15
+ seriesOf( Array.from( { length: count }, ( _, index ) => index * hours ) );
16
+
17
+ const pad = ( value: number ) => String( value ).padStart( 2, '0' );
18
+
19
+ // Built from local components so it round-trips through `parseAsLocalDate`,
20
+ // which reads a `dateString` as wall-clock time in the runtime's own zone.
21
+ const seriesOfStrings = ( offsetsInHours: number[] ): SeriesData[] => [
22
+ {
23
+ label: 'views',
24
+ data: offsetsInHours.map( ( offset, index ) => {
25
+ const date = at( offset );
26
+ return {
27
+ dateString: `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad(
28
+ date.getDate()
29
+ ) }T${ pad( date.getHours() ) }:${ pad( date.getMinutes() ) }:00`,
30
+ value: index,
31
+ };
32
+ } ),
33
+ },
34
+ ];
35
+
36
+ const stringSeriesSpacedHours = ( hours: number, count: number ): SeriesData[] =>
37
+ seriesOfStrings( Array.from( { length: count }, ( _, index ) => index * hours ) );
38
+
39
+ const hourlyDates = ( start: Date, hours: number ): Date[] =>
40
+ Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
41
+
42
+ const dailyDates = ( start: Date, days: number ): Date[] =>
43
+ Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
44
+
45
+ const toSeries = ( dates: Date[] ): SeriesData[] => [
46
+ {
47
+ label: 'series',
48
+ data: dates.map( date => ( { date, value: 1 } ) ),
49
+ },
50
+ ];
51
+
52
+ describe( 'getBucketInfo', () => {
53
+ it( 'preserves a declared week in bucket and collapses it for display', () => {
54
+ const data = seriesSpacedHours( 24 * 7, 6 );
55
+
56
+ expect( getBucketInfo( data, 'week' ) ).toEqual( {
57
+ bucket: 'week',
58
+ displayResolution: 'day',
59
+ } );
60
+ } );
61
+
62
+ it( 'passes a declared resolution through unchanged otherwise', () => {
63
+ const data = seriesSpacedHours( 1, 6 );
64
+
65
+ expect( getBucketInfo( data, 'hour' ) ).toEqual( {
66
+ bucket: 'hour',
67
+ displayResolution: 'hour',
68
+ } );
69
+ } );
70
+
71
+ it( 'infers from spacing when nothing is declared', () => {
72
+ expect( getBucketInfo( seriesSpacedHours( 1, 6 ) ) ).toEqual( {
73
+ bucket: 'hour',
74
+ displayResolution: 'hour',
75
+ } );
76
+ expect( getBucketInfo( seriesSpacedHours( 24, 6 ) ) ).toEqual( {
77
+ bucket: 'day',
78
+ displayResolution: 'day',
79
+ } );
80
+ } );
81
+
82
+ it( 'infers from dateString points, which the chart parses before it draws them', () => {
83
+ expect( getBucketInfo( stringSeriesSpacedHours( 1, 6 ) ) ).toEqual( {
84
+ bucket: 'hour',
85
+ displayResolution: 'hour',
86
+ } );
87
+ expect( getBucketInfo( stringSeriesSpacedHours( 24, 6 ) ) ).toEqual( {
88
+ bucket: 'day',
89
+ displayResolution: 'day',
90
+ } );
91
+ } );
92
+
93
+ it( 'reads a series the same whichever date field carries it', () => {
94
+ const offsets = [ 0, 1, 2, 3 ];
95
+
96
+ expect( getBucketInfo( seriesOfStrings( offsets ) ) ).toEqual(
97
+ getBucketInfo( seriesOf( offsets ) )
98
+ );
99
+ } );
100
+
101
+ it( 'never infers week, only reports it when declared', () => {
102
+ expect( getBucketInfo( seriesSpacedHours( 24 * 7, 6 ) ).bucket ).toBe( 'day' );
103
+ } );
104
+
105
+ it( 'reads the same bucket whatever order the points arrive in', () => {
106
+ const offsets = [ 0, 24, 48 ];
107
+ const shuffled = [ 0, 48, 24 ];
108
+
109
+ expect( getBucketInfo( seriesOf( shuffled ) ) ).toEqual( getBucketInfo( seriesOf( offsets ) ) );
110
+ expect( getBucketInfo( seriesOf( shuffled ) ).bucket ).toBe( 'day' );
111
+ } );
112
+
113
+ it( 'ignores a repeated instant, which pads a series rather than dating it', () => {
114
+ // A shorter comparison series can repeat its last date to match length.
115
+ expect( getBucketInfo( seriesOf( [ 0, 24, 48, 48 ] ) ).bucket ).toBe( 'day' );
116
+ } );
117
+ } );
118
+
119
+ describe( 'getBucketResolution', () => {
120
+ it( 'infers hourly buckets from sub-daily spacing', () => {
121
+ expect(
122
+ getBucketResolution( toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) ) )
123
+ ).toBe( 'hour' );
124
+ } );
125
+
126
+ it( 'infers daily buckets from daily spacing', () => {
127
+ expect(
128
+ getBucketResolution( toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 5 ) ) )
129
+ ).toBe( 'day' );
130
+ } );
131
+
132
+ it( 'infers daily buckets from weekly spacing, which labels the same way', () => {
133
+ const weeklyDates = Array.from(
134
+ { length: 5 },
135
+ ( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
136
+ );
137
+
138
+ expect( getBucketResolution( toSeries( weeklyDates ) ) ).toBe( 'day' );
139
+ } );
140
+
141
+ it( 'infers monthly buckets from a shortest-month gap', () => {
142
+ expect(
143
+ getBucketResolution(
144
+ toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] )
145
+ )
146
+ ).toBe( 'month' );
147
+ } );
148
+
149
+ it( 'infers yearly buckets from a yearly gap', () => {
150
+ expect(
151
+ getBucketResolution(
152
+ toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] )
153
+ )
154
+ ).toBe( 'year' );
155
+ } );
156
+
157
+ it( 'keeps a daily gap across spring-forward out of the hourly bucket', () => {
158
+ const start = new Date( '2026-03-08T00:00:00' );
159
+ const dstDates = [ 0, 23, 47 ].map(
160
+ offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
161
+ );
162
+
163
+ expect( getBucketResolution( toSeries( dstDates ) ) ).toBe( 'day' );
164
+ } );
165
+
166
+ it( 'reports daily buckets when no series has two points', () => {
167
+ // Unmeasurable spacing reads as Infinity, which must not be mistaken for a
168
+ // very coarse bucket.
169
+ expect( getBucketResolution( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ) ) ).toBe(
170
+ 'day'
171
+ );
172
+ } );
173
+
174
+ it( 'reports a declared resolution over the measured spacing', () => {
175
+ const hourly = toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 6 ) );
176
+
177
+ expect( getBucketResolution( hourly, 'month' ) ).toBe( 'month' );
178
+ } );
179
+
180
+ it( 'reports a declared weekly resolution as daily', () => {
181
+ // Weeks and days are both calendar-date buckets as far as labelling goes,
182
+ // and 'week' is not a label format of its own.
183
+ expect( getBucketResolution( toSeries( [] ), 'week' ) ).toBe( 'day' );
184
+ } );
185
+ } );
@@ -7,7 +7,6 @@ import {
7
7
  hexToRgba,
8
8
  validateHexColor,
9
9
  parseHslString,
10
- parseRgbString,
11
10
  normalizeColorToHex,
12
11
  relativeLuminance,
13
12
  prefersLightText,
@@ -547,7 +546,7 @@ describe( 'lightenHexColor', () => {
547
546
  expect( result ).toBe( '#8080ff' );
548
547
  } );
549
548
 
550
- it( 'lightens a theme color by 80%', () => {
549
+ it( 'lightens a color by 80%', () => {
551
550
  const result = lightenHexColor( '#98C8DF', 0.8 );
552
551
  // R: 152 + (255-152)*0.8 = 152 + 82.4 = 234
553
552
  // G: 200 + (255-200)*0.8 = 200 + 44 = 244
@@ -668,52 +667,6 @@ describe( 'parseHslString', () => {
668
667
  } );
669
668
  } );
670
669
 
671
- describe( 'parseRgbString', () => {
672
- describe( 'Valid RGB strings', () => {
673
- it( 'parses rgb(255, 0, 0) to #ff0000', () => {
674
- expect( parseRgbString( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
675
- } );
676
-
677
- it( 'parses rgb(0, 255, 0) to #00ff00', () => {
678
- expect( parseRgbString( 'rgb(0, 255, 0)' ) ).toBe( '#00ff00' );
679
- } );
680
-
681
- it( 'parses rgb(0, 0, 255) to #0000ff', () => {
682
- expect( parseRgbString( 'rgb(0, 0, 255)' ) ).toBe( '#0000ff' );
683
- } );
684
-
685
- it( 'parses rgb with no spaces', () => {
686
- expect( parseRgbString( 'rgb(128,128,128)' ) ).toBe( '#808080' );
687
- } );
688
-
689
- it( 'clamps values above 255', () => {
690
- expect( parseRgbString( 'rgb(300, 0, 0)' ) ).toBe( '#ff0000' );
691
- } );
692
-
693
- it( 'clamps negative values to 0', () => {
694
- expect( parseRgbString( 'rgb(-50, 0, 0)' ) ).toBe( '#000000' );
695
- } );
696
- } );
697
-
698
- describe( 'Invalid RGB strings', () => {
699
- it( 'returns null for hsl strings', () => {
700
- expect( parseRgbString( 'hsl(0, 100%, 50%)' ) ).toBeNull();
701
- } );
702
-
703
- it( 'returns null for hex colors', () => {
704
- expect( parseRgbString( '#ff0000' ) ).toBeNull();
705
- } );
706
-
707
- it( 'returns null for rgba strings', () => {
708
- expect( parseRgbString( 'rgba(255, 0, 0, 1)' ) ).toBeNull();
709
- } );
710
-
711
- it( 'returns null for empty string', () => {
712
- expect( parseRgbString( '' ) ).toBeNull();
713
- } );
714
- } );
715
- } );
716
-
717
670
  describe( 'normalizeColorToHex', () => {
718
671
  describe( 'Hex colors', () => {
719
672
  it( 'returns valid 6-digit hex as-is', () => {
@@ -0,0 +1,89 @@
1
+ import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
2
+
3
+ // `@wordpress/jest-console` registers the matcher but ships no types for it.
4
+ declare global {
5
+ // eslint-disable-next-line @typescript-eslint/no-namespace
6
+ namespace jest {
7
+ interface Matchers< R > {
8
+ toHaveWarned(): R;
9
+ }
10
+ }
11
+ }
12
+
13
+ describe( 'createDateFormatter', () => {
14
+ it( 'renders an invalid date rather than throwing', () => {
15
+ const format = createDateFormatter(
16
+ { month: 'short', day: 'numeric' },
17
+ { locale: 'de-DE', timeZone: 'Asia/Tokyo' }
18
+ );
19
+
20
+ expect( format( NaN ) ).toBe( 'Invalid Date' );
21
+ expect( format( new Date( 'nope' ) ) ).toBe( 'Invalid Date' );
22
+ } );
23
+ } );
24
+
25
+ describe( 'createZonedClock', () => {
26
+ it( 'reads calendar fields in the supplied zone', () => {
27
+ // 00:30 on Aug 3 in Tokyo.
28
+ expect( createZonedClock( 'Asia/Tokyo' )( new Date( '2026-08-02T15:30:00Z' ) ) ).toEqual( {
29
+ month: 8,
30
+ hour: 0,
31
+ } );
32
+ } );
33
+
34
+ it.each( [
35
+ [ 'with a zone', 'Asia/Tokyo' ],
36
+ [ 'without one', undefined ],
37
+ ] )( 'answers NaN for an invalid date %s', ( _label, timeZone ) => {
38
+ expect( createZonedClock( timeZone )( new Date( 'nope' ) ) ).toEqual( {
39
+ month: NaN,
40
+ hour: NaN,
41
+ } );
42
+ } );
43
+ } );
44
+
45
+ describe( 'sanitizeFormatting', () => {
46
+ it( 'keeps a usable locale and zone', () => {
47
+ const formatting = { locale: 'de-DE', timeZone: 'Asia/Tokyo' };
48
+
49
+ expect( sanitizeFormatting( formatting ) ).toEqual( formatting );
50
+ } );
51
+
52
+ it( 'keeps a UTC offset, which a WordPress site may store instead of a zone name', () => {
53
+ expect( sanitizeFormatting( { timeZone: '+05:30' } ) ).toEqual( {
54
+ locale: undefined,
55
+ timeZone: '+05:30',
56
+ } );
57
+ } );
58
+
59
+ it( 'repairs the underscored locale WordPress reports', () => {
60
+ expect( sanitizeFormatting( { locale: 'en_US', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
61
+ locale: 'en-US',
62
+ timeZone: 'Asia/Tokyo',
63
+ } );
64
+ } );
65
+
66
+ it( 'drops a locale no underscore swap can rescue, keeping the zone', () => {
67
+ expect( sanitizeFormatting( { locale: 'nope!', timeZone: 'Asia/Tokyo' } ) ).toEqual( {
68
+ locale: undefined,
69
+ timeZone: 'Asia/Tokyo',
70
+ } );
71
+ expect( console ).toHaveWarned();
72
+ } );
73
+
74
+ it( 'drops the empty zone a site set to a manual offset reports, keeping the locale', () => {
75
+ expect( sanitizeFormatting( { locale: 'de-DE', timeZone: '' } ) ).toEqual( {
76
+ locale: 'de-DE',
77
+ timeZone: undefined,
78
+ } );
79
+ expect( console ).toHaveWarned();
80
+ } );
81
+
82
+ it( 'drops both when neither is usable', () => {
83
+ expect( sanitizeFormatting( { locale: '', timeZone: 'Mars/Olympus' } ) ).toEqual( {
84
+ locale: undefined,
85
+ timeZone: undefined,
86
+ } );
87
+ expect( console ).toHaveWarned();
88
+ } );
89
+ } );
@@ -13,8 +13,11 @@ describe( 'Series styling utility functions', () => {
13
13
  options: {},
14
14
  };
15
15
 
16
+ // `getSeriesStroke` takes the resolved palette as an argument rather than reading it
17
+ // off the theme, so this is a plain array of colors.
18
+ const palette = [ '#FF0000', '#00FF00', '#0000FF' ];
19
+
16
20
  const mockTheme: Partial< ChartTheme > = {
17
- colors: [ '#FF0000', '#00FF00', '#0000FF' ],
18
21
  lineChart: {
19
22
  lineStyles: {
20
23
  comparison: {
@@ -40,17 +43,17 @@ describe( 'Series styling utility functions', () => {
40
43
  options: { stroke: '#CUSTOM' },
41
44
  };
42
45
 
43
- const result = getSeriesStroke( seriesWithStroke, 0, mockTheme.colors );
46
+ const result = getSeriesStroke( seriesWithStroke, 0, palette );
44
47
  expect( result ).toBe( '#CUSTOM' );
45
48
  } );
46
49
 
47
- it( 'returns theme color by index when no custom stroke', () => {
48
- const result = getSeriesStroke( mockSeriesData, 1, mockTheme.colors );
50
+ it( 'returns the palette color by index when no custom stroke', () => {
51
+ const result = getSeriesStroke( mockSeriesData, 1, palette );
49
52
  expect( result ).toBe( '#00FF00' ); // Second color
50
53
  } );
51
54
 
52
- it( 'wraps around theme colors when index exceeds array length', () => {
53
- const result = getSeriesStroke( mockSeriesData, 5, mockTheme.colors );
55
+ it( 'wraps around the palette when index exceeds its length', () => {
56
+ const result = getSeriesStroke( mockSeriesData, 5, palette );
54
57
  expect( result ).toBe( '#0000FF' ); // 5 % 3 = 2, third color
55
58
  } );
56
59
  } );
@@ -92,7 +95,7 @@ describe( 'Series styling utility functions', () => {
92
95
  } );
93
96
 
94
97
  it( 'returns empty object when no styles available', () => {
95
- const themeWithoutStyles = { colors: [ '#FF0000' ] } as ChartTheme;
98
+ const themeWithoutStyles = { tickLength: 4 } as ChartTheme;
96
99
  const result = getSeriesLineStyles( mockSeriesData, 0, themeWithoutStyles );
97
100
  expect( result ).toEqual( {} );
98
101
  } );
@@ -3,33 +3,24 @@ import type { ChartTheme } from '../../types';
3
3
 
4
4
  describe( 'mergeThemes', () => {
5
5
  const baseTheme: ChartTheme = {
6
- backgroundColor: '#FFFFFF',
7
- colors: [ '#red', '#blue' ],
8
6
  gridStyles: {
9
- stroke: '#DCDCDE',
10
7
  strokeWidth: 1,
11
8
  },
12
9
  tickLength: 4,
13
- gridColor: '',
14
- gridColorDark: '',
15
- leaderboardChart: {
16
- primaryColor: '#global-primary',
17
- secondaryColor: '#global-secondary',
10
+ seriesLineStyles: [ { strokeWidth: 1 }, { strokeWidth: 2 } ],
11
+ sparkline: {
12
+ strokeWidth: 1.5,
13
+ margin: { top: 2, right: 2, bottom: 2, left: 2 },
18
14
  },
19
15
  };
20
16
 
21
17
  const overrideTheme: ChartTheme = {
22
- backgroundColor: '#F0F0F0',
23
- colors: [ '#green', '#yellow' ],
24
18
  gridStyles: {
25
- stroke: '#000000',
26
19
  strokeWidth: 2,
27
20
  },
28
21
  tickLength: 8,
29
- gridColor: '',
30
- gridColorDark: '',
31
- leaderboardChart: {
32
- primaryColor: '#local-primary',
22
+ sparkline: {
23
+ strokeWidth: 3,
33
24
  },
34
25
  };
35
26
 
@@ -41,60 +32,41 @@ describe( 'mergeThemes', () => {
41
32
  it( 'should merge themes with local theme taking precedence for top-level properties', () => {
42
33
  const result = mergeThemes( baseTheme, overrideTheme );
43
34
 
44
- expect( result.backgroundColor ).toBe( '#F0F0F0' ); // from local
45
- expect( result.tickLength ).toBe( 8 ); // from local
46
- expect( result.colors ).toEqual( [ '#green', '#yellow' ] ); // from local (array replacement)
35
+ expect( result.tickLength ).toBe( 8 );
47
36
  } );
48
37
 
49
38
  it( 'should deeply merge nested objects with local theme taking precedence', () => {
50
39
  const result = mergeThemes( baseTheme, overrideTheme );
51
40
 
52
- // gridStyles should be merged
53
- expect( result.gridStyles ).toEqual( {
54
- stroke: '#000000', // from local
55
- strokeWidth: 2, // from local
56
- } );
41
+ expect( result.gridStyles ).toEqual( { strokeWidth: 2 } );
57
42
 
58
- // leaderboardChart should be merged
59
- expect( result.leaderboardChart ).toEqual( {
60
- primaryColor: '#local-primary', // from local
61
- secondaryColor: '#global-secondary', // from global (not overridden)
43
+ expect( result.sparkline ).toEqual( {
44
+ strokeWidth: 3, // from local
45
+ margin: { top: 2, right: 2, bottom: 2, left: 2 }, // from global (not overridden)
62
46
  } );
63
47
  } );
64
48
 
65
49
  it( 'should use global theme properties when not defined in local theme', () => {
66
50
  const partialLocalTheme: ChartTheme = {
67
- backgroundColor: '#F0F0F0',
68
- colors: [ '#green' ],
69
51
  tickLength: 8,
70
- gridColor: '',
71
- gridColorDark: '',
72
52
  };
73
53
 
74
54
  const result = mergeThemes( baseTheme, partialLocalTheme );
75
55
 
76
- expect( result.backgroundColor ).toBe( '#F0F0F0' ); // from local
77
- expect( result.gridStyles ).toEqual( baseTheme.gridStyles ); // from global
78
- expect( result.leaderboardChart ).toEqual( baseTheme.leaderboardChart ); // from global
56
+ expect( result.tickLength ).toBe( 8 );
57
+ expect( result.gridStyles ).toEqual( baseTheme.gridStyles );
58
+ expect( result.leaderboardChart ).toEqual( baseTheme.leaderboardChart );
79
59
  } );
80
60
 
81
61
  it( 'should handle array replacement correctly', () => {
82
- const globalWithColors: ChartTheme = {
83
- ...baseTheme,
84
- colors: [ '#red', '#blue', '#purple' ],
85
- };
86
-
87
- const localWithFewerColors: ChartTheme = {
62
+ const localWithFewerStyles: ChartTheme = {
88
63
  ...overrideTheme,
89
- colors: [ '#green' ],
64
+ seriesLineStyles: [ { strokeWidth: 5 } ],
90
65
  };
91
66
 
92
- const result = mergeThemes( globalWithColors, localWithFewerColors );
67
+ const result = mergeThemes( baseTheme, localWithFewerStyles );
93
68
 
94
69
  // Arrays should be replaced, not concatenated
95
- expect( result.colors ).toEqual( [ '#green' ] );
96
- expect( result.colors ).not.toContain( '#red' );
97
- expect( result.colors ).not.toContain( '#blue' );
98
- expect( result.colors ).not.toContain( '#purple' );
70
+ expect( result.seriesLineStyles ).toEqual( [ { strokeWidth: 5 } ] );
99
71
  } );
100
72
  } );
package/src/visx/types.ts CHANGED
@@ -24,8 +24,35 @@ export type XyChartTooltipProps< Datum extends object > = {
24
24
  verticalCrosshairStyle?: SVGProps< SVGLineElement >;
25
25
  horizontalCrosshairStyle?: SVGProps< SVGLineElement >;
26
26
  glyphStyle?: SVGProps< SVGCircleElement >;
27
+ /**
28
+ * Flip and clamp the tooltip box so it stays inside the nearest ancestor
29
+ * that clips its overflow, or the viewport when there is none. The box may
30
+ * leave the chart wrapper. (It used to keep a body-level portal inside the
31
+ * viewport.)
32
+ * @default true
33
+ */
34
+ detectBounds?: boolean;
35
+ /**
36
+ * Stacking order of the tooltip box inside the chart wrapper, which isolates
37
+ * its stacking context: the value never competes with page chrome outside
38
+ * the chart.
39
+ * @default 3
40
+ */
41
+ zIndex?: number;
42
+ /**
43
+ * @deprecated Accepted and ignored. The box renders inside the chart wrapper
44
+ * and moves with it, so it needs no scroll tracking.
45
+ */
46
+ scroll?: boolean;
47
+ /**
48
+ * @deprecated Accepted and ignored. Nothing measures the box any more, so
49
+ * there is no measurement to debounce.
50
+ */
51
+ debounce?: number;
52
+ /**
53
+ * @deprecated Accepted and ignored. No ResizeObserver is used.
54
+ */
27
55
  resizeObserverPolyfill?: UseTooltipPortalOptions[ 'polyfill' ];
28
- } & Omit< VisxTooltipProps, 'left' | 'top' | 'children' > &
29
- Pick< UseTooltipPortalOptions, 'debounce' | 'detectBounds' | 'scroll' | 'zIndex' >;
56
+ } & Omit< VisxTooltipProps, 'left' | 'top' | 'children' | 'applyPositionStyle' >;
30
57
 
31
58
  export type { LineStyles, GridStyles } from '@visx/xychart';
@@ -1,28 +0,0 @@
1
- import { renderHook } from '@testing-library/react';
2
- import { GlobalChartsProvider } from '../../providers/chart-context/global-charts-provider';
3
- import { ChartScopeContext } from '../../providers/chart-scope';
4
- import { useXYChartTheme } from '../use-xychart-theme';
5
- import type { ReactNode } from 'react';
6
-
7
- // A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches visx (SVG presentation attributes) through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-grid` override set inside the provider tree had no effect on the JS-resolved value — the two paths could disagree. After the fix, `providerTheme` carries the catalog pointer for an overridden role, so both paths resolve through the same CSS cascade and the nearer element wins.
8
- describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
9
- it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
10
- const nestedOverride = document.createElement( 'div' );
11
- nestedOverride.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
- document.body.appendChild( nestedOverride );
13
-
14
- const wrapper = ( { children }: { children: ReactNode } ) => (
15
- <GlobalChartsProvider theme={ { gridStyles: { stroke: 'rgb(255,0,0)' } } }>
16
- <ChartScopeContext.Provider value={ nestedOverride }>
17
- { children }
18
- </ChartScopeContext.Provider>
19
- </GlobalChartsProvider>
20
- );
21
-
22
- const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
23
-
24
- expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
25
-
26
- document.body.removeChild( nestedOverride );
27
- } );
28
- } );
@@ -1,87 +0,0 @@
1
- import type { ChartTheme } from '../../../types';
2
-
3
- // The consumer theme fields that correspond to an emitted catalog role, and the role each one overrides. Each role must be read by exactly the elements its field controlled before the role existed: `svgLabelSmall.fill` maps to the narrow `--a8c-charts-color-label-axis` rather than `--a8c-charts-color-label`, because the broad role is also read by legend labels, heatmap cell values, funnel labels and the line-chart tooltip, which that field never moved.
4
- const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
5
- [
6
- [ '--a8c-charts-color-background', theme => theme.backgroundColor ],
7
- [ '--a8c-charts-color-grid', theme => theme.gridStyles?.stroke ],
8
- [ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
9
- [ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
10
- [ '--a8c-charts-color-label-axis', theme => theme.svgLabelSmall?.fill ],
11
- ];
12
-
13
- /**
14
- * The variable a `theme` prop override is published as, one layer outside the role itself.
15
- *
16
- * `chart-scope.scss` declares each of these roles as `var(<role>-theme, <catalog default>)`, so an override that is invalid at computed-value time — a fallback-less `var()` naming a token the host never set — only invalidates this variable, and the role still resolves its mapped `--wpds-*` token. Published as the role directly, such a value took the role and every bare `var(<role>)` read site down with it.
17
- *
18
- * @param role - The catalog role being overridden.
19
- * @return The custom property carrying the consumer's value.
20
- */
21
- export const themeLayerVar = ( role: string ): string => `${ role }-theme`;
22
-
23
- /** Every role whose catalog entry must read a theme layer. `src/styles/test/chart-scope.test.ts` checks the stylesheet against this. */
24
- export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ role ] ) => role );
25
-
26
- /**
27
- * Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
28
- *
29
- * The dependency runs `<role>: var(<role>-theme, …)`, so a value naming `<role>` closes a cycle through the catalog entry. CSS marks every custom property in a cycle invalid at computed-value time — the role's own fallback is *not* used, so the token resolves to nothing and every chart loses that colour. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
30
- *
31
- * Three things this has to get right, none of which a plain substring test gets:
32
- *
33
- * `var()` permits whitespace after the opening paren, so testing for `var(<role>` misses `var( --a8c-charts-color-grid, … )`.
34
- *
35
- * Several roles are prefixes of others (`--a8c-charts-color-label` of `--a8c-charts-color-label-secondary`), and every role is a prefix of its own theme layer. A prefix match would read a legitimate cross-role pointer as a self-reference and silently drop the override, so the role must be followed by something that cannot continue an identifier — `-` included. A value naming `<role>-theme` is therefore not treated as a self-reference: that cycle is confined to the theme layer, which leaves the role free to fall back to the catalog default.
36
- *
37
- * `resolveCssVariable` also accepts a bare custom-property name with no `var()` wrapper (`--a8c-charts-color-grid`), so that is legal `theme` input too. It forms no cycle — custom properties accept arbitrary token streams — so it survives as a literal string and drops silently at the use site instead of falling back to the catalog default.
38
- *
39
- * @param value - The consumer's value for the field.
40
- * @param role - The catalog role this value would override.
41
- * @return True when the value reads `role`, so it must not be published.
42
- */
43
- const readsOwnRole = ( value: string, role: string ): boolean =>
44
- value.trim() === role ||
45
- new RegExp( `var\\(\\s*${ role.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) }(?![\\w-])` ).test(
46
- value
47
- );
48
-
49
- export type ThemeOverrides = {
50
- /** The custom properties to write on the provider wrapper. */
51
- vars: Record< string, string >;
52
- /** Every mapped role the consumer set a value for, whether or not it was published. */
53
- roles: readonly string[];
54
- };
55
-
56
- /**
57
- * Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colours read one source.
58
- *
59
- * A value that reads the role it would override is left unpublished — that is the default theme's own pointer surviving `mergeThemes`, and publishing it would invalidate the role. The role is still reported in `roles`: `withCatalogPointers` restores its theme field to the catalog pointer either way, so CSS and visx agree on the catalog default rather than visx painting a literal CSS never sees.
60
- *
61
- * @param theme - The consumer's `theme` prop, before merging with the default theme.
62
- * @return The wrapper's custom properties, and the roles the consumer overrode.
63
- */
64
- export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrides => {
65
- if ( ! theme ) {
66
- return { vars: {}, roles: [] };
67
- }
68
-
69
- const vars: Record< string, string > = {};
70
- const roles: string[] = [];
71
-
72
- for ( const [ role, read ] of ROLE_FOR_FIELD ) {
73
- const value = read( theme );
74
-
75
- if ( typeof value !== 'string' || value === '' ) {
76
- continue;
77
- }
78
-
79
- roles.push( role );
80
-
81
- if ( ! readsOwnRole( value, role ) ) {
82
- vars[ themeLayerVar( role ) ] = value;
83
- }
84
- }
85
-
86
- return { vars, roles };
87
- };