@automattic/charts 2.0.0 → 3.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 +28 -0
- package/TOKENS.md +107 -48
- package/dist/index.cjs +584 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +97 -43
- package/dist/index.d.cts +46 -41
- package/dist/index.d.ts +46 -41
- package/dist/index.js +582 -210
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/area-chart/area-chart.module.scss +8 -3
- package/src/charts/area-chart/area-chart.tsx +3 -5
- package/src/charts/bar-chart/bar-chart.module.scss +14 -6
- package/src/charts/bar-chart/bar-chart.tsx +24 -15
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
- package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
- package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
- package/src/charts/geo-chart/geo-chart.tsx +12 -4
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
- package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
- package/src/charts/leaderboard-chart/types.ts +0 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -5
- package/src/charts/line-chart/line-chart.tsx +3 -5
- package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
- package/src/charts/pie-chart/pie-chart.tsx +4 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/time-axis.test.ts +247 -2
- package/src/charts/private/time-axis.ts +252 -63
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/components/legend/private/base-legend.tsx +3 -1
- package/src/components/legend/test/legend.test.tsx +2 -1
- package/src/components/tooltip/accessible-tooltip.tsx +4 -2
- package/src/components/tooltip/base-tooltip.tsx +8 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +22 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
- package/src/components/trend-indicator/trend-indicator.tsx +3 -0
- package/src/hooks/test/use-chart-margin.test.tsx +60 -0
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +89 -0
- package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
- package/src/hooks/use-chart-margin.tsx +3 -4
- package/src/hooks/use-xychart-theme.ts +19 -11
- package/src/index.ts +9 -2
- package/src/providers/chart-context/global-charts-provider.tsx +35 -12
- package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
- package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
- package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
- package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
- package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
- package/src/providers/chart-context/themes.ts +18 -27
- package/src/providers/chart-context/types.ts +0 -3
- package/src/providers/chart-scope/chart-scope-context.ts +8 -0
- package/src/providers/chart-scope/index.ts +3 -0
- package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
- package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
- package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
- package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
- package/src/providers/index.ts +1 -0
- package/src/styles/chart-scope-class.ts +6 -0
- package/src/styles/chart-scope.scss +83 -0
- package/src/styles/test/chart-scope.test.ts +165 -0
- package/src/types.ts +6 -40
- package/src/utils/index.ts +1 -1
- package/src/utils/resolve-css-var.ts +69 -28
- package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
- package/src/charts/private/grid-control/grid-control.module.scss +0 -8
- package/src/charts/private/grid-control/grid-control.tsx +0 -26
- package/src/charts/private/grid-control/index.ts +0 -1
- package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
|
@@ -33,37 +33,86 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
|
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Built once and reused: these run per bucket over the whole band domain when
|
|
37
|
+
// the axis picks its tick values, and `toLocaleDateString` rebuilds its
|
|
38
|
+
// formatter on every call once an options object is passed.
|
|
39
|
+
const dateTimeFormat = ( options: Intl.DateTimeFormatOptions ) => {
|
|
40
|
+
let formatter: Intl.DateTimeFormat;
|
|
41
|
+
return ( timestamp: number ) => {
|
|
42
|
+
// `Intl.DateTimeFormat` throws on an invalid date where the `Date`
|
|
43
|
+
// methods it replaces return "Invalid Date", and charts are expected to
|
|
44
|
+
// render past a bad point rather than fail.
|
|
45
|
+
// visx hands its tick formatter a `Date`, so coerce before the check.
|
|
46
|
+
if ( ! Number.isFinite( Number( timestamp ) ) ) {
|
|
47
|
+
return new Date( timestamp ).toLocaleDateString();
|
|
48
|
+
}
|
|
49
|
+
formatter = formatter ?? new Intl.DateTimeFormat( undefined, options );
|
|
50
|
+
return formatter.format( timestamp );
|
|
51
|
+
};
|
|
39
52
|
};
|
|
40
53
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
const formatYearTick = dateTimeFormat( { year: 'numeric' } );
|
|
55
|
+
|
|
56
|
+
const formatDateTick = dateTimeFormat( { month: 'short', day: 'numeric' } );
|
|
57
|
+
|
|
58
|
+
const formatHourTick = dateTimeFormat( { hour: 'numeric', hour12: true } );
|
|
59
|
+
|
|
60
|
+
const formatMonthTick = dateTimeFormat( { month: 'short' } );
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A tick format, carrying the boundary test that decides its coarser label.
|
|
64
|
+
*
|
|
65
|
+
* A band scale samples by index, so `getBandTickValues` has to steer ticks onto
|
|
66
|
+
* the boundaries a format prints the date or the year at. Hanging the test off
|
|
67
|
+
* the format that branches on it keeps the two from drifting apart.
|
|
68
|
+
*/
|
|
69
|
+
export type TickFormat = ( ( timestamp: number ) => string ) & {
|
|
70
|
+
isAnchor?: ( date: Date ) => boolean;
|
|
44
71
|
};
|
|
45
72
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
const boundaryFormat = (
|
|
74
|
+
isAnchor: ( date: Date ) => boolean,
|
|
75
|
+
atBoundary: ( timestamp: number ) => string,
|
|
76
|
+
between: ( timestamp: number ) => string
|
|
77
|
+
): TickFormat => {
|
|
78
|
+
const format: TickFormat = ( timestamp: number ) =>
|
|
79
|
+
isAnchor( new Date( timestamp ) ) ? atBoundary( timestamp ) : between( timestamp );
|
|
80
|
+
format.isAnchor = isAnchor;
|
|
81
|
+
return format;
|
|
49
82
|
};
|
|
50
83
|
|
|
51
84
|
// Hour ticks with the date at midnight boundaries, so multi-day spans of
|
|
52
85
|
// sub-daily data keep their days identifiable.
|
|
53
|
-
const formatDateOrHourTick = (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
};
|
|
86
|
+
const formatDateOrHourTick = boundaryFormat(
|
|
87
|
+
date => date.getHours() === 0 && date.getMinutes() === 0,
|
|
88
|
+
formatDateTick,
|
|
89
|
+
formatHourTick
|
|
90
|
+
);
|
|
59
91
|
|
|
60
92
|
// Month ticks with the year at January boundaries, for month-or-coarser
|
|
61
93
|
// buckets where a full "Sep 1" date would misread as a daily point.
|
|
62
|
-
const formatMonthOrYearTick = (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
const formatMonthOrYearTick = boundaryFormat(
|
|
95
|
+
date => date.getMonth() === 0,
|
|
96
|
+
formatYearTick,
|
|
97
|
+
formatMonthTick
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Overall time span of the data. Series with no dated points are dropped rather
|
|
101
|
+
// than folded in: an empty comparison series is legitimate, and one undefined
|
|
102
|
+
// bound would turn the whole span into NaN. Null when nothing is dated.
|
|
103
|
+
const getSpan = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
|
|
104
|
+
const bounds = sortedData
|
|
105
|
+
.map( datom => [ datom.data.at( 0 )?.date, datom.data.at( -1 )?.date ] )
|
|
106
|
+
.filter( ( [ first, last ] ) => first !== undefined && last !== undefined );
|
|
107
|
+
|
|
108
|
+
if ( ! bounds.length ) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
minX: Math.min( ...bounds.map( ( [ first ] ) => Number( first ) ) ),
|
|
114
|
+
maxX: Math.max( ...bounds.map( ( [ , last ] ) => Number( last ) ) ),
|
|
115
|
+
};
|
|
67
116
|
};
|
|
68
117
|
|
|
69
118
|
// Smallest interval between consecutive points across all series, in hours.
|
|
@@ -85,64 +134,201 @@ const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTran
|
|
|
85
134
|
);
|
|
86
135
|
};
|
|
87
136
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
// 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
|
|
138
|
+
// spring-forward DST transition.
|
|
139
|
+
const SUB_DAILY_SPACING_HOURS = 23;
|
|
140
|
+
|
|
141
|
+
// The shortest month, so monthly buckets clear it but weekly ones don't.
|
|
142
|
+
const MONTHLY_SPACING_HOURS = 28 * 24;
|
|
143
|
+
|
|
144
|
+
// The bucket size behind the data, as the resolution label the tick formats are
|
|
145
|
+
// keyed on. Exported so consumers that label a single point — bar chart
|
|
146
|
+
// tooltips — read the same classification the axis does instead of inferring
|
|
147
|
+
// the resolution a second way. Weeks report as 'day': both are calendar-date
|
|
148
|
+
// buckets as far as labelling goes.
|
|
149
|
+
export const getBucketResolution = (
|
|
150
|
+
sortedData: ReturnType< typeof useChartDataTransform >,
|
|
151
|
+
tickResolution?: TickResolution
|
|
152
|
+
): Exclude< TickResolution, 'week' > => {
|
|
153
|
+
if ( tickResolution ) {
|
|
154
|
+
return tickResolution === 'week' ? 'day' : tickResolution;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const spacingInHours = getPointSpacingInHours( sortedData );
|
|
158
|
+
if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
|
|
159
|
+
return 'hour';
|
|
160
|
+
}
|
|
161
|
+
// Fewer than two points leaves the spacing unknowable, so fall back to
|
|
162
|
+
// calendar dates rather than reading Infinity as a very coarse bucket.
|
|
163
|
+
if ( ! Number.isFinite( spacingInHours ) || spacingInHours < MONTHLY_SPACING_HOURS ) {
|
|
164
|
+
return 'day';
|
|
165
|
+
}
|
|
166
|
+
// Twelve shortest months: above any monthly gap (31 days at most) and below
|
|
167
|
+
// any yearly one (365 days at least).
|
|
168
|
+
return spacingInHours < 12 * MONTHLY_SPACING_HOURS ? 'month' : 'year';
|
|
97
169
|
};
|
|
98
170
|
|
|
99
|
-
// Pick the most informative tick formatter for the data's resolution and
|
|
171
|
+
// Pick the most informative tick formatter for the data's bucket resolution and
|
|
172
|
+
// time span. Month-or-coarser buckets are formatted from the resolution alone —
|
|
173
|
+
// they carry no day to print at any span — while finer buckets narrow with the
|
|
100
174
|
// span: hours within a day, hours with day boundaries for sub-daily data
|
|
101
|
-
// spanning up to a week, calendar dates
|
|
102
|
-
// year, months (with the year at January) for month-or-coarser buckets,
|
|
103
|
-
// otherwise just years. The resolution comes from `tickResolution` when the
|
|
104
|
-
// caller knows it, and is inferred from point spacing otherwise.
|
|
175
|
+
// spanning up to a week, calendar dates within a year, otherwise years.
|
|
105
176
|
export const getFormatter = (
|
|
106
177
|
sortedData: ReturnType< typeof useChartDataTransform >,
|
|
107
178
|
tickResolution?: TickResolution
|
|
108
|
-
) => {
|
|
179
|
+
): TickFormat => {
|
|
180
|
+
const resolution = getBucketResolution( sortedData, tickResolution );
|
|
181
|
+
|
|
109
182
|
// The month regime only prints the year at January boundaries, so yearly
|
|
110
|
-
// buckets starting mid-year would render as month names
|
|
111
|
-
|
|
112
|
-
if ( tickResolution === 'year' ) {
|
|
183
|
+
// buckets starting mid-year would render as bare month names.
|
|
184
|
+
if ( resolution === 'year' ) {
|
|
113
185
|
return formatYearTick;
|
|
114
186
|
}
|
|
115
187
|
|
|
116
|
-
|
|
117
|
-
|
|
188
|
+
if ( resolution === 'month' ) {
|
|
189
|
+
return formatMonthOrYearTick;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const span = getSpan( sortedData );
|
|
193
|
+
if ( ! span ) {
|
|
194
|
+
return formatDateTick;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const diffInHours = Math.abs( differenceInHours( span.maxX, span.minX ) );
|
|
198
|
+
if ( resolution === 'hour' ) {
|
|
199
|
+
if ( diffInHours <= 24 ) {
|
|
200
|
+
return formatHourTick;
|
|
201
|
+
}
|
|
202
|
+
if ( diffInHours <= 24 * 7 ) {
|
|
203
|
+
return formatDateOrHourTick;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
118
206
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
207
|
+
// Beyond a year, dates repeat often enough under tick sampling that the year
|
|
208
|
+
// is the only part worth printing.
|
|
209
|
+
return Math.abs( differenceInYears( span.maxX, span.minX ) ) <= 1
|
|
210
|
+
? formatDateTick
|
|
211
|
+
: formatYearTick;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Indices of the buckets whose label carries the coarser unit.
|
|
215
|
+
const getAnchorIndices = ( domain: Date[], isAnchor: ( date: Date ) => boolean ) =>
|
|
216
|
+
domain.reduce< number[] >( ( indices, date, index ) => {
|
|
217
|
+
if ( isAnchor( date ) ) {
|
|
218
|
+
indices.push( index );
|
|
219
|
+
}
|
|
220
|
+
return indices;
|
|
221
|
+
}, [] );
|
|
125
222
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
223
|
+
// Index strides worth sampling the domain at. Both roundings of each tick count
|
|
224
|
+
// are needed: flooring alone skips the stride that fits a mid-series anchor on
|
|
225
|
+
// short domains. Anchors add the strides that can land on more than one of them
|
|
226
|
+
// — those dividing their spacing, and the smallest whole multiple that still
|
|
227
|
+
// fits, which carries spans too long for any divisor to reach.
|
|
228
|
+
const getCandidateSteps = ( length: number, maxTicks: number, anchorSpacing: number | null ) => {
|
|
229
|
+
const steps = new Set< number >();
|
|
230
|
+
for ( let count = maxTicks; count > 1; count-- ) {
|
|
231
|
+
steps.add( Math.max( 1, Math.floor( ( length - 1 ) / ( count - 1 ) ) ) );
|
|
232
|
+
steps.add( Math.max( 1, Math.ceil( ( length - 1 ) / ( count - 1 ) ) ) );
|
|
129
233
|
}
|
|
130
234
|
|
|
131
|
-
if (
|
|
132
|
-
|
|
235
|
+
if ( anchorSpacing ) {
|
|
236
|
+
for ( let divisor = 1; divisor <= anchorSpacing; divisor++ ) {
|
|
237
|
+
if ( anchorSpacing % divisor === 0 ) {
|
|
238
|
+
steps.add( anchorSpacing / divisor );
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const minStep = Math.floor( ( length - 1 ) / Math.max( 1, maxTicks ) ) + 1;
|
|
242
|
+
steps.add( Math.ceil( minStep / anchorSpacing ) * anchorSpacing );
|
|
133
243
|
}
|
|
134
244
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
245
|
+
return steps;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Tick values for a band time axis.
|
|
250
|
+
*
|
|
251
|
+
* visx samples a band domain by index from offset zero, blind to which labels
|
|
252
|
+
* carry a boundary and without collapsing repeats — so the tick that prints the
|
|
253
|
+
* year or the date often isn't sampled at all, and a long series can show the
|
|
254
|
+
* same label twice. Choose the values instead: sweep the evenly spaced
|
|
255
|
+
* candidates, including those that step from anchor to anchor, and keep the one
|
|
256
|
+
* that reaches the most anchors without thinning the axis or putting two
|
|
257
|
+
* identical labels side by side.
|
|
258
|
+
*
|
|
259
|
+
* @param domain - Band domain, in axis order.
|
|
260
|
+
* @param tickFormatter - Formatter the axis will render these values with.
|
|
261
|
+
* @param maxTicks - Most ticks the axis should carry.
|
|
262
|
+
* @return Values to hand the axis as `tickValues`.
|
|
263
|
+
*/
|
|
264
|
+
export const getBandTickValues = (
|
|
265
|
+
domain: Date[],
|
|
266
|
+
tickFormatter: TickFormat,
|
|
267
|
+
maxTicks: number
|
|
268
|
+
): Date[] => {
|
|
269
|
+
if ( ! domain.length ) {
|
|
270
|
+
return [];
|
|
143
271
|
}
|
|
144
272
|
|
|
145
|
-
|
|
273
|
+
const { isAnchor } = tickFormatter;
|
|
274
|
+
const anchorIndices = isAnchor ? getAnchorIndices( domain, isAnchor ) : [];
|
|
275
|
+
|
|
276
|
+
// Once per bucket rather than once per bucket per candidate: the sweep reads
|
|
277
|
+
// the same labels back for every step and offset.
|
|
278
|
+
const domainLabels = domain.map( date => tickFormatter( date.getTime() ) );
|
|
279
|
+
|
|
280
|
+
const candidates: number[][] = [];
|
|
281
|
+
const consider = ( indices: number[] ) => {
|
|
282
|
+
if ( ! indices.length || indices.length > maxTicks ) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const repeats = indices.some(
|
|
286
|
+
( index, position ) =>
|
|
287
|
+
position > 0 && domainLabels[ index ] === domainLabels[ indices[ position - 1 ] ]
|
|
288
|
+
);
|
|
289
|
+
if ( ! repeats ) {
|
|
290
|
+
candidates.push( indices );
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const anchorSpacing = anchorIndices.length > 1 ? anchorIndices[ 1 ] - anchorIndices[ 0 ] : null;
|
|
295
|
+
for ( const step of getCandidateSteps( domain.length, maxTicks, anchorSpacing ) ) {
|
|
296
|
+
for ( let offset = 0; offset < step; offset++ ) {
|
|
297
|
+
const indices: number[] = [];
|
|
298
|
+
for ( let index = offset; index < domain.length; index += step ) {
|
|
299
|
+
indices.push( index );
|
|
300
|
+
}
|
|
301
|
+
consider( indices );
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Stepping the anchors themselves rather than the domain, so that anchors an
|
|
306
|
+
// uneven number of buckets apart are still all reachable — a wall-clock day
|
|
307
|
+
// is 23 buckets of hourly data across a spring-forward DST transition, and a
|
|
308
|
+
// fixed stride drifts off midnight for the rest of the span.
|
|
309
|
+
for ( let stride = 1; stride <= anchorIndices.length; stride++ ) {
|
|
310
|
+
consider( anchorIndices.filter( ( _, position ) => position % stride === 0 ) );
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if ( ! candidates.length ) {
|
|
314
|
+
return [ domain[ 0 ] ];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// An anchor is worth at most one tick. Ranking anchors above density outright
|
|
318
|
+
// collapsed ordinary monthly axes to two ticks — worse than sampling by index,
|
|
319
|
+
// which at least stayed dense — while ignoring anchors leaves the year unnamed.
|
|
320
|
+
const densest = candidates.reduce( ( most, indices ) => Math.max( most, indices.length ), 0 );
|
|
321
|
+
const anchorsIn = ( indices: number[] ) =>
|
|
322
|
+
isAnchor ? indices.filter( index => isAnchor( domain[ index ] ) ).length : 0;
|
|
323
|
+
|
|
324
|
+
const best = candidates
|
|
325
|
+
.filter( indices => indices.length >= densest - 1 )
|
|
326
|
+
.reduce( ( chosen, indices ) => {
|
|
327
|
+
const gain = anchorsIn( indices ) - anchorsIn( chosen );
|
|
328
|
+
return gain > 0 || ( gain === 0 && indices.length > chosen.length ) ? indices : chosen;
|
|
329
|
+
} );
|
|
330
|
+
|
|
331
|
+
return best.map( index => domain[ index ] );
|
|
146
332
|
};
|
|
147
333
|
|
|
148
334
|
// Estimate the largest number of x-axis ticks that fit without producing
|
|
@@ -153,9 +339,12 @@ export const guessOptimalNumTicks = (
|
|
|
153
339
|
chartWidth: number,
|
|
154
340
|
tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
|
|
155
341
|
) => {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
342
|
+
const span = getSpan( data );
|
|
343
|
+
if ( ! span ) {
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const xScale = scaleTime( { domain: [ span.minX, span.maxX ] } );
|
|
159
348
|
|
|
160
349
|
const upperBound = Math.min(
|
|
161
350
|
data[ 0 ]?.data.length || 3,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { ChartScopeContext } from '../../../../providers/chart-scope';
|
|
2
4
|
import { withResponsive } from '../index';
|
|
3
5
|
import type { BaseChartProps } from '../../../../types';
|
|
4
6
|
|
|
@@ -311,4 +313,18 @@ describe( 'withResponsive', () => {
|
|
|
311
313
|
expect( screen.getByTestId( 'responsive-container' ) ).toBeInTheDocument();
|
|
312
314
|
} );
|
|
313
315
|
} );
|
|
316
|
+
|
|
317
|
+
it( 'publishes the responsive wrapper element as the chart scope', () => {
|
|
318
|
+
// The catalog is declared on the GlobalChartsProvider wrapper (or a standalone component's own element), not on every chart root, so this wrapper doesn't carry a scope class itself — but it must still be the element the JS bridge resolves scoped CSS variables against, via ChartScopeContext, so it picks up any override set inside the provider tree the same way a CSS-painted sibling would.
|
|
319
|
+
const ScopeProbe = () => {
|
|
320
|
+
const scopeNode = useContext( ChartScopeContext );
|
|
321
|
+
return <span data-testid="scope-probe">{ scopeNode?.dataset.testid }</span>;
|
|
322
|
+
};
|
|
323
|
+
const Chart: ( props: BaseChartProps ) => JSX.Element = () => <ScopeProbe />;
|
|
324
|
+
const Wrapped = withResponsive( Chart );
|
|
325
|
+
|
|
326
|
+
render( <Wrapped data={ [] } /> );
|
|
327
|
+
|
|
328
|
+
expect( screen.getByTestId( 'scope-probe' ) ).toHaveTextContent( 'responsive-wrapper' );
|
|
329
|
+
} );
|
|
314
330
|
} );
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useParentSize } from '@visx/responsive';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ChartScopeContext } from '../../../providers/chart-scope';
|
|
4
5
|
import styles from './with-responsive.module.scss';
|
|
5
6
|
import type { BaseChartProps } from '../../../types';
|
|
6
7
|
import type { ComponentType } from 'react';
|
|
@@ -66,6 +67,9 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
66
67
|
|
|
67
68
|
const hasAspectRatio = aspectRatio !== undefined && aspectRatio > 0;
|
|
68
69
|
|
|
70
|
+
// The context needs a re-render when the node attaches; wrapperRef alone cannot provide that, so mirror the node into state from the same callback ref.
|
|
71
|
+
const [ scopeNode, setScopeNode ] = useState< HTMLDivElement | null >( null );
|
|
72
|
+
|
|
69
73
|
// Keep our own handle on the wrapper so we can read its live height below, and
|
|
70
74
|
// still hand the node to useParentSize's ref (a callback ref in practice; guard
|
|
71
75
|
// the object-ref shape too).
|
|
@@ -73,6 +77,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
73
77
|
const setWrapperRef = useCallback(
|
|
74
78
|
( node: HTMLDivElement | null ) => {
|
|
75
79
|
wrapperRef.current = node;
|
|
80
|
+
setScopeNode( node );
|
|
76
81
|
if ( typeof parentRef === 'function' ) {
|
|
77
82
|
parentRef( node );
|
|
78
83
|
} else if ( parentRef ) {
|
|
@@ -160,17 +165,19 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
160
165
|
...( height !== undefined ? { height } : null ),
|
|
161
166
|
} }
|
|
162
167
|
>
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
<ChartScopeContext.Provider value={ scopeNode }>
|
|
169
|
+
{ hasAspectRatio ? (
|
|
170
|
+
<div
|
|
171
|
+
data-testid="responsive-content"
|
|
172
|
+
className={ styles.content }
|
|
173
|
+
style={ { width: boxWidth, height: boxHeight } }
|
|
174
|
+
>
|
|
175
|
+
{ wrappedComponent }
|
|
176
|
+
</div>
|
|
177
|
+
) : (
|
|
178
|
+
wrappedComponent
|
|
179
|
+
) }
|
|
180
|
+
</ChartScopeContext.Provider>
|
|
174
181
|
</div>
|
|
175
182
|
);
|
|
176
183
|
};
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// The selection's translucency lives in the SVG opacity properties so
|
|
4
4
|
// the colors themselves can come straight from WPDS tokens.
|
|
5
5
|
&__selection {
|
|
6
|
-
fill: var(--a8c-charts-color-zoom-selection
|
|
6
|
+
fill: var(--a8c-charts-color-zoom-selection);
|
|
7
7
|
fill-opacity: 0.16;
|
|
8
|
-
stroke: var(--a8c-charts-color-zoom-selection-stroke
|
|
8
|
+
stroke: var(--a8c-charts-color-zoom-selection-stroke);
|
|
9
9
|
stroke-opacity: 0.65;
|
|
10
10
|
stroke-width: var(--wpds-border-width-xs, 1px);
|
|
11
11
|
pointer-events: none;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// through it. Scoped to the rest state so the WPDS
|
|
30
30
|
// hover/active/focus backgrounds still apply.
|
|
31
31
|
&:not(:hover, :active, :focus) {
|
|
32
|
-
background-color: var(--a8c-charts-color-
|
|
32
|
+
background-color: var(--a8c-charts-color-surface);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'react';
|
|
14
14
|
import { useTextTruncation } from '../../../hooks';
|
|
15
15
|
import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers';
|
|
16
|
+
import { useStandaloneScopeClass } from '../../../providers/chart-scope';
|
|
16
17
|
import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils';
|
|
17
18
|
import styles from './base-legend.module.scss';
|
|
18
19
|
import type { BaseLegendProps } from '../types';
|
|
@@ -102,6 +103,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
102
103
|
|
|
103
104
|
const theme = useGlobalChartsTheme();
|
|
104
105
|
const context = useContext( GlobalChartsContext );
|
|
106
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
105
107
|
|
|
106
108
|
const legendScale = scaleOrdinal( {
|
|
107
109
|
domain: items.map( item => item.label ),
|
|
@@ -189,7 +191,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
189
191
|
wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
|
|
190
192
|
role="list"
|
|
191
193
|
data-testid={ `legend-${ orientation }` }
|
|
192
|
-
className={ clsx( styles.legend, className ) }
|
|
194
|
+
className={ clsx( standaloneScopeClass, styles.legend, className ) }
|
|
193
195
|
style={ theme.legend?.containerStyles }
|
|
194
196
|
>
|
|
195
197
|
{ labels.map( ( label, i ) => {
|
|
@@ -188,7 +188,8 @@ describe( 'BaseLegend', () => {
|
|
|
188
188
|
);
|
|
189
189
|
const labels = screen.getAllByText( /Long Label/ );
|
|
190
190
|
labels.forEach( label => {
|
|
191
|
-
|
|
191
|
+
// Note getComputedStyle converts lengths to px.
|
|
192
|
+
expect( label ).toHaveStyle( { maxWidth: '160px' } );
|
|
192
193
|
} );
|
|
193
194
|
} );
|
|
194
195
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Tooltip, TooltipContext } from '@visx/xychart';
|
|
2
|
+
import clsx from 'clsx';
|
|
2
3
|
import { useContext, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
|
|
3
5
|
import type { SeriesData, DataPointDate } from '../../types';
|
|
4
6
|
import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
|
|
5
7
|
import type { ReactNode } from 'react';
|
|
@@ -128,7 +130,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
128
130
|
tabIndex={ -1 }
|
|
129
131
|
role="tooltip"
|
|
130
132
|
aria-atomic="true"
|
|
131
|
-
className={ keyboardFocusedClassName }
|
|
133
|
+
className={ clsx( CHART_SCOPE_CLASS, keyboardFocusedClassName ) }
|
|
132
134
|
data-testid={ `chart-tooltip-${ selectedIndex }` }
|
|
133
135
|
key={ `chart-tooltip-${ selectedIndex }` }
|
|
134
136
|
>
|
|
@@ -138,7 +140,7 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
return (
|
|
141
|
-
<div role="tooltip" aria-live="polite">
|
|
143
|
+
<div className={ CHART_SCOPE_CLASS } role="tooltip" aria-live="polite">
|
|
142
144
|
{ tooltipContent }
|
|
143
145
|
</div>
|
|
144
146
|
);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { formatNumber } from '@automattic/number-formatters';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
2
4
|
import styles from './base-tooltip.module.scss';
|
|
3
5
|
import type { CSSProperties, ComponentType, ReactNode } from 'react';
|
|
4
6
|
|
|
@@ -57,13 +59,18 @@ export const BaseTooltip = ( {
|
|
|
57
59
|
renderContainer = true,
|
|
58
60
|
}: BaseTooltipProps ) => {
|
|
59
61
|
const content = children || ( data && <Component data={ data } className={ className } /> );
|
|
62
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
60
63
|
|
|
61
64
|
if ( ! renderContainer ) {
|
|
62
65
|
return content;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
return (
|
|
66
|
-
<div
|
|
69
|
+
<div
|
|
70
|
+
className={ clsx( standaloneScopeClass, styles.tooltip ) }
|
|
71
|
+
style={ { top, left, ...style } }
|
|
72
|
+
role="tooltip"
|
|
73
|
+
>
|
|
67
74
|
{ content }
|
|
68
75
|
</div>
|
|
69
76
|
);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../../providers/chart-context/global-charts-provider';
|
|
3
|
+
import { CHART_SCOPE_CLASS } from '../../../styles/chart-scope-class';
|
|
2
4
|
import { TrendIndicator } from '../trend-indicator';
|
|
3
5
|
|
|
4
6
|
describe( 'TrendIndicator', () => {
|
|
@@ -74,8 +76,27 @@ describe( 'TrendIndicator', () => {
|
|
|
74
76
|
<TrendIndicator direction="up" value="+10%" style={ { fontSize: '2rem' } } />
|
|
75
77
|
);
|
|
76
78
|
|
|
79
|
+
// Note getComputedStyle converts lengths to px.
|
|
77
80
|
// eslint-disable-next-line testing-library/no-node-access
|
|
78
|
-
expect( container.firstChild ).toHaveStyle( { fontSize: '
|
|
81
|
+
expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
describe( 'chart scope class', () => {
|
|
85
|
+
it( 'carries the scope class when rendered with no provider above it', () => {
|
|
86
|
+
render( <TrendIndicator direction="up" value="+10%" /> );
|
|
87
|
+
|
|
88
|
+
expect( screen.getByLabelText( 'Increase: +10%' ) ).toHaveClass( CHART_SCOPE_CLASS );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
it( 'does not carry the scope class when rendered inside a GlobalChartsProvider', () => {
|
|
92
|
+
render(
|
|
93
|
+
<GlobalChartsProvider>
|
|
94
|
+
<TrendIndicator direction="up" value="+10%" />
|
|
95
|
+
</GlobalChartsProvider>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect( screen.getByLabelText( 'Increase: +10%' ) ).not.toHaveClass( CHART_SCOPE_CLASS );
|
|
99
|
+
} );
|
|
79
100
|
} );
|
|
80
101
|
|
|
81
102
|
describe( 'accessibility', () => {
|
|
@@ -6,19 +6,16 @@
|
|
|
6
6
|
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
7
7
|
line-height: 1;
|
|
8
8
|
|
|
9
|
-
// Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
|
|
10
|
-
// to the deprecated --charts-trend-*-color override API and then to the
|
|
11
|
-
// accessible WPDS semantic tokens.
|
|
12
9
|
&--up {
|
|
13
|
-
color: var(--a8c-charts-color-trend-up
|
|
10
|
+
color: var(--a8c-charts-color-trend-up);
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
&--down {
|
|
17
|
-
color: var(--a8c-charts-color-trend-down
|
|
14
|
+
color: var(--a8c-charts-color-trend-down);
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
&--neutral {
|
|
21
|
-
color: var(--a8c-charts-color-trend-neutral
|
|
18
|
+
color: var(--a8c-charts-color-trend-neutral);
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
&__icon {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import clsx from 'clsx';
|
|
2
|
+
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
2
3
|
import styles from './trend-indicator.module.scss';
|
|
3
4
|
import type { TrendIndicatorProps, TrendDirection } from './types';
|
|
4
5
|
|
|
@@ -47,10 +48,12 @@ export function TrendIndicator( {
|
|
|
47
48
|
showIcon = true,
|
|
48
49
|
}: TrendIndicatorProps ) {
|
|
49
50
|
const ariaLabel = `${ DIRECTION_LABELS[ direction ] }: ${ value }`;
|
|
51
|
+
const standaloneScopeClass = useStandaloneScopeClass();
|
|
50
52
|
|
|
51
53
|
return (
|
|
52
54
|
<span
|
|
53
55
|
className={ clsx(
|
|
56
|
+
standaloneScopeClass,
|
|
54
57
|
styles[ 'trend-indicator' ],
|
|
55
58
|
styles[ `trend-indicator--${ direction }` ],
|
|
56
59
|
className
|