@automattic/charts 3.0.0 → 3.1.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 +18 -0
- package/TOKENS.md +3 -3
- package/dist/index.cjs +250 -136
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +23 -14
- package/dist/index.d.cts +51 -14
- package/dist/index.d.ts +51 -14
- package/dist/index.js +251 -138
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +26 -26
- package/src/charts/area-chart/private/overlays.tsx +3 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +346 -11
- package/src/charts/area-chart/types.ts +4 -3
- package/src/charts/bar-chart/bar-chart.tsx +21 -15
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
- package/src/charts/bar-chart/test/bar-chart.test.tsx +277 -2
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +6 -6
- package/src/charts/geo-chart/geo-chart.module.scss +8 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +9 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +31 -8
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
- package/src/charts/heatmap-chart/types.ts +8 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
- package/src/charts/line-chart/line-chart.module.scss +2 -2
- package/src/charts/line-chart/line-chart.tsx +33 -29
- package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
- package/src/charts/line-chart/test/line-chart.test.tsx +313 -5
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/pie-chart/pie-chart.tsx +8 -13
- package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +8 -13
- package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
- package/src/charts/private/chart-instance-context/index.ts +3 -0
- package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
- package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
- package/src/charts/private/svg-empty-state/index.ts +1 -0
- package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
- package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
- package/src/components/legend/legend.tsx +4 -4
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/legend/private/base-legend.tsx +67 -18
- package/src/components/legend/test/legend.test.tsx +100 -4
- package/src/components/tooltip/accessible-tooltip.tsx +13 -2
- package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
- package/src/hooks/index.ts +1 -1
- package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
- package/src/index.ts +3 -1
- package/src/providers/chart-context/global-charts-provider.tsx +92 -18
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +142 -0
- package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
- package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
- package/src/providers/chart-context/types.ts +7 -1
- package/src/styles/chart-scope.scss +2 -2
- package/src/styles/test/wpds-fallbacks.test.ts +131 -0
- package/src/types.ts +12 -0
- package/src/charts/private/single-chart-context/index.ts +0 -8
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { _x } from '@wordpress/i18n';
|
|
2
|
+
|
|
3
|
+
export type HiddenChartItemType = 'series' | 'segments';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Return the empty-state message for a chart whose items are all hidden.
|
|
7
|
+
*
|
|
8
|
+
* Each translation call has a distinct gettext context. This prevents Terser
|
|
9
|
+
* from folding conditional calls into one call with a conditional msgid, which
|
|
10
|
+
* would make the strings unavailable to static translation extraction.
|
|
11
|
+
*
|
|
12
|
+
* @param interactive - Whether the legend can be used to show hidden items.
|
|
13
|
+
* @param itemType - The chart-specific name for its data items.
|
|
14
|
+
* @return The translated empty-state message.
|
|
15
|
+
*/
|
|
16
|
+
export const getAllHiddenMessage = (
|
|
17
|
+
interactive: boolean,
|
|
18
|
+
itemType: HiddenChartItemType
|
|
19
|
+
): string => {
|
|
20
|
+
if ( itemType === 'segments' ) {
|
|
21
|
+
if ( interactive ) {
|
|
22
|
+
return _x(
|
|
23
|
+
'All segments are hidden. Click legend items to show data.',
|
|
24
|
+
'chart empty state: interactive segments',
|
|
25
|
+
'jetpack-charts'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return _x( 'All segments are hidden.', 'chart empty state: segments', 'jetpack-charts' );
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if ( interactive ) {
|
|
33
|
+
return _x(
|
|
34
|
+
'All series are hidden. Click legend items to show data.',
|
|
35
|
+
'chart empty state: interactive series',
|
|
36
|
+
'jetpack-charts'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return _x( 'All series are hidden.', 'chart empty state: series', 'jetpack-charts' );
|
|
41
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getAllHiddenMessage } from '../get-all-hidden-message';
|
|
2
|
+
|
|
3
|
+
describe( 'getAllHiddenMessage', () => {
|
|
4
|
+
it.each( [
|
|
5
|
+
[ false, 'series', 'All series are hidden.' ],
|
|
6
|
+
[ true, 'series', 'All series are hidden. Click legend items to show data.' ],
|
|
7
|
+
[ false, 'segments', 'All segments are hidden.' ],
|
|
8
|
+
[ true, 'segments', 'All segments are hidden. Click legend items to show data.' ],
|
|
9
|
+
] as const )(
|
|
10
|
+
'returns the expected message for interactive=%s %s',
|
|
11
|
+
( interactive, type, message ) => {
|
|
12
|
+
expect( getAllHiddenMessage( interactive, type ) ).toBe( message );
|
|
13
|
+
}
|
|
14
|
+
);
|
|
15
|
+
} );
|
|
@@ -2,7 +2,7 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { useCallback, useRef, useState } from 'react';
|
|
4
4
|
import { useXZoom, ZoomResetButton } from '../index';
|
|
5
|
-
import type {
|
|
5
|
+
import type { ChartInstanceRef } from '../../chart-instance-context';
|
|
6
6
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
7
7
|
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ const makeChartRef = (
|
|
|
23
23
|
getScales: () => ( scale ? { xScale: scale, yScale: scale } : null ),
|
|
24
24
|
getChartDimensions: () => ( { width: 0, height: 0, margin: {} } ),
|
|
25
25
|
},
|
|
26
|
-
} ) as unknown as ReturnType< typeof useRef<
|
|
26
|
+
} ) as unknown as ReturnType< typeof useRef< ChartInstanceRef > >;
|
|
27
27
|
|
|
28
28
|
const makeParams = ( x: number ): EventHandlerParams< object > =>
|
|
29
29
|
( {
|
|
@@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
|
|
|
3
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
|
-
import type {
|
|
6
|
+
import type { ChartInstanceRef } from '../chart-instance-context';
|
|
7
7
|
import type { AxisScale } from '@visx/axis';
|
|
8
8
|
import type { EventHandlerParams } from '@visx/xychart';
|
|
9
9
|
import type { KeyboardEvent, ReactNode, RefObject } from 'react';
|
|
@@ -38,7 +38,7 @@ export function useXZoom< T extends Date | number = Date >( {
|
|
|
38
38
|
userHandlers,
|
|
39
39
|
}: {
|
|
40
40
|
enabled: boolean;
|
|
41
|
-
chartRef: RefObject<
|
|
41
|
+
chartRef: RefObject< ChartInstanceRef | null >;
|
|
42
42
|
userHandlers?: {
|
|
43
43
|
onPointerDown?: PointerHandler;
|
|
44
44
|
onPointerMove?: PointerHandler;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useMemo, forwardRef } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ChartInstanceContext } from '../../charts/private/chart-instance-context';
|
|
3
3
|
import { GlobalChartsContext } from '../../providers';
|
|
4
4
|
import { BaseLegend } from './private';
|
|
5
5
|
import type { LegendProps } from './types';
|
|
@@ -20,11 +20,11 @@ export const Legend = forwardRef< HTMLDivElement, LegendProps >(
|
|
|
20
20
|
( { chartId, items, shape, ...props }, ref ) => {
|
|
21
21
|
// Get context but don't throw if it doesn't exist
|
|
22
22
|
const context = useContext( GlobalChartsContext );
|
|
23
|
-
const
|
|
23
|
+
const chartInstanceContext = useContext( ChartInstanceContext );
|
|
24
24
|
|
|
25
25
|
// When chartId is used, it is standalone mode
|
|
26
|
-
// When chartId is not provided, we use the context's chartId, meaning it is
|
|
27
|
-
const contextChartId = chartId ??
|
|
26
|
+
// When chartId is not provided, we use the context's chartId, meaning it is inside a chart instance
|
|
27
|
+
const contextChartId = chartId ?? chartInstanceContext?.chartId;
|
|
28
28
|
|
|
29
29
|
const chartData = useMemo(
|
|
30
30
|
() => ( contextChartId && context ? context.getChartData( contextChartId ) : undefined ),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { LegendItem, LegendLabel, LegendOrdinal, LegendShape } from '@visx/legend';
|
|
3
3
|
import { scaleOrdinal } from '@visx/scale';
|
|
4
|
+
import { _x, sprintf } from '@wordpress/i18n';
|
|
4
5
|
import { Stack } from '@wordpress/ui';
|
|
5
6
|
import clsx from 'clsx';
|
|
6
7
|
import {
|
|
@@ -11,12 +12,13 @@ import {
|
|
|
11
12
|
useCallback,
|
|
12
13
|
useContext,
|
|
13
14
|
} from 'react';
|
|
15
|
+
import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
|
|
14
16
|
import { useTextTruncation } from '../../../hooks';
|
|
15
17
|
import { GlobalChartsContext, useGlobalChartsTheme } from '../../../providers';
|
|
16
18
|
import { useStandaloneScopeClass } from '../../../providers/chart-scope';
|
|
17
19
|
import { valueOrIdentity, valueOrIdentityString, labelTransformFactory } from '../utils';
|
|
18
20
|
import styles from './base-legend.module.scss';
|
|
19
|
-
import type { BaseLegendProps } from '../types';
|
|
21
|
+
import type { BaseLegendItem, BaseLegendProps } from '../types';
|
|
20
22
|
|
|
21
23
|
const ALIGNMENT_TO_FLEX = {
|
|
22
24
|
start: 'flex-start',
|
|
@@ -58,6 +60,52 @@ const LegendText = ( {
|
|
|
58
60
|
);
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
// Interactive items get a toggle affordance; non-interactive items only need a label
|
|
64
|
+
// when hidden, since a visible item's own text already serves as its accessible name.
|
|
65
|
+
const getLegendItemAriaLabel = (
|
|
66
|
+
text: string,
|
|
67
|
+
value: BaseLegendItem[ 'value' ],
|
|
68
|
+
visible: boolean,
|
|
69
|
+
interactive: boolean
|
|
70
|
+
) => {
|
|
71
|
+
const accessibleText =
|
|
72
|
+
value != null && value !== ''
|
|
73
|
+
? sprintf(
|
|
74
|
+
/* translators: 1: legend item label; 2: legend item value. */
|
|
75
|
+
_x( '%1$s, %2$s', 'legend item label and value', 'jetpack-charts' ),
|
|
76
|
+
text,
|
|
77
|
+
String( value )
|
|
78
|
+
)
|
|
79
|
+
: text;
|
|
80
|
+
|
|
81
|
+
if ( interactive ) {
|
|
82
|
+
if ( visible ) {
|
|
83
|
+
return sprintf(
|
|
84
|
+
/* translators: %s: legend item label (e.g. a series or segment name) */
|
|
85
|
+
_x(
|
|
86
|
+
'%s: visible. Toggle visibility.',
|
|
87
|
+
'visible interactive legend item',
|
|
88
|
+
'jetpack-charts'
|
|
89
|
+
),
|
|
90
|
+
accessibleText
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return sprintf(
|
|
94
|
+
/* translators: %s: legend item label (e.g. a series or segment name) */
|
|
95
|
+
_x( '%s: hidden. Toggle visibility.', 'hidden interactive legend item', 'jetpack-charts' ),
|
|
96
|
+
accessibleText
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if ( visible ) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return sprintf(
|
|
103
|
+
/* translators: %s: legend item label (e.g. a series or segment name) */
|
|
104
|
+
_x( '%s: hidden', 'hidden non-interactive legend item', 'jetpack-charts' ),
|
|
105
|
+
accessibleText
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
61
109
|
/*
|
|
62
110
|
* Base legend component that displays color-coded items with labels based on visx LegendOrdinal.
|
|
63
111
|
* We avoid using LegendOrdinal directly to enable support for advanced features such as interactivity.
|
|
@@ -103,6 +151,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
103
151
|
|
|
104
152
|
const theme = useGlobalChartsTheme();
|
|
105
153
|
const context = useContext( GlobalChartsContext );
|
|
154
|
+
const chartInstanceContext = useContext( ChartInstanceContext );
|
|
106
155
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
107
156
|
|
|
108
157
|
const legendScale = scaleOrdinal( {
|
|
@@ -120,29 +169,28 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
120
169
|
const handleLegendClick = useCallback(
|
|
121
170
|
( seriesLabels: string[] ) => {
|
|
122
171
|
if ( interactive && chartId && context ) {
|
|
123
|
-
// Converge the whole group on the representative's next state: only toggle series that
|
|
124
|
-
// currently match it, so a desynced group (e.g. one member hidden programmatically)
|
|
125
|
-
// ends up uniformly hidden/shown after one click.
|
|
126
172
|
const representativeVisible = context.isSeriesVisible( chartId, seriesLabels[ 0 ] );
|
|
127
|
-
seriesLabels.forEach( label =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
} );
|
|
173
|
+
seriesLabels.forEach( label =>
|
|
174
|
+
context.setSeriesVisibility( chartId, label, ! representativeVisible )
|
|
175
|
+
);
|
|
132
176
|
}
|
|
133
177
|
},
|
|
134
178
|
[ interactive, chartId, context ]
|
|
135
179
|
);
|
|
136
180
|
|
|
137
|
-
//
|
|
181
|
+
// Visibility is display state, not interaction state: a series hidden
|
|
182
|
+
// programmatically must read as hidden even when the legend cannot be clicked.
|
|
138
183
|
const isSeriesVisible = useCallback(
|
|
139
184
|
( seriesLabel: string ) => {
|
|
140
|
-
if (
|
|
185
|
+
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
186
|
+
return chartInstanceContext.isSeriesVisible( seriesLabel );
|
|
187
|
+
}
|
|
188
|
+
if ( ! chartId || ! context ) {
|
|
141
189
|
return true;
|
|
142
190
|
}
|
|
143
191
|
return context.isSeriesVisible( chartId, seriesLabel );
|
|
144
192
|
},
|
|
145
|
-
[
|
|
193
|
+
[ chartId, chartInstanceContext, context ]
|
|
146
194
|
);
|
|
147
195
|
|
|
148
196
|
// Create event handlers to avoid inline arrow functions
|
|
@@ -189,7 +237,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
189
237
|
align={ orientation === 'vertical' ? flexAlignment : undefined }
|
|
190
238
|
justify={ orientation === 'horizontal' ? flexAlignment : undefined }
|
|
191
239
|
wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
|
|
192
|
-
role=
|
|
240
|
+
role={ interactive ? undefined : 'list' }
|
|
193
241
|
data-testid={ `legend-${ orientation }` }
|
|
194
242
|
className={ clsx( standaloneScopeClass, styles.legend, className ) }
|
|
195
243
|
style={ theme.legend?.containerStyles }
|
|
@@ -223,14 +271,15 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
223
271
|
}
|
|
224
272
|
onClick={ handleClick }
|
|
225
273
|
onKeyDown={ handleKeyDown }
|
|
226
|
-
role={ interactive ? 'button' :
|
|
274
|
+
role={ interactive ? 'button' : 'listitem' }
|
|
227
275
|
tabIndex={ interactive ? 0 : undefined }
|
|
228
276
|
aria-pressed={ interactive ? visible : undefined }
|
|
229
|
-
aria-label={
|
|
277
|
+
aria-label={ getLegendItemAriaLabel(
|
|
278
|
+
label.text,
|
|
279
|
+
matchedItem?.value,
|
|
280
|
+
visible,
|
|
230
281
|
interactive
|
|
231
|
-
|
|
232
|
-
: undefined
|
|
233
|
-
}
|
|
282
|
+
) }
|
|
234
283
|
>
|
|
235
284
|
{ items[ i ]?.renderGlyph ? (
|
|
236
285
|
<svg
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-no-bind */
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
|
-
import { useMemo } from 'react';
|
|
5
|
-
import {
|
|
4
|
+
import { useEffect, useMemo } from 'react';
|
|
5
|
+
import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
|
|
6
6
|
import {
|
|
7
7
|
GlobalChartsProvider,
|
|
8
8
|
useChartId,
|
|
@@ -473,9 +473,9 @@ describe( 'BaseLegend', () => {
|
|
|
473
473
|
return render(
|
|
474
474
|
<GlobalChartsProvider>
|
|
475
475
|
<ChartRegistrar chartType={ chartType } chartId={ chartId } />
|
|
476
|
-
<
|
|
476
|
+
<ChartInstanceContext.Provider value={ { chartId } }>
|
|
477
477
|
<Legend shape={ explicitShape } />
|
|
478
|
-
</
|
|
478
|
+
</ChartInstanceContext.Provider>
|
|
479
479
|
</GlobalChartsProvider>
|
|
480
480
|
);
|
|
481
481
|
};
|
|
@@ -535,6 +535,7 @@ describe( 'BaseLegend', () => {
|
|
|
535
535
|
expect( legendItems ).toHaveLength( 2 );
|
|
536
536
|
expect( legendItems[ 0 ] ).toHaveAttribute( 'tabIndex', '0' );
|
|
537
537
|
expect( legendItems[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
538
|
+
expect( screen.queryByRole( 'list' ) ).not.toBeInTheDocument();
|
|
538
539
|
} );
|
|
539
540
|
|
|
540
541
|
it( 'handles click events to toggle visibility', async () => {
|
|
@@ -621,6 +622,24 @@ describe( 'BaseLegend', () => {
|
|
|
621
622
|
expect( buttons ).toHaveLength( 0 );
|
|
622
623
|
} );
|
|
623
624
|
|
|
625
|
+
it( 'uses chart-instance visibility before provider state', () => {
|
|
626
|
+
render(
|
|
627
|
+
<GlobalChartsProvider>
|
|
628
|
+
<ChartInstanceContext.Provider
|
|
629
|
+
value={ {
|
|
630
|
+
chartId: 'test-chart',
|
|
631
|
+
isSeriesVisible: label => label !== 'Item 1',
|
|
632
|
+
} }
|
|
633
|
+
>
|
|
634
|
+
<BaseLegend items={ defaultItems } chartId="test-chart" />
|
|
635
|
+
</ChartInstanceContext.Provider>
|
|
636
|
+
</GlobalChartsProvider>
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
expect( screen.getByRole( 'listitem', { name: /Item 1.*hidden/ } ) ).toBeInTheDocument();
|
|
640
|
+
expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
|
|
641
|
+
} );
|
|
642
|
+
|
|
624
643
|
it( 'works without chartId but does not toggle', async () => {
|
|
625
644
|
const user = userEvent.setup();
|
|
626
645
|
|
|
@@ -754,4 +773,81 @@ describe( 'BaseLegend', () => {
|
|
|
754
773
|
expect( legendButton ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
755
774
|
} );
|
|
756
775
|
} );
|
|
776
|
+
|
|
777
|
+
describe( 'Non-interactive legend reflects hidden state', () => {
|
|
778
|
+
const HiddenSeriesHarness = ( { interactive }: { interactive: boolean } ) => {
|
|
779
|
+
const context = useGlobalChartsContext();
|
|
780
|
+
useEffect( () => {
|
|
781
|
+
context.toggleSeriesVisibility( 'legend-a11y-chart', 'Series A' );
|
|
782
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
783
|
+
}, [] );
|
|
784
|
+
return (
|
|
785
|
+
<BaseLegend
|
|
786
|
+
chartId="legend-a11y-chart"
|
|
787
|
+
interactive={ interactive }
|
|
788
|
+
items={ [
|
|
789
|
+
{ label: 'Series A', value: '10', color: '#ff0000' },
|
|
790
|
+
{ label: 'Series B', value: '20', color: '#00ff00' },
|
|
791
|
+
] }
|
|
792
|
+
/>
|
|
793
|
+
);
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
it( 'marks a programmatically hidden series inactive on a non-interactive legend', () => {
|
|
797
|
+
render(
|
|
798
|
+
<GlobalChartsProvider>
|
|
799
|
+
<HiddenSeriesHarness interactive={ false } />
|
|
800
|
+
</GlobalChartsProvider>
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const items = screen.getAllByTestId( 'legend-item' );
|
|
804
|
+
expect( items[ 0 ] ).toHaveClass( 'legend-item--inactive' );
|
|
805
|
+
expect( items[ 1 ] ).not.toHaveClass( 'legend-item--inactive' );
|
|
806
|
+
} );
|
|
807
|
+
|
|
808
|
+
it( 'does not make a non-interactive legend item operable', () => {
|
|
809
|
+
render(
|
|
810
|
+
<GlobalChartsProvider>
|
|
811
|
+
<HiddenSeriesHarness interactive={ false } />
|
|
812
|
+
</GlobalChartsProvider>
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
expect( screen.queryAllByRole( 'button' ) ).toHaveLength( 0 );
|
|
816
|
+
const items = screen.getAllByTestId( 'legend-item' );
|
|
817
|
+
expect( items[ 0 ] ).not.toHaveAttribute( 'aria-pressed' );
|
|
818
|
+
expect( items[ 0 ] ).not.toHaveAttribute( 'tabindex' );
|
|
819
|
+
expect( items[ 0 ] ).toHaveAttribute( 'role', 'listitem' );
|
|
820
|
+
} );
|
|
821
|
+
|
|
822
|
+
it( 'names the hidden state accessibly on a non-interactive legend', () => {
|
|
823
|
+
render(
|
|
824
|
+
<GlobalChartsProvider>
|
|
825
|
+
<HiddenSeriesHarness interactive={ false } />
|
|
826
|
+
</GlobalChartsProvider>
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const items = screen.getAllByTestId( 'legend-item' );
|
|
830
|
+
expect( items[ 0 ] ).toHaveAttribute( 'aria-label', 'Series A, 10: hidden' );
|
|
831
|
+
expect( items[ 1 ] ).not.toHaveAttribute( 'aria-label' );
|
|
832
|
+
} );
|
|
833
|
+
|
|
834
|
+
it( 'includes the item value in interactive accessible names', () => {
|
|
835
|
+
render(
|
|
836
|
+
<GlobalChartsProvider>
|
|
837
|
+
<HiddenSeriesHarness interactive={ true } />
|
|
838
|
+
</GlobalChartsProvider>
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
expect(
|
|
842
|
+
screen.getByRole( 'button', {
|
|
843
|
+
name: 'Series A, 10: hidden. Toggle visibility.',
|
|
844
|
+
} )
|
|
845
|
+
).toBeInTheDocument();
|
|
846
|
+
expect(
|
|
847
|
+
screen.getByRole( 'button', {
|
|
848
|
+
name: 'Series B, 20: visible. Toggle visibility.',
|
|
849
|
+
} )
|
|
850
|
+
).toBeInTheDocument();
|
|
851
|
+
} );
|
|
852
|
+
} );
|
|
757
853
|
} );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Tooltip, TooltipContext } from '@visx/xychart';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import { useContext, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
4
4
|
import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
|
|
5
5
|
import type { SeriesData, DataPointDate } from '../../types';
|
|
6
6
|
import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
|
|
@@ -79,13 +79,24 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
|
|
|
79
79
|
return flattened;
|
|
80
80
|
}, [ series, mode ] );
|
|
81
81
|
|
|
82
|
+
// Tracks whether this effect opened a tooltip, so it only closes its own.
|
|
83
|
+
const hasKeyboardSelection = useRef( false );
|
|
84
|
+
|
|
82
85
|
// Handle tooltip highlighting for keyboard navigation
|
|
83
86
|
useEffect( () => {
|
|
84
87
|
if ( selectedIndex === undefined ) {
|
|
85
|
-
|
|
88
|
+
// visx debounces the hide and cancels only the most recently scheduled one,
|
|
89
|
+
// so hiding on every run leaves earlier hides pending. One of those lands
|
|
90
|
+
// mid-navigation and closes the tooltip the user is reading.
|
|
91
|
+
if ( hasKeyboardSelection.current ) {
|
|
92
|
+
hasKeyboardSelection.current = false;
|
|
93
|
+
tooltipContext?.hideTooltip();
|
|
94
|
+
}
|
|
86
95
|
return;
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
hasKeyboardSelection.current = true;
|
|
99
|
+
|
|
89
100
|
if ( mode === 'group' ) {
|
|
90
101
|
// Show all series at the selected data point index in single tooltip.
|
|
91
102
|
series.forEach( ( s, index ) => {
|
package/src/hooks/index.ts
CHANGED
|
@@ -7,5 +7,5 @@ export { useElementSize } from './use-element-size';
|
|
|
7
7
|
export { useTextTruncation } from './use-text-truncation';
|
|
8
8
|
export { useZeroValueDisplay } from './use-zero-value-display';
|
|
9
9
|
export { useDataWithPercentages } from './use-data-with-percentages';
|
|
10
|
-
export {
|
|
10
|
+
export { useLegendVisibilityData } from './use-legend-visibility-data';
|
|
11
11
|
export { usePrefersReducedMotion } from './use-prefers-reduced-motion';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Data point interface for charts with
|
|
4
|
+
* Data point interface for charts with visibility-aware legends.
|
|
5
5
|
* Requires label for series identification, value for calculations,
|
|
6
6
|
* and percentage (should be pre-calculated by the chart component).
|
|
7
7
|
*/
|
|
@@ -12,23 +12,21 @@ interface DataPointWithPercentage {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Parameters for the
|
|
15
|
+
* Parameters for the useLegendVisibilityData hook.
|
|
16
16
|
*/
|
|
17
|
-
interface
|
|
17
|
+
interface UseLegendVisibilityDataParams< T extends DataPointWithPercentage > {
|
|
18
18
|
/** The chart data with pre-calculated percentages */
|
|
19
19
|
data: T[];
|
|
20
|
-
/** Unique chart identifier, required for
|
|
20
|
+
/** Unique chart identifier, required for filtering */
|
|
21
21
|
chartId: string | undefined;
|
|
22
|
-
/** Whether interactive legend filtering is enabled */
|
|
23
|
-
legendInteractive: boolean;
|
|
24
22
|
/** Function to check if a series is visible in the legend */
|
|
25
23
|
isSeriesVisible: ( chartId: string, label: string ) => boolean;
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
|
-
* Return value from the
|
|
27
|
+
* Return value from the useLegendVisibilityData hook.
|
|
30
28
|
*/
|
|
31
|
-
interface
|
|
29
|
+
interface UseLegendVisibilityDataResult< T extends DataPointWithPercentage > {
|
|
32
30
|
/** Filtered data array containing only visible segments with recalculated percentages */
|
|
33
31
|
visibleData: T[];
|
|
34
32
|
/** Boolean indicating if all segments are hidden */
|
|
@@ -42,10 +40,10 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
|
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
/**
|
|
45
|
-
* Custom hook to filter and recalculate chart data
|
|
43
|
+
* Custom hook to filter and recalculate chart data based on series visibility.
|
|
46
44
|
*
|
|
47
|
-
*
|
|
48
|
-
* 1. Filters data to show only visible series
|
|
45
|
+
* Regardless of whether the legend is interactive (clickable), this hook:
|
|
46
|
+
* 1. Filters data to show only visible series (visibility can also be set programmatically)
|
|
49
47
|
* 2. Recalculates percentages so visible segments total 100%
|
|
50
48
|
* 3. Tracks whether all segments are hidden to show empty state
|
|
51
49
|
*
|
|
@@ -54,10 +52,9 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
|
|
|
54
52
|
*
|
|
55
53
|
* @example
|
|
56
54
|
* ```tsx
|
|
57
|
-
* const { visibleData, allSegmentsHidden, legendData } =
|
|
55
|
+
* const { visibleData, allSegmentsHidden, legendData } = useLegendVisibilityData({
|
|
58
56
|
* data: chartData,
|
|
59
57
|
* chartId: 'my-pie-chart',
|
|
60
|
-
* legendInteractive: true,
|
|
61
58
|
* isSeriesVisible: (id, label) => context.isSeriesVisible(id, label),
|
|
62
59
|
* });
|
|
63
60
|
*
|
|
@@ -72,24 +69,24 @@ interface UseInteractiveLegendDataResult< T extends DataPointWithPercentage > {
|
|
|
72
69
|
* return <PieChart data={visibleData} />;
|
|
73
70
|
* ```
|
|
74
71
|
*
|
|
75
|
-
* @param params
|
|
76
|
-
* @param params.data
|
|
77
|
-
* @param params.chartId
|
|
78
|
-
* @param params.
|
|
79
|
-
* @param params.isSeriesVisible - Function to check series visibility
|
|
72
|
+
* @param params - Configuration object for the hook
|
|
73
|
+
* @param params.data - The chart data to filter
|
|
74
|
+
* @param params.chartId - Unique identifier for the chart (required for filtering)
|
|
75
|
+
* @param params.isSeriesVisible - Function to check series visibility
|
|
80
76
|
* @return Object containing visibleData, allSegmentsHidden flag, and legendData with recalculated percentages
|
|
81
77
|
*/
|
|
82
|
-
export const
|
|
78
|
+
export const useLegendVisibilityData = < T extends DataPointWithPercentage >( {
|
|
83
79
|
data,
|
|
84
80
|
chartId,
|
|
85
|
-
legendInteractive,
|
|
86
81
|
isSeriesVisible,
|
|
87
|
-
}:
|
|
88
|
-
// Filter and recalculate data
|
|
82
|
+
}: UseLegendVisibilityDataParams< T > ): UseLegendVisibilityDataResult< T > => {
|
|
83
|
+
// Filter and recalculate data based on shared legend visibility state.
|
|
89
84
|
// Note: data should already have percentages calculated by the chart component
|
|
90
85
|
const visibleData = useMemo( () => {
|
|
91
|
-
//
|
|
92
|
-
|
|
86
|
+
// Visibility filtering applies regardless of whether the legend is
|
|
87
|
+
// interactive: interactivity only controls whether clicking the legend
|
|
88
|
+
// can change visibility, not whether the chart honors it.
|
|
89
|
+
if ( ! chartId ) {
|
|
93
90
|
return data;
|
|
94
91
|
}
|
|
95
92
|
|
|
@@ -107,18 +104,20 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
|
|
|
107
104
|
...segment,
|
|
108
105
|
percentage: totalValue > 0 ? ( segment.value / totalValue ) * 100 : 0,
|
|
109
106
|
} ) );
|
|
110
|
-
}, [ data, chartId, isSeriesVisible
|
|
107
|
+
}, [ data, chartId, isSeriesVisible ] );
|
|
111
108
|
|
|
112
|
-
// Check if all segments are hidden
|
|
109
|
+
// Check if all segments are hidden. Guard on the source data being non-empty:
|
|
110
|
+
// unlike line/area's `.every()`, an empty `visibleData` alone can't distinguish
|
|
111
|
+
// "everything was hidden" from "there was nothing to show" for an empty dataset.
|
|
113
112
|
const allSegmentsHidden = useMemo( () => {
|
|
114
|
-
return
|
|
115
|
-
}, [
|
|
113
|
+
return visibleData.length === 0 && data.length > 0;
|
|
114
|
+
}, [ visibleData, data ] );
|
|
116
115
|
|
|
117
116
|
// Prepare legend data with percentages
|
|
118
117
|
// Hidden items keep their original percentage (calculated from all values)
|
|
119
118
|
// Visible items show recalculated percentages (totaling 100%)
|
|
120
119
|
const legendData = useMemo( () => {
|
|
121
|
-
if ( !
|
|
120
|
+
if ( ! chartId ) {
|
|
122
121
|
return data;
|
|
123
122
|
}
|
|
124
123
|
|
|
@@ -135,7 +134,7 @@ export const useInteractiveLegendData = < T extends DataPointWithPercentage >( {
|
|
|
135
134
|
// For visible items, get the recalculated percentage from visibleData
|
|
136
135
|
return visibleDataMap.get( segment.label ) || segment;
|
|
137
136
|
} );
|
|
138
|
-
}, [ data, visibleData,
|
|
137
|
+
}, [ data, visibleData, chartId, isSeriesVisible ] );
|
|
139
138
|
|
|
140
139
|
return { visibleData, allSegmentsHidden, legendData };
|
|
141
140
|
};
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
|
|
|
4
4
|
export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
|
|
5
5
|
export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
|
|
6
6
|
export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart';
|
|
7
|
+
// A value export, not a type: building a tooltip column header needs the enum member itself.
|
|
8
|
+
export { GoogleDataTableColumnRoleType } from './types';
|
|
7
9
|
export {
|
|
8
10
|
HeatmapChart,
|
|
9
11
|
HeatmapChartUnresponsive,
|
|
@@ -61,9 +63,9 @@ export type {
|
|
|
61
63
|
LegendPosition,
|
|
62
64
|
ChartLegendConfig,
|
|
63
65
|
SeriesChartLegendConfig,
|
|
66
|
+
SeriesVisibilityProps,
|
|
64
67
|
BaseChartProps,
|
|
65
68
|
GoogleDataTableColumn,
|
|
66
|
-
GoogleDataTableColumnRoleType,
|
|
67
69
|
GoogleDataTableRow,
|
|
68
70
|
LegendShape,
|
|
69
71
|
LegendShapeLabel,
|