@automattic/charts 1.11.0 → 1.12.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 +10 -2
- package/TOKENS.md +12 -1
- package/dist/index.cjs +751 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +11 -28
- package/dist/index.d.cts +58 -6
- package/dist/index.d.ts +58 -6
- package/dist/index.js +751 -97
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/area-chart/area-chart.tsx +28 -8
- package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
- package/src/charts/area-chart/types.ts +20 -6
- package/src/charts/bar-chart/bar-chart.tsx +33 -9
- package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
- package/src/charts/line-chart/line-chart.tsx +40 -5
- package/src/charts/line-chart/test/line-chart.test.tsx +190 -1
- package/src/charts/line-chart/types.ts +14 -0
- package/src/charts/private/x-zoom/index.ts +1 -0
- package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +46 -3
- package/src/charts/private/x-zoom/x-zoom.module.scss +43 -0
- package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +52 -24
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
- package/src/components/legend/private/base-legend.tsx +21 -9
- package/src/components/legend/test/legend-config-types.test.tsx +32 -0
- package/src/components/legend/test/legend.test.tsx +121 -1
- package/src/components/legend/types.ts +6 -0
- package/src/index.ts +1 -0
- package/src/types.ts +16 -0
- package/src/charts/private/x-zoom.module.scss +0 -50
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-no-bind */
|
|
2
2
|
|
|
3
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
5
|
import { GlyphDiamond } from '@visx/glyph';
|
|
6
6
|
import { createElement, createRef } from 'react';
|
|
@@ -1294,4 +1294,193 @@ describe( 'LineChart', () => {
|
|
|
1294
1294
|
'url(#chart-zoom-clip-zoomtest)'
|
|
1295
1295
|
);
|
|
1296
1296
|
} );
|
|
1297
|
+
|
|
1298
|
+
describe( 'Legend group collapsing', () => {
|
|
1299
|
+
const comparisonPair = [
|
|
1300
|
+
{
|
|
1301
|
+
label: 'Views',
|
|
1302
|
+
group: 'views',
|
|
1303
|
+
data: [
|
|
1304
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1305
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1306
|
+
],
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
label: 'Views — previous',
|
|
1310
|
+
group: 'views',
|
|
1311
|
+
options: { type: 'comparison' as const },
|
|
1312
|
+
data: [
|
|
1313
|
+
{ date: new Date( '2024-01-01' ), value: 8, label: 'Jan 1' },
|
|
1314
|
+
{ date: new Date( '2024-01-02' ), value: 16, label: 'Jan 2' },
|
|
1315
|
+
],
|
|
1316
|
+
},
|
|
1317
|
+
];
|
|
1318
|
+
|
|
1319
|
+
it( 'renders one legend item per series by default', () => {
|
|
1320
|
+
renderWithTheme( {
|
|
1321
|
+
showLegend: true,
|
|
1322
|
+
chartId: 'legend-groups-default',
|
|
1323
|
+
data: comparisonPair,
|
|
1324
|
+
} );
|
|
1325
|
+
|
|
1326
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1327
|
+
expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
|
|
1328
|
+
} );
|
|
1329
|
+
|
|
1330
|
+
it( 'collapses a group to one item when legend.collapseGroups is set', () => {
|
|
1331
|
+
renderWithTheme( {
|
|
1332
|
+
showLegend: true,
|
|
1333
|
+
legend: { collapseGroups: true },
|
|
1334
|
+
chartId: 'legend-groups-collapsed',
|
|
1335
|
+
data: comparisonPair,
|
|
1336
|
+
} );
|
|
1337
|
+
|
|
1338
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1339
|
+
expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
|
|
1340
|
+
} );
|
|
1341
|
+
|
|
1342
|
+
it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
|
|
1343
|
+
const user = userEvent.setup();
|
|
1344
|
+
|
|
1345
|
+
renderWithTheme( {
|
|
1346
|
+
showLegend: true,
|
|
1347
|
+
legend: { interactive: true },
|
|
1348
|
+
chartId: 'legend-groups-interactive',
|
|
1349
|
+
data: comparisonPair,
|
|
1350
|
+
} );
|
|
1351
|
+
|
|
1352
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1353
|
+
await user.click( buttons[ 0 ] );
|
|
1354
|
+
|
|
1355
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1356
|
+
expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
1357
|
+
} );
|
|
1358
|
+
|
|
1359
|
+
it( 'toggles the whole group when interactive with collapseGroups', async () => {
|
|
1360
|
+
const user = userEvent.setup();
|
|
1361
|
+
|
|
1362
|
+
renderWithTheme( {
|
|
1363
|
+
showLegend: true,
|
|
1364
|
+
legend: { interactive: true, collapseGroups: true },
|
|
1365
|
+
chartId: 'legend-groups-interactive-collapsed',
|
|
1366
|
+
data: comparisonPair,
|
|
1367
|
+
} );
|
|
1368
|
+
|
|
1369
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1370
|
+
expect( buttons ).toHaveLength( 1 );
|
|
1371
|
+
|
|
1372
|
+
await user.click( buttons[ 0 ] );
|
|
1373
|
+
|
|
1374
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1375
|
+
expect(
|
|
1376
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1377
|
+
).toBeInTheDocument();
|
|
1378
|
+
} );
|
|
1379
|
+
} );
|
|
1380
|
+
|
|
1381
|
+
describe( 'Interactive Legend — value axis', () => {
|
|
1382
|
+
const twoSeries = [
|
|
1383
|
+
{
|
|
1384
|
+
label: 'Views',
|
|
1385
|
+
data: [
|
|
1386
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1387
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1388
|
+
],
|
|
1389
|
+
options: {},
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
label: 'Visitors',
|
|
1393
|
+
data: [
|
|
1394
|
+
{ date: new Date( '2024-01-01' ), value: 100, label: 'Jan 1' },
|
|
1395
|
+
{ date: new Date( '2024-01-02' ), value: 200, label: 'Jan 2' },
|
|
1396
|
+
],
|
|
1397
|
+
options: {},
|
|
1398
|
+
},
|
|
1399
|
+
];
|
|
1400
|
+
// Bare numbers inside the plot (scoped via the chart's grid role, away from visx's
|
|
1401
|
+
// off-screen text-measurement SVGs) are value-axis ticks: the series labels carry no digits
|
|
1402
|
+
// and the empty-state message is text-only.
|
|
1403
|
+
const numericTick = /^[\d,]+$/;
|
|
1404
|
+
|
|
1405
|
+
it( 'pins the value axis when rescaleYOnVisibilityChange is false', async () => {
|
|
1406
|
+
const user = userEvent.setup();
|
|
1407
|
+
|
|
1408
|
+
renderWithTheme( {
|
|
1409
|
+
showLegend: true,
|
|
1410
|
+
legend: { interactive: true },
|
|
1411
|
+
rescaleYOnVisibilityChange: false,
|
|
1412
|
+
chartId: 'line-stable-axis',
|
|
1413
|
+
data: twoSeries,
|
|
1414
|
+
} );
|
|
1415
|
+
|
|
1416
|
+
const chart = screen.getByRole( 'grid' );
|
|
1417
|
+
const ticksBefore = within( chart )
|
|
1418
|
+
.getAllByText( numericTick )
|
|
1419
|
+
.map( el => el.textContent )
|
|
1420
|
+
.sort();
|
|
1421
|
+
expect( ticksBefore.length ).toBeGreaterThan( 0 );
|
|
1422
|
+
|
|
1423
|
+
// Hide the high-range Visitors series; with a pinned domain the axis keeps its ticks.
|
|
1424
|
+
await user.click( screen.getAllByRole( 'button' )[ 1 ] );
|
|
1425
|
+
|
|
1426
|
+
const ticksAfter = within( chart )
|
|
1427
|
+
.getAllByText( numericTick )
|
|
1428
|
+
.map( el => el.textContent )
|
|
1429
|
+
.sort();
|
|
1430
|
+
expect( ticksAfter ).toEqual( ticksBefore );
|
|
1431
|
+
} );
|
|
1432
|
+
|
|
1433
|
+
it( 'rescales the value axis by default when a series is toggled off', async () => {
|
|
1434
|
+
const user = userEvent.setup();
|
|
1435
|
+
|
|
1436
|
+
renderWithTheme( {
|
|
1437
|
+
showLegend: true,
|
|
1438
|
+
legend: { interactive: true },
|
|
1439
|
+
chartId: 'line-rescale-axis',
|
|
1440
|
+
data: twoSeries,
|
|
1441
|
+
} );
|
|
1442
|
+
|
|
1443
|
+
const chart = screen.getByRole( 'grid' );
|
|
1444
|
+
const ticksBefore = within( chart )
|
|
1445
|
+
.getAllByText( numericTick )
|
|
1446
|
+
.map( el => el.textContent )
|
|
1447
|
+
.sort();
|
|
1448
|
+
expect( ticksBefore.length ).toBeGreaterThan( 0 );
|
|
1449
|
+
|
|
1450
|
+
// Default preserves the pre-existing behaviour: hiding the high-range Visitors series
|
|
1451
|
+
// lets the axis rescale to the remaining Views range, so its ticks change.
|
|
1452
|
+
await user.click( screen.getAllByRole( 'button' )[ 1 ] );
|
|
1453
|
+
|
|
1454
|
+
const ticksAfter = within( chart )
|
|
1455
|
+
.getAllByText( numericTick )
|
|
1456
|
+
.map( el => el.textContent )
|
|
1457
|
+
.sort();
|
|
1458
|
+
expect( ticksAfter ).not.toEqual( ticksBefore );
|
|
1459
|
+
} );
|
|
1460
|
+
|
|
1461
|
+
it( 'drops the axes when all series are hidden so they do not collapse', async () => {
|
|
1462
|
+
const user = userEvent.setup();
|
|
1463
|
+
|
|
1464
|
+
renderWithTheme( {
|
|
1465
|
+
showLegend: true,
|
|
1466
|
+
legend: { interactive: true },
|
|
1467
|
+
chartId: 'line-hidden-axes',
|
|
1468
|
+
data: twoSeries,
|
|
1469
|
+
} );
|
|
1470
|
+
|
|
1471
|
+
const chart = screen.getByRole( 'grid' );
|
|
1472
|
+
expect( within( chart ).getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
|
|
1473
|
+
|
|
1474
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1475
|
+
await user.click( buttons[ 0 ] );
|
|
1476
|
+
await user.click( buttons[ 1 ] );
|
|
1477
|
+
|
|
1478
|
+
// With no visible data the value scale would collapse, so the axes are removed rather
|
|
1479
|
+
// than rendered squished at the top — no tick labels remain.
|
|
1480
|
+
expect( within( chart ).queryAllByText( numericTick ) ).toHaveLength( 0 );
|
|
1481
|
+
expect(
|
|
1482
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1483
|
+
).toBeInTheDocument();
|
|
1484
|
+
} );
|
|
1485
|
+
} );
|
|
1297
1486
|
} );
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
BaseChartProps,
|
|
3
3
|
DataPointDate,
|
|
4
4
|
SeriesData,
|
|
5
|
+
SeriesChartLegendConfig,
|
|
5
6
|
AnnotationStyles,
|
|
6
7
|
DataPoint,
|
|
7
8
|
} from '../../types';
|
|
@@ -28,6 +29,10 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
|
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export interface LineChartProps extends BaseChartProps< SeriesData[] > {
|
|
32
|
+
/**
|
|
33
|
+
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
34
|
+
*/
|
|
35
|
+
legend?: SeriesChartLegendConfig;
|
|
31
36
|
withGradientFill: boolean;
|
|
32
37
|
smoothing?: boolean;
|
|
33
38
|
curveType?: CurveType;
|
|
@@ -47,6 +52,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
|
|
|
47
52
|
* button appears in the top-right of the chart while zoomed.
|
|
48
53
|
*/
|
|
49
54
|
zoomable?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the Y axis rescales to fit only the visible series when series are
|
|
57
|
+
* hidden or shown through the interactive legend.
|
|
58
|
+
* Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
|
|
59
|
+
* axis to the full data extent so hiding series does not move the chart's
|
|
60
|
+
* baseline — useful for comparison charts. Matches `AreaChart`.
|
|
61
|
+
* @default true
|
|
62
|
+
*/
|
|
63
|
+
rescaleYOnVisibilityChange?: boolean;
|
|
50
64
|
children?: ReactNode;
|
|
51
65
|
}
|
|
52
66
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useXZoom, ZoomClip, ZoomResetButton, ZoomSelectionRect } from './x-zoom';
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { act, renderHook } from '@testing-library/react';
|
|
1
|
+
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
2
3
|
import { useRef } from 'react';
|
|
3
|
-
import { useXZoom } from '../
|
|
4
|
-
import type { SingleChartRef } from '
|
|
4
|
+
import { useXZoom, ZoomResetButton } from '../index';
|
|
5
|
+
import type { SingleChartRef } from '../../single-chart-context';
|
|
5
6
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
7
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
6
8
|
|
|
7
9
|
// A fake scale that lets the hook convert pixel positions to data values.
|
|
8
10
|
// The hook only uses `.invert()`; everything else can be stubbed.
|
|
@@ -140,3 +142,44 @@ describe( 'useXZoom', () => {
|
|
|
140
142
|
expect( result.current.domain ).toBeNull();
|
|
141
143
|
} );
|
|
142
144
|
} );
|
|
145
|
+
|
|
146
|
+
const preventDefaultKeydown = ( event: ReactKeyboardEvent< HTMLDivElement > ) =>
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
|
|
149
|
+
describe( 'ZoomResetButton', () => {
|
|
150
|
+
test( 'renders a labelled button with a hover tooltip', () => {
|
|
151
|
+
const noop = jest.fn();
|
|
152
|
+
render( <ZoomResetButton onClick={ noop } /> );
|
|
153
|
+
const button = screen.getByTestId( 'chart-zoom-reset' );
|
|
154
|
+
expect( button.tagName ).toBe( 'BUTTON' );
|
|
155
|
+
expect( button ).toHaveClass( 'x-zoom__reset' );
|
|
156
|
+
expect( button ).toHaveAccessibleName( 'Reset zoom' );
|
|
157
|
+
// WPDS `IconButton` would supply a tooltip of its own, but it pulls in a
|
|
158
|
+
// CommonJS dependency that breaks Script Module consumers (see
|
|
159
|
+
// ZoomResetButton). `title` restores the hover hint on plain `Button`.
|
|
160
|
+
expect( button ).toHaveAttribute( 'title', 'Reset zoom' );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
test( 'fires onClick when activated', async () => {
|
|
164
|
+
const onClick = jest.fn();
|
|
165
|
+
render( <ZoomResetButton onClick={ onClick } /> );
|
|
166
|
+
await userEvent.click( screen.getByTestId( 'chart-zoom-reset' ) );
|
|
167
|
+
expect( onClick ).toHaveBeenCalledTimes( 1 );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
test( 'keyboard activation survives the chart wrapper keydown handler', async () => {
|
|
171
|
+
const onClick = jest.fn();
|
|
172
|
+
// Mirrors the chart's grid wrapper, whose keyboard-navigation handler
|
|
173
|
+
// calls preventDefault() on bubbled keydowns — which would cancel the
|
|
174
|
+
// native Enter/Space button activation.
|
|
175
|
+
render(
|
|
176
|
+
<div role="grid" tabIndex={ 0 } onKeyDown={ preventDefaultKeydown }>
|
|
177
|
+
<ZoomResetButton onClick={ onClick } />
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
act( () => screen.getByTestId( 'chart-zoom-reset' ).focus() );
|
|
181
|
+
await userEvent.keyboard( '{Enter}' );
|
|
182
|
+
await userEvent.keyboard( ' ' );
|
|
183
|
+
expect( onClick ).toHaveBeenCalledTimes( 2 );
|
|
184
|
+
} );
|
|
185
|
+
} );
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.x-zoom {
|
|
2
|
+
|
|
3
|
+
// The selection's translucency lives in the SVG opacity properties so
|
|
4
|
+
// the colors themselves can come straight from WPDS tokens.
|
|
5
|
+
&__selection {
|
|
6
|
+
fill: var(--a8c-charts-color-zoom-selection, var(--wpds-color-background-interactive-brand-strong, #3858e9));
|
|
7
|
+
fill-opacity: 0.16;
|
|
8
|
+
stroke: var(--a8c-charts-color-zoom-selection-stroke, var(--wpds-color-stroke-interactive-brand, #3858e9));
|
|
9
|
+
stroke-opacity: 0.65;
|
|
10
|
+
stroke-width: var(--wpds-border-width-xs, 1px);
|
|
11
|
+
pointer-events: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Overlay placement and icon-only geometry — the visual treatment (border,
|
|
15
|
+
// hover/focus, sizing) comes from the WPDS Button. The elevation shadow is
|
|
16
|
+
// deliberately kept for separation from chart content (CHARTS-237
|
|
17
|
+
// design call, carrying over CHARTS-200's tokenization).
|
|
18
|
+
&__reset {
|
|
19
|
+
position: absolute;
|
|
20
|
+
top: var(--wpds-dimension-gap-sm, 8px);
|
|
21
|
+
right: var(--wpds-dimension-gap-sm, 8px);
|
|
22
|
+
z-index: 2;
|
|
23
|
+
box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
|
|
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;
|
|
34
|
+
|
|
35
|
+
// WPDS outline-neutral is transparent-bodied at rest; give this
|
|
36
|
+
// floating control an opaque body so chart content doesn't show
|
|
37
|
+
// through it. Scoped to the rest state so the WPDS
|
|
38
|
+
// hover/active/focus backgrounds still apply.
|
|
39
|
+
&:not(:hover, :active, :focus) {
|
|
40
|
+
background-color: var(--a8c-charts-color-background, var(--wpds-color-background-surface-neutral-strong, #fff));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { DataContext } from '@visx/xychart';
|
|
2
2
|
import { __ } from '@wordpress/i18n';
|
|
3
|
+
import { Button } from '@wordpress/ui';
|
|
3
4
|
import { useCallback, useContext, useMemo, useState } from 'react';
|
|
4
5
|
import styles from './x-zoom.module.scss';
|
|
5
|
-
import type { SingleChartRef } from '
|
|
6
|
+
import type { SingleChartRef } from '../single-chart-context';
|
|
6
7
|
import type { AxisScale } from '@visx/axis';
|
|
7
8
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
8
|
-
import type { ReactNode, RefObject } from 'react';
|
|
9
|
+
import type { KeyboardEvent, ReactNode, RefObject } from 'react';
|
|
9
10
|
|
|
10
11
|
const MIN_DRAG_PIXELS = 6;
|
|
11
12
|
|
|
@@ -170,40 +171,67 @@ export function ZoomClip( {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
/**
|
|
173
|
-
* Visible icon-only reset
|
|
174
|
-
*
|
|
175
|
-
* container so the button anchors correctly.
|
|
174
|
+
* Visible icon-only reset control rendered as an HTML overlay on top of the
|
|
175
|
+
* chart container, using the WPDS `Button`. The host should wrap its SVG in a
|
|
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`.
|
|
176
183
|
*
|
|
177
184
|
* @param props - Props.
|
|
178
185
|
* @param props.onClick - Click handler. Typically the `reset` from `useXZoom`.
|
|
179
186
|
* @return JSX element.
|
|
180
187
|
*/
|
|
181
188
|
export function ZoomResetButton( { onClick }: { onClick: () => void } ) {
|
|
189
|
+
// The chart's keyboard-navigation wrapper calls preventDefault() on
|
|
190
|
+
// bubbled keydowns, which would cancel the native Enter/Space button
|
|
191
|
+
// activation. Keep activation keys out of its reach.
|
|
192
|
+
const stopActivationKeys = useCallback( ( event: KeyboardEvent< HTMLButtonElement > ) => {
|
|
193
|
+
if ( event.key === 'Enter' || event.key === ' ' ) {
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
}
|
|
196
|
+
}, [] );
|
|
182
197
|
const label = __( 'Reset zoom', 'jetpack-charts' );
|
|
183
198
|
return (
|
|
184
|
-
<
|
|
185
|
-
type="button"
|
|
199
|
+
<Button
|
|
186
200
|
className={ styles[ 'x-zoom__reset' ] }
|
|
187
|
-
|
|
201
|
+
onKeyDown={ stopActivationKeys }
|
|
188
202
|
aria-label={ label }
|
|
189
203
|
title={ label }
|
|
204
|
+
variant="outline"
|
|
205
|
+
tone="neutral"
|
|
206
|
+
size="small"
|
|
207
|
+
onClick={ onClick }
|
|
190
208
|
data-testid="chart-zoom-reset"
|
|
191
209
|
>
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
210
|
+
<Button.Icon
|
|
211
|
+
icon={
|
|
212
|
+
<svg
|
|
213
|
+
viewBox="0 0 24 24"
|
|
214
|
+
fill="none"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
strokeWidth="2"
|
|
217
|
+
strokeLinecap="round"
|
|
218
|
+
strokeLinejoin="round"
|
|
219
|
+
aria-hidden="true"
|
|
220
|
+
focusable="false"
|
|
221
|
+
>
|
|
222
|
+
{ /*
|
|
223
|
+
Icons render edge-to-edge at 24px, so inset the glyph the
|
|
224
|
+
way @wordpress/icons glyphs do (drawn within roughly 4-20
|
|
225
|
+
of the viewBox, ~1.5px effective stroke).
|
|
226
|
+
*/ }
|
|
227
|
+
<g transform="translate(2.4 2.4) scale(0.8)">
|
|
228
|
+
<circle cx="10" cy="10" r="6" />
|
|
229
|
+
<line x1="15" y1="15" x2="20" y2="20" />
|
|
230
|
+
<line x1="7" y1="10" x2="13" y2="10" />
|
|
231
|
+
</g>
|
|
232
|
+
</svg>
|
|
233
|
+
}
|
|
234
|
+
/>
|
|
235
|
+
</Button>
|
|
208
236
|
);
|
|
209
237
|
}
|
|
@@ -265,4 +265,156 @@ describe( 'useChartLegendItems', () => {
|
|
|
265
265
|
expect( result.current[ 0 ].color ).toBeDefined();
|
|
266
266
|
} );
|
|
267
267
|
} );
|
|
268
|
+
|
|
269
|
+
describe( 'Group collapsing (SeriesData)', () => {
|
|
270
|
+
const groupedComparisonData: SeriesData[] = [
|
|
271
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
272
|
+
{
|
|
273
|
+
label: 'Views — previous',
|
|
274
|
+
group: 'views',
|
|
275
|
+
options: { type: 'comparison' as const },
|
|
276
|
+
data: [ { label: 'Mon', value: 90 } ],
|
|
277
|
+
},
|
|
278
|
+
{ label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
|
|
279
|
+
{
|
|
280
|
+
label: 'Visitors — previous',
|
|
281
|
+
group: 'visitors',
|
|
282
|
+
options: { type: 'comparison' as const },
|
|
283
|
+
data: [ { label: 'Mon', value: 45 } ],
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
describe( 'by default', () => {
|
|
288
|
+
test( 'keeps one item per series for a comparison group', () => {
|
|
289
|
+
const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
|
|
290
|
+
wrapper,
|
|
291
|
+
} );
|
|
292
|
+
|
|
293
|
+
expect( result.current ).toHaveLength( 4 );
|
|
294
|
+
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
295
|
+
'Views',
|
|
296
|
+
'Views — previous',
|
|
297
|
+
'Visitors',
|
|
298
|
+
'Visitors — previous',
|
|
299
|
+
] );
|
|
300
|
+
} );
|
|
301
|
+
|
|
302
|
+
test( 'gives each item only its own series label', () => {
|
|
303
|
+
const { result } = renderHook( () => useChartLegendItems( groupedComparisonData ), {
|
|
304
|
+
wrapper,
|
|
305
|
+
} );
|
|
306
|
+
|
|
307
|
+
expect( result.current.map( item => item.seriesLabels ) ).toEqual( [
|
|
308
|
+
[ 'Views' ],
|
|
309
|
+
[ 'Views — previous' ],
|
|
310
|
+
[ 'Visitors' ],
|
|
311
|
+
[ 'Visitors — previous' ],
|
|
312
|
+
] );
|
|
313
|
+
} );
|
|
314
|
+
|
|
315
|
+
test( 'preserves the original series order for interleaved groups', () => {
|
|
316
|
+
// `group` predates collapsing and was used for colour coordination, so existing
|
|
317
|
+
// charts may interleave grouped series. The default legend must not reorder them.
|
|
318
|
+
const interleaved: SeriesData[] = [
|
|
319
|
+
{ label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
|
|
320
|
+
{ label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
|
|
321
|
+
{
|
|
322
|
+
label: 'Views — previous',
|
|
323
|
+
group: 'views',
|
|
324
|
+
options: { type: 'comparison' as const },
|
|
325
|
+
data: [ { label: 'Mon', value: 90 } ],
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
label: 'Visitors — previous',
|
|
329
|
+
group: 'visitors',
|
|
330
|
+
options: { type: 'comparison' as const },
|
|
331
|
+
data: [ { label: 'Mon', value: 45 } ],
|
|
332
|
+
},
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
const { result } = renderHook( () => useChartLegendItems( interleaved ), { wrapper } );
|
|
336
|
+
|
|
337
|
+
expect( result.current.map( item => item.label ) ).toEqual( [
|
|
338
|
+
'Views',
|
|
339
|
+
'Visitors',
|
|
340
|
+
'Views — previous',
|
|
341
|
+
'Visitors — previous',
|
|
342
|
+
] );
|
|
343
|
+
} );
|
|
344
|
+
} );
|
|
345
|
+
|
|
346
|
+
describe( 'with collapseGroups enabled', () => {
|
|
347
|
+
const collapsed = () =>
|
|
348
|
+
renderHook( () => useChartLegendItems( groupedComparisonData, { collapseGroups: true } ), {
|
|
349
|
+
wrapper,
|
|
350
|
+
} );
|
|
351
|
+
|
|
352
|
+
test( 'collapses series that share a group into one item per group', () => {
|
|
353
|
+
const { result } = collapsed();
|
|
354
|
+
|
|
355
|
+
expect( result.current ).toHaveLength( 2 );
|
|
356
|
+
} );
|
|
357
|
+
|
|
358
|
+
test( 'uses the primary (non-comparison) series label for the grouped item', () => {
|
|
359
|
+
const { result } = collapsed();
|
|
360
|
+
|
|
361
|
+
expect( result.current[ 0 ].label ).toBe( 'Views' );
|
|
362
|
+
expect( result.current[ 1 ].label ).toBe( 'Visitors' );
|
|
363
|
+
} );
|
|
364
|
+
|
|
365
|
+
test( 'exposes all member series labels via seriesLabels, primary first', () => {
|
|
366
|
+
const { result } = collapsed();
|
|
367
|
+
|
|
368
|
+
expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'Views', 'Views — previous' ] );
|
|
369
|
+
expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Visitors', 'Visitors — previous' ] );
|
|
370
|
+
} );
|
|
371
|
+
|
|
372
|
+
test( 'collapses a shared group that has no comparison series', () => {
|
|
373
|
+
const sharedGroupNoComparison: SeriesData[] = [
|
|
374
|
+
{ label: 'A', group: 'metrics', data: [ { label: 'x', value: 1 } ] },
|
|
375
|
+
{ label: 'B', group: 'metrics', data: [ { label: 'x', value: 2 } ] },
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
const { result } = renderHook(
|
|
379
|
+
() => useChartLegendItems( sharedGroupNoComparison, { collapseGroups: true } ),
|
|
380
|
+
{ wrapper }
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect( result.current ).toHaveLength( 1 );
|
|
384
|
+
expect( result.current[ 0 ].label ).toBe( 'A' );
|
|
385
|
+
expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'A', 'B' ] );
|
|
386
|
+
} );
|
|
387
|
+
|
|
388
|
+
test( 'does not collapse series with distinct group values', () => {
|
|
389
|
+
const distinctGroups: SeriesData[] = [
|
|
390
|
+
{ label: 'A', group: 'a', data: [ { label: 'x', value: 1 } ] },
|
|
391
|
+
{ label: 'B', group: 'b', data: [ { label: 'x', value: 2 } ] },
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
const { result } = renderHook(
|
|
395
|
+
() => useChartLegendItems( distinctGroups, { collapseGroups: true } ),
|
|
396
|
+
{ wrapper }
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect( result.current ).toHaveLength( 2 );
|
|
400
|
+
expect( result.current.map( item => item.label ) ).toEqual( [ 'A', 'B' ] );
|
|
401
|
+
} );
|
|
402
|
+
|
|
403
|
+
test( 'leaves ungrouped series as individual items with their own seriesLabels', () => {
|
|
404
|
+
const ungrouped: SeriesData[] = [
|
|
405
|
+
{ label: 'One', data: [ { label: 'x', value: 1 } ] },
|
|
406
|
+
{ label: 'Two', data: [ { label: 'x', value: 2 } ] },
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
const { result } = renderHook(
|
|
410
|
+
() => useChartLegendItems( ungrouped, { collapseGroups: true } ),
|
|
411
|
+
{ wrapper }
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect( result.current ).toHaveLength( 2 );
|
|
415
|
+
expect( result.current[ 0 ].seriesLabels ).toEqual( [ 'One' ] );
|
|
416
|
+
expect( result.current[ 1 ].seriesLabels ).toEqual( [ 'Two' ] );
|
|
417
|
+
} );
|
|
418
|
+
} );
|
|
419
|
+
} );
|
|
268
420
|
} );
|