@automattic/charts 1.12.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/TOKENS.md +8 -1
- package/dist/index.cjs +2487 -3465
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +15 -18
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +452 -1429
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/charts/area-chart/area-chart.tsx +6 -7
- 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 +7 -7
- package/src/charts/line-chart/line-chart.module.scss +3 -3
- package/src/charts/line-chart/line-chart.tsx +6 -7
- package/src/charts/line-chart/test/line-chart.test.tsx +44 -4
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
- package/src/charts/private/test/time-axis.test.ts +427 -0
- package/src/charts/private/time-axis.ts +291 -23
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +52 -7
- package/src/charts/private/x-zoom/x-zoom.module.scss +7 -15
- package/src/charts/private/x-zoom/x-zoom.tsx +29 -40
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/legend/test/legend.test.tsx +2 -1
- package/src/components/tooltip/base-tooltip.module.scss +1 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +2 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +60 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +83 -0
- package/src/hooks/use-chart-margin.tsx +3 -4
- package/src/hooks/use-xychart-theme.ts +17 -11
- package/src/index.ts +1 -0
- package/src/types.ts +15 -0
- 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
|
@@ -2,6 +2,7 @@ import { curveCatmullRom, curveLinear, curveMonotoneX } from '@visx/curve';
|
|
|
2
2
|
import { scaleTime } from '@visx/scale';
|
|
3
3
|
import { differenceInHours, differenceInYears } from 'date-fns';
|
|
4
4
|
import type { useChartDataTransform } from '../../hooks';
|
|
5
|
+
import type { TickResolution } from '../../types';
|
|
5
6
|
import type { CurveType } from '../line-chart/types';
|
|
6
7
|
|
|
7
8
|
// Approximate min pixel width for an x-axis tick label.
|
|
@@ -32,38 +33,302 @@ export const getCurveType = ( type?: CurveType, smoothing?: boolean ): typeof cu
|
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
};
|
|
38
52
|
};
|
|
39
53
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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;
|
|
71
|
+
};
|
|
72
|
+
|
|
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;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Hour ticks with the date at midnight boundaries, so multi-day spans of
|
|
85
|
+
// sub-daily data keep their days identifiable.
|
|
86
|
+
const formatDateOrHourTick = boundaryFormat(
|
|
87
|
+
date => date.getHours() === 0 && date.getMinutes() === 0,
|
|
88
|
+
formatDateTick,
|
|
89
|
+
formatHourTick
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Month ticks with the year at January boundaries, for month-or-coarser
|
|
93
|
+
// buckets where a full "Sep 1" date would misread as a daily point.
|
|
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
|
+
};
|
|
43
116
|
};
|
|
44
117
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
118
|
+
// Smallest interval between consecutive points across all series, in hours.
|
|
119
|
+
// Infinity when no series has two points.
|
|
120
|
+
const getPointSpacingInHours = ( sortedData: ReturnType< typeof useChartDataTransform > ) => {
|
|
121
|
+
return sortedData.reduce(
|
|
122
|
+
( spacing, datom ) =>
|
|
123
|
+
datom.data.reduce( ( seriesSpacing, point, index ) => {
|
|
124
|
+
const previous = datom.data[ index - 1 ];
|
|
125
|
+
if ( previous?.date === undefined || point?.date === undefined ) {
|
|
126
|
+
return seriesSpacing;
|
|
127
|
+
}
|
|
128
|
+
return Math.min(
|
|
129
|
+
seriesSpacing,
|
|
130
|
+
Math.abs( differenceInHours( point.date, previous.date ) )
|
|
131
|
+
);
|
|
132
|
+
}, spacing ),
|
|
133
|
+
Number.POSITIVE_INFINITY
|
|
134
|
+
);
|
|
48
135
|
};
|
|
49
136
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
|
55
156
|
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
157
|
+
const spacingInHours = getPointSpacingInHours( sortedData );
|
|
158
|
+
if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
|
|
159
|
+
return 'hour';
|
|
59
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';
|
|
169
|
+
};
|
|
170
|
+
|
|
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
|
|
174
|
+
// span: hours within a day, hours with day boundaries for sub-daily data
|
|
175
|
+
// spanning up to a week, calendar dates within a year, otherwise years.
|
|
176
|
+
export const getFormatter = (
|
|
177
|
+
sortedData: ReturnType< typeof useChartDataTransform >,
|
|
178
|
+
tickResolution?: TickResolution
|
|
179
|
+
): TickFormat => {
|
|
180
|
+
const resolution = getBucketResolution( sortedData, tickResolution );
|
|
60
181
|
|
|
61
|
-
|
|
62
|
-
|
|
182
|
+
// The month regime only prints the year at January boundaries, so yearly
|
|
183
|
+
// buckets starting mid-year would render as bare month names.
|
|
184
|
+
if ( resolution === 'year' ) {
|
|
185
|
+
return formatYearTick;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if ( resolution === 'month' ) {
|
|
189
|
+
return formatMonthOrYearTick;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const span = getSpan( sortedData );
|
|
193
|
+
if ( ! span ) {
|
|
63
194
|
return formatDateTick;
|
|
64
195
|
}
|
|
65
196
|
|
|
66
|
-
|
|
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
|
+
}
|
|
206
|
+
|
|
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
|
+
}, [] );
|
|
222
|
+
|
|
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 ) ) ) );
|
|
233
|
+
}
|
|
234
|
+
|
|
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 );
|
|
243
|
+
}
|
|
244
|
+
|
|
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 [];
|
|
271
|
+
}
|
|
272
|
+
|
|
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 ] );
|
|
67
332
|
};
|
|
68
333
|
|
|
69
334
|
// Estimate the largest number of x-axis ticks that fit without producing
|
|
@@ -74,9 +339,12 @@ export const guessOptimalNumTicks = (
|
|
|
74
339
|
chartWidth: number,
|
|
75
340
|
tickFormatter: ( timestamp: number, index?: number, values?: unknown ) => string
|
|
76
341
|
) => {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
342
|
+
const span = getSpan( data );
|
|
343
|
+
if ( ! span ) {
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const xScale = scaleTime( { domain: [ span.minX, span.maxX ] } );
|
|
80
348
|
|
|
81
349
|
const upperBound = Math.min(
|
|
82
350
|
data[ 0 ]?.data.length || 3,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
1
|
+
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useRef } from 'react';
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
4
4
|
import { useXZoom, ZoomResetButton } from '../index';
|
|
5
5
|
import type { SingleChartRef } from '../../single-chart-context';
|
|
6
6
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
@@ -147,17 +147,35 @@ const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
|
|
|
147
147
|
event.preventDefault();
|
|
148
148
|
|
|
149
149
|
describe( 'ZoomResetButton', () => {
|
|
150
|
-
test( 'renders a labelled button
|
|
150
|
+
test( 'renders a labelled button without a native title tooltip', () => {
|
|
151
151
|
const noop = jest.fn();
|
|
152
152
|
render( <ZoomResetButton onClick={ noop } /> );
|
|
153
153
|
const button = screen.getByTestId( 'chart-zoom-reset' );
|
|
154
154
|
expect( button.tagName ).toBe( 'BUTTON' );
|
|
155
155
|
expect( button ).toHaveClass( 'x-zoom__reset' );
|
|
156
156
|
expect( button ).toHaveAccessibleName( 'Reset zoom' );
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
// IconButton renders a real tooltip, so the `title` fallback is gone —
|
|
158
|
+
// `title` is invisible to keyboard users and cannot be dismissed.
|
|
159
|
+
expect( button ).not.toHaveAttribute( 'title' );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
test( 'shows a tooltip on keyboard focus', async () => {
|
|
163
|
+
const noop = jest.fn();
|
|
164
|
+
render( <ZoomResetButton onClick={ noop } /> );
|
|
165
|
+
await userEvent.tab();
|
|
166
|
+
expect( screen.getByTestId( 'chart-zoom-reset' ) ).toHaveFocus();
|
|
167
|
+
// The button's only text is the tooltip's — its own label is an
|
|
168
|
+
// `aria-label` attribute, so this cannot match the trigger.
|
|
169
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
test( 'dismisses the tooltip on Escape', async () => {
|
|
173
|
+
const noop = jest.fn();
|
|
174
|
+
render( <ZoomResetButton onClick={ noop } /> );
|
|
175
|
+
await userEvent.tab();
|
|
176
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
177
|
+
await userEvent.keyboard( '{Escape}' );
|
|
178
|
+
await waitFor( () => expect( screen.queryByText( 'Reset zoom' ) ).not.toBeInTheDocument() );
|
|
161
179
|
} );
|
|
162
180
|
|
|
163
181
|
test( 'fires onClick when activated', async () => {
|
|
@@ -167,6 +185,33 @@ describe( 'ZoomResetButton', () => {
|
|
|
167
185
|
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
168
186
|
} );
|
|
169
187
|
|
|
188
|
+
test( 'leaves no orphaned tooltip when activation unmounts the button', async () => {
|
|
189
|
+
// Resetting the zoom unmounts this control while its tooltip is open.
|
|
190
|
+
// The tooltip renders in a portal outside the container, so a missed
|
|
191
|
+
// cleanup would strand it on the page rather than remove it with the
|
|
192
|
+
// button.
|
|
193
|
+
/**
|
|
194
|
+
* Mirrors the host charts: the reset control exists only while zoomed.
|
|
195
|
+
*
|
|
196
|
+
* @return JSX element or null.
|
|
197
|
+
*/
|
|
198
|
+
function Host() {
|
|
199
|
+
const [ zoomed, setZoomed ] = useState( true );
|
|
200
|
+
const unzoom = useCallback( () => setZoomed( false ), [] );
|
|
201
|
+
return zoomed ? <ZoomResetButton onClick={ unzoom } /> : null;
|
|
202
|
+
}
|
|
203
|
+
render( <Host /> );
|
|
204
|
+
await userEvent.tab();
|
|
205
|
+
await expect( screen.findByText( 'Reset zoom' ) ).resolves.toBeVisible();
|
|
206
|
+
|
|
207
|
+
await userEvent.keyboard( '{Enter}' );
|
|
208
|
+
|
|
209
|
+
await waitFor( () =>
|
|
210
|
+
expect( screen.queryByTestId( 'chart-zoom-reset' ) ).not.toBeInTheDocument()
|
|
211
|
+
);
|
|
212
|
+
expect( document.body ).not.toHaveTextContent( 'Reset zoom' );
|
|
213
|
+
} );
|
|
214
|
+
|
|
170
215
|
test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
|
|
171
216
|
const onClick = jest.fn();
|
|
172
217
|
// Mirrors the chart's grid wrapper, whose keyboard-navigation handler
|
|
@@ -11,26 +11,18 @@
|
|
|
11
11
|
pointer-events: none;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// The elevation shadow is deliberately kept for separation
|
|
15
|
+
// from chart content (CHARTS-237 design call, carrying
|
|
16
|
+
// over CHARTS-200's tokenization). `@wordpress/theme` 1.0.0
|
|
17
|
+
// dropped the `--wpds-elevation-*` group, so this role carries
|
|
18
|
+
// the former token's spec value directly rather than nesting
|
|
19
|
+
// a `--wpds-*` var.
|
|
18
20
|
&__reset {
|
|
19
21
|
position: absolute;
|
|
20
22
|
top: var(--wpds-dimension-gap-sm, 8px);
|
|
21
23
|
right: var(--wpds-dimension-gap-sm, 8px);
|
|
22
24
|
z-index: 2;
|
|
23
|
-
box-shadow: var(--
|
|
24
|
-
|
|
25
|
-
// `Button` is sized for a text label. These are the same three custom
|
|
26
|
-
// properties @wordpress/ui's own IconButton composition sets to make a
|
|
27
|
-
// square icon-only control; we set them directly because IconButton
|
|
28
|
-
// itself cannot be used here — it renders a Base UI tooltip, whose
|
|
29
|
-
// CommonJS `use-sync-external-store` dependency makes Rolldown emit a
|
|
30
|
-
// dynamic `require()` that throws in Script Module consumers.
|
|
31
|
-
--wp-ui-button-aspect-ratio: 1;
|
|
32
|
-
--wp-ui-button-padding-inline: 0;
|
|
33
|
-
--wp-ui-button-min-width: unset;
|
|
25
|
+
box-shadow: var(--a8c-charts-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
|
|
34
26
|
|
|
35
27
|
// WPDS outline-neutral is transparent-bodied at rest; give this
|
|
36
28
|
// floating control an opaque body so chart content doesn't show
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DataContext } from '@visx/xychart';
|
|
2
2
|
import { __ } from '@wordpress/i18n';
|
|
3
|
-
import {
|
|
3
|
+
import { IconButton } from '@wordpress/ui';
|
|
4
4
|
import { useCallback, useContext, useMemo, useState } from 'react';
|
|
5
5
|
import styles from './x-zoom.module.scss';
|
|
6
6
|
import type { SingleChartRef } from '../single-chart-context';
|
|
@@ -172,14 +172,8 @@ export function ZoomClip( {
|
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
174
|
* Visible icon-only reset control rendered as an HTML overlay on top of the
|
|
175
|
-
* chart container, using the WPDS `
|
|
176
|
-
* `position: relative` container so the button anchors correctly.
|
|
177
|
-
*
|
|
178
|
-
* `IconButton` would be the natural fit, but it renders a Base UI tooltip whose
|
|
179
|
-
* CommonJS `use-sync-external-store` dependency makes Rolldown emit a dynamic
|
|
180
|
-
* `require()` into `dist`, which throws on evaluation in WordPress Script
|
|
181
|
-
* Module consumers. `Button` gives the same treatment without that dependency;
|
|
182
|
-
* the tooltip is replaced by `aria-label` + `title`.
|
|
175
|
+
* chart container, using the WPDS `IconButton`. The host should wrap its SVG in
|
|
176
|
+
* a `position: relative` container so the button anchors correctly.
|
|
183
177
|
*
|
|
184
178
|
* @param props - Props.
|
|
185
179
|
* @param props.onClick - Click handler. Typically the `reset` from `useXZoom`.
|
|
@@ -194,44 +188,39 @@ export function ZoomResetButton( { onClick }: { onClick: () => void } ) {
|
|
|
194
188
|
event.stopPropagation();
|
|
195
189
|
}
|
|
196
190
|
}, [] );
|
|
197
|
-
const label = __( 'Reset zoom', 'jetpack-charts' );
|
|
198
191
|
return (
|
|
199
|
-
<
|
|
192
|
+
<IconButton
|
|
200
193
|
className={ styles[ 'x-zoom__reset' ] }
|
|
201
194
|
onKeyDown={ stopActivationKeys }
|
|
202
|
-
|
|
203
|
-
title={ label }
|
|
195
|
+
label={ __( 'Reset zoom', 'jetpack-charts' ) }
|
|
204
196
|
variant="outline"
|
|
205
197
|
tone="neutral"
|
|
206
198
|
size="small"
|
|
207
199
|
onClick={ onClick }
|
|
208
200
|
data-testid="chart-zoom-reset"
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
/>
|
|
235
|
-
</Button>
|
|
201
|
+
icon={
|
|
202
|
+
<svg
|
|
203
|
+
viewBox="0 0 24 24"
|
|
204
|
+
fill="none"
|
|
205
|
+
stroke="currentColor"
|
|
206
|
+
strokeWidth="2"
|
|
207
|
+
strokeLinecap="round"
|
|
208
|
+
strokeLinejoin="round"
|
|
209
|
+
aria-hidden="true"
|
|
210
|
+
focusable="false"
|
|
211
|
+
>
|
|
212
|
+
{ /*
|
|
213
|
+
Icons render edge-to-edge at 24px, so inset the glyph the
|
|
214
|
+
way @wordpress/icons glyphs do (drawn within roughly 4-20
|
|
215
|
+
of the viewBox, ~1.5px effective stroke).
|
|
216
|
+
*/ }
|
|
217
|
+
<g transform="translate(2.4 2.4) scale(0.8)">
|
|
218
|
+
<circle cx="10" cy="10" r="6" />
|
|
219
|
+
<line x1="15" y1="15" x2="20" y2="20" />
|
|
220
|
+
<line x1="7" y1="10" x2="13" y2="10" />
|
|
221
|
+
</g>
|
|
222
|
+
</svg>
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
236
225
|
);
|
|
237
226
|
}
|
|
@@ -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
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
color: #fff;
|
|
9
9
|
border-radius: var(--wpds-border-radius-md, 4px);
|
|
10
10
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
11
|
-
box-shadow: var(--
|
|
11
|
+
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
12
12
|
position: absolute;
|
|
13
13
|
pointer-events: none;
|
|
14
14
|
transform: translate(-50%, -100%);
|
|
@@ -74,8 +74,9 @@ describe( 'TrendIndicator', () => {
|
|
|
74
74
|
<TrendIndicator direction="up" value="+10%" style={ { fontSize: '2rem' } } />
|
|
75
75
|
);
|
|
76
76
|
|
|
77
|
+
// Note getComputedStyle converts lengths to px.
|
|
77
78
|
// eslint-disable-next-line testing-library/no-node-access
|
|
78
|
-
expect( container.firstChild ).toHaveStyle( { fontSize: '
|
|
79
|
+
expect( container.firstChild ).toHaveStyle( { fontSize: '32px' } );
|
|
79
80
|
} );
|
|
80
81
|
|
|
81
82
|
describe( 'accessibility', () => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
align-items: center;
|
|
4
4
|
gap: 0.125em;
|
|
5
5
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
6
|
-
font-weight: var(--wpds-typography-font-weight-
|
|
6
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 499);
|
|
7
7
|
line-height: 1;
|
|
8
8
|
|
|
9
9
|
// Trend colours use the --a8c-charts-color-trend-* catalog roles, falling back
|