@automattic/charts 3.2.0 → 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.
- package/CHANGELOG.md +29 -0
- package/TOKENS.md +53 -58
- package/dist/index.cjs +1012 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -29
- package/dist/index.d.cts +151 -179
- package/dist/index.d.ts +151 -179
- package/dist/index.js +1013 -544
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +2 -1
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +93 -24
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/types.ts +4 -1
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +1 -1
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +13 -15
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
- package/src/hooks/use-xychart-theme.ts +25 -9
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +37 -38
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +3 -13
- package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
- package/src/providers/chart-context/themes.ts +7 -52
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +39 -62
- package/src/styles/test/chart-scope.test.ts +19 -47
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +79 -166
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +174 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/visx/types.ts +29 -2
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
- 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
|
|
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,
|
|
46
|
+
const result = getSeriesStroke( seriesWithStroke, 0, palette );
|
|
44
47
|
expect( result ).toBe( '#CUSTOM' );
|
|
45
48
|
} );
|
|
46
49
|
|
|
47
|
-
it( 'returns
|
|
48
|
-
const result = getSeriesStroke( mockSeriesData, 1,
|
|
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
|
|
53
|
-
const result = getSeriesStroke( mockSeriesData, 5,
|
|
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 = {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
77
|
-
expect( result.gridStyles ).toEqual( baseTheme.gridStyles );
|
|
78
|
-
expect( result.leaderboardChart ).toEqual( baseTheme.leaderboardChart );
|
|
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
|
|
83
|
-
...baseTheme,
|
|
84
|
-
colors: [ '#red', '#blue', '#purple' ],
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const localWithFewerColors: ChartTheme = {
|
|
62
|
+
const localWithFewerStyles: ChartTheme = {
|
|
88
63
|
...overrideTheme,
|
|
89
|
-
|
|
64
|
+
seriesLineStyles: [ { strokeWidth: 5 } ],
|
|
90
65
|
};
|
|
91
66
|
|
|
92
|
-
const result = mergeThemes(
|
|
67
|
+
const result = mergeThemes( baseTheme, localWithFewerStyles );
|
|
93
68
|
|
|
94
69
|
// Arrays should be replaced, not concatenated
|
|
95
|
-
expect( result.
|
|
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 the values our own components read through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-background` 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-background', '#00ff00' );
|
|
12
|
-
document.body.appendChild( nestedOverride );
|
|
13
|
-
|
|
14
|
-
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
15
|
-
<GlobalChartsProvider theme={ { backgroundColor: '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.backgroundColor ).toBe( '#00ff00' );
|
|
25
|
-
|
|
26
|
-
document.body.removeChild( nestedOverride );
|
|
27
|
-
} );
|
|
28
|
-
} );
|