@automattic/charts 3.0.0 → 3.1.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 +13 -0
- package/TOKENS.md +3 -3
- package/dist/index.cjs +235 -123
- 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 +236 -125
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +24 -24
- package/src/charts/area-chart/private/overlays.tsx +3 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +301 -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/test/bar-chart.test.tsx +255 -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 +31 -27
- package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
- package/src/charts/line-chart/test/line-chart.test.tsx +262 -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
|
@@ -20,11 +20,12 @@ import {
|
|
|
20
20
|
useGlobalChartsContext,
|
|
21
21
|
GlobalChartsContext,
|
|
22
22
|
} from '../../providers';
|
|
23
|
+
import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
|
|
23
24
|
import { attachSubComponents } from '../../utils';
|
|
24
25
|
import { useChartChildren } from '../private/chart-composition';
|
|
26
|
+
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
25
27
|
import { ChartLayout } from '../private/chart-layout';
|
|
26
|
-
import {
|
|
27
|
-
import { SvgEmptyState } from '../private/svg-empty-state';
|
|
28
|
+
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
28
29
|
import { withResponsive } from '../private/with-responsive';
|
|
29
30
|
import styles from './bar-chart.module.scss';
|
|
30
31
|
import {
|
|
@@ -42,13 +43,14 @@ import type {
|
|
|
42
43
|
DataPointDate,
|
|
43
44
|
SeriesData,
|
|
44
45
|
SeriesChartLegendConfig,
|
|
46
|
+
SeriesVisibilityProps,
|
|
45
47
|
Optional,
|
|
46
48
|
} from '../../types';
|
|
47
49
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
48
50
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
49
51
|
import type { FC, ReactNode, ComponentType } from 'react';
|
|
50
52
|
|
|
51
|
-
export interface BarChartProps extends BaseChartProps< SeriesData[]
|
|
53
|
+
export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
|
|
52
54
|
/**
|
|
53
55
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
54
56
|
*/
|
|
@@ -122,6 +124,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
122
124
|
orientation = 'vertical',
|
|
123
125
|
withPatterns = false,
|
|
124
126
|
showZeroValues = false,
|
|
127
|
+
defaultHiddenSeries,
|
|
125
128
|
animation,
|
|
126
129
|
children,
|
|
127
130
|
gap = 'md',
|
|
@@ -130,6 +133,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
130
133
|
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
131
134
|
const horizontal = orientation === 'horizontal';
|
|
132
135
|
const chartId = useChartId( providedChartId );
|
|
136
|
+
const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
|
|
137
|
+
const isSeriesVisible = useCallback(
|
|
138
|
+
( seriesLabel: string ) => ! hiddenSeries.has( seriesLabel ),
|
|
139
|
+
[ hiddenSeries ]
|
|
140
|
+
);
|
|
133
141
|
const theme = useXYChartTheme( data );
|
|
134
142
|
|
|
135
143
|
const dataSorted = useChartDataTransform( data );
|
|
@@ -148,14 +156,14 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
148
156
|
);
|
|
149
157
|
const legendItems = useChartLegendItems( dataSorted, legendOptions );
|
|
150
158
|
|
|
151
|
-
const { getElementStyles
|
|
159
|
+
const { getElementStyles } = useGlobalChartsContext();
|
|
152
160
|
|
|
153
161
|
// A hidden series is unmounted, so it is not on the band scale either — the
|
|
154
|
-
// axis has to choose its tick values from what is left.
|
|
162
|
+
// axis has to choose its tick values from what is left. Visibility is owned by
|
|
163
|
+
// the provider, whether it changed through the legend or programmatically.
|
|
155
164
|
const isSeriesRendered = useCallback(
|
|
156
|
-
( series: SeriesData ) =>
|
|
157
|
-
|
|
158
|
-
[ chartId, legendInteractive, isSeriesVisible ]
|
|
165
|
+
( series: SeriesData ) => isSeriesVisible( series.label ),
|
|
166
|
+
[ isSeriesVisible ]
|
|
159
167
|
);
|
|
160
168
|
|
|
161
169
|
const chartOptions = useBarChartOptions(
|
|
@@ -198,7 +206,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
198
206
|
totalPoints,
|
|
199
207
|
} );
|
|
200
208
|
|
|
201
|
-
// Add visibility information
|
|
209
|
+
// Add visibility information from the shared legend state.
|
|
202
210
|
const seriesWithVisibility = useMemo(
|
|
203
211
|
() =>
|
|
204
212
|
dataWithVisibleZeros.map( ( series, index ) => ( {
|
|
@@ -514,9 +522,10 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
514
522
|
);
|
|
515
523
|
|
|
516
524
|
return (
|
|
517
|
-
<
|
|
525
|
+
<ChartInstanceContext.Provider
|
|
518
526
|
value={ {
|
|
519
527
|
chartId,
|
|
528
|
+
isSeriesVisible,
|
|
520
529
|
chartWidth: width,
|
|
521
530
|
chartHeight: measuredChartHeight || 0,
|
|
522
531
|
} }
|
|
@@ -606,10 +615,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
606
615
|
width={ width }
|
|
607
616
|
height={ chartHeight }
|
|
608
617
|
>
|
|
609
|
-
{
|
|
610
|
-
'All series are hidden. Click legend items to show data.',
|
|
611
|
-
'jetpack-charts'
|
|
612
|
-
) }
|
|
618
|
+
{ getAllHiddenMessage( legendInteractive, 'series' ) }
|
|
613
619
|
</SvgEmptyState>
|
|
614
620
|
) : null }
|
|
615
621
|
|
|
@@ -673,7 +679,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
673
679
|
);
|
|
674
680
|
} }
|
|
675
681
|
</ChartLayout>
|
|
676
|
-
</
|
|
682
|
+
</ChartInstanceContext.Provider>
|
|
677
683
|
);
|
|
678
684
|
};
|
|
679
685
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { render, renderHook, screen, within } from '@testing-library/react';
|
|
1
|
+
import { render, renderHook, screen, within, act } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
|
-
import
|
|
4
|
+
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
5
|
+
import BarChart, { BarChartUnresponsive } from '../bar-chart';
|
|
5
6
|
import { useBarChartOptions } from '../private';
|
|
7
|
+
import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
|
|
6
8
|
import type { SeriesData } from '../../../types';
|
|
7
9
|
|
|
8
10
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
@@ -1017,6 +1019,64 @@ describe( 'BarChart', () => {
|
|
|
1017
1019
|
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
1018
1020
|
} );
|
|
1019
1021
|
|
|
1022
|
+
test( 'keeps the tooltip open once the chart re-renders under it', async () => {
|
|
1023
|
+
// visx hides its tooltip on a debounce and cancels only the most recently
|
|
1024
|
+
// scheduled hide, so anything that re-runs the tooltip effect while nothing
|
|
1025
|
+
// is shown leaves a hide pending that lands on the next tooltip. Seeding a
|
|
1026
|
+
// hidden series re-renders the chart right after mount, which is exactly
|
|
1027
|
+
// that shape.
|
|
1028
|
+
jest.useFakeTimers();
|
|
1029
|
+
|
|
1030
|
+
try {
|
|
1031
|
+
const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
|
|
1032
|
+
renderWithTheme( {
|
|
1033
|
+
withTooltips: true,
|
|
1034
|
+
defaultHiddenSeries: [ 'Series B' ],
|
|
1035
|
+
data: [
|
|
1036
|
+
{
|
|
1037
|
+
label: 'Series A',
|
|
1038
|
+
data: [
|
|
1039
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1040
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1041
|
+
],
|
|
1042
|
+
options: {},
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
label: 'Series B',
|
|
1046
|
+
data: [
|
|
1047
|
+
{ date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
|
|
1048
|
+
{ date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
|
|
1049
|
+
],
|
|
1050
|
+
options: {},
|
|
1051
|
+
},
|
|
1052
|
+
],
|
|
1053
|
+
} );
|
|
1054
|
+
|
|
1055
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
1056
|
+
chart.focus();
|
|
1057
|
+
|
|
1058
|
+
await user.keyboard( '{ArrowRight}' );
|
|
1059
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
|
|
1060
|
+
|
|
1061
|
+
// Well past any pending hide.
|
|
1062
|
+
await act( async () => {
|
|
1063
|
+
jest.advanceTimersByTime( 5000 );
|
|
1064
|
+
} );
|
|
1065
|
+
|
|
1066
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
|
|
1067
|
+
|
|
1068
|
+
// Leaving navigation still closes it.
|
|
1069
|
+
await user.keyboard( '{Escape}' );
|
|
1070
|
+
await act( async () => {
|
|
1071
|
+
jest.advanceTimersByTime( 5000 );
|
|
1072
|
+
} );
|
|
1073
|
+
|
|
1074
|
+
expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
|
|
1075
|
+
} finally {
|
|
1076
|
+
jest.useRealTimers();
|
|
1077
|
+
}
|
|
1078
|
+
} );
|
|
1079
|
+
|
|
1020
1080
|
test( 'left arrow key navigates to previous data point', async () => {
|
|
1021
1081
|
const user = userEvent.setup();
|
|
1022
1082
|
renderWithTheme( {
|
|
@@ -1918,6 +1978,133 @@ describe( 'BarChart', () => {
|
|
|
1918
1978
|
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1919
1979
|
).toBeInTheDocument();
|
|
1920
1980
|
} );
|
|
1981
|
+
|
|
1982
|
+
it( 'hides a series programmatically when the legend is not interactive', () => {
|
|
1983
|
+
let context: GlobalChartsContextValue;
|
|
1984
|
+
const Grab = () => {
|
|
1985
|
+
context = useGlobalChartsContext();
|
|
1986
|
+
return null;
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
render(
|
|
1990
|
+
<GlobalChartsProvider>
|
|
1991
|
+
<Grab />
|
|
1992
|
+
<BarChartUnresponsive
|
|
1993
|
+
width={ 500 }
|
|
1994
|
+
height={ 300 }
|
|
1995
|
+
showLegend={ true }
|
|
1996
|
+
legend={ { interactive: false } }
|
|
1997
|
+
chartId="test-programmatic-bar"
|
|
1998
|
+
data={ [
|
|
1999
|
+
{
|
|
2000
|
+
label: 'Series A',
|
|
2001
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
2002
|
+
options: {},
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
label: 'Series B',
|
|
2006
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
2007
|
+
options: {},
|
|
2008
|
+
},
|
|
2009
|
+
] }
|
|
2010
|
+
/>
|
|
2011
|
+
</GlobalChartsProvider>
|
|
2012
|
+
);
|
|
2013
|
+
|
|
2014
|
+
act( () => {
|
|
2015
|
+
context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series A' );
|
|
2016
|
+
context.toggleSeriesVisibility( 'test-programmatic-bar', 'Series B' );
|
|
2017
|
+
} );
|
|
2018
|
+
|
|
2019
|
+
expect( screen.getByText( /all series are hidden/i ) ).toBeInTheDocument();
|
|
2020
|
+
} );
|
|
2021
|
+
|
|
2022
|
+
it( 'omits the click instruction when the legend cannot be clicked', () => {
|
|
2023
|
+
let context: GlobalChartsContextValue;
|
|
2024
|
+
const Grab = () => {
|
|
2025
|
+
context = useGlobalChartsContext();
|
|
2026
|
+
return null;
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
render(
|
|
2030
|
+
<GlobalChartsProvider>
|
|
2031
|
+
<Grab />
|
|
2032
|
+
<BarChartUnresponsive
|
|
2033
|
+
width={ 500 }
|
|
2034
|
+
height={ 300 }
|
|
2035
|
+
showLegend={ true }
|
|
2036
|
+
legend={ { interactive: false } }
|
|
2037
|
+
chartId="test-empty-copy-bar"
|
|
2038
|
+
data={ [
|
|
2039
|
+
{
|
|
2040
|
+
label: 'Series A',
|
|
2041
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
2042
|
+
options: {},
|
|
2043
|
+
},
|
|
2044
|
+
] }
|
|
2045
|
+
/>
|
|
2046
|
+
</GlobalChartsProvider>
|
|
2047
|
+
);
|
|
2048
|
+
|
|
2049
|
+
act( () => {
|
|
2050
|
+
context.toggleSeriesVisibility( 'test-empty-copy-bar', 'Series A' );
|
|
2051
|
+
} );
|
|
2052
|
+
|
|
2053
|
+
expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
|
|
2054
|
+
expect( screen.queryByText( /click legend items/i ) ).not.toBeInTheDocument();
|
|
2055
|
+
} );
|
|
2056
|
+
|
|
2057
|
+
it( 'still renders the other series when only one is hidden programmatically', () => {
|
|
2058
|
+
// A test that hides every series would pass even if the chart still forced
|
|
2059
|
+
// all series visible right up until `allSeriesHidden` short-circuited it —
|
|
2060
|
+
// that shape of test masked a real bug in a sibling chart. Hiding exactly
|
|
2061
|
+
// one of two series exercises the partial-hide render path: the bar count
|
|
2062
|
+
// only drops to one if the hidden series' `BarSeries` is actually excluded
|
|
2063
|
+
// from the `BarGroup`, not merely rendered with zero opacity.
|
|
2064
|
+
let context: GlobalChartsContextValue;
|
|
2065
|
+
const Grab = () => {
|
|
2066
|
+
context = useGlobalChartsContext();
|
|
2067
|
+
return null;
|
|
2068
|
+
};
|
|
2069
|
+
|
|
2070
|
+
render(
|
|
2071
|
+
<GlobalChartsProvider>
|
|
2072
|
+
<Grab />
|
|
2073
|
+
<BarChartUnresponsive
|
|
2074
|
+
width={ 500 }
|
|
2075
|
+
height={ 300 }
|
|
2076
|
+
showLegend={ true }
|
|
2077
|
+
legend={ { interactive: false } }
|
|
2078
|
+
chartId="test-programmatic-partial-bar"
|
|
2079
|
+
data={ [
|
|
2080
|
+
{
|
|
2081
|
+
label: 'Series A',
|
|
2082
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
2083
|
+
options: {},
|
|
2084
|
+
},
|
|
2085
|
+
{
|
|
2086
|
+
label: 'Series B',
|
|
2087
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
2088
|
+
options: {},
|
|
2089
|
+
},
|
|
2090
|
+
] }
|
|
2091
|
+
/>
|
|
2092
|
+
</GlobalChartsProvider>
|
|
2093
|
+
);
|
|
2094
|
+
|
|
2095
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
2096
|
+
const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
|
|
2097
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
2098
|
+
expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 2 );
|
|
2099
|
+
|
|
2100
|
+
act( () => {
|
|
2101
|
+
context.toggleSeriesVisibility( 'test-programmatic-partial-bar', 'Series B' );
|
|
2102
|
+
} );
|
|
2103
|
+
|
|
2104
|
+
expect( screen.queryByText( /all series are hidden/i ) ).not.toBeInTheDocument();
|
|
2105
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
2106
|
+
expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 1 );
|
|
2107
|
+
} );
|
|
1921
2108
|
} );
|
|
1922
2109
|
|
|
1923
2110
|
describe( 'Legend group collapsing', () => {
|
|
@@ -1996,4 +2183,70 @@ describe( 'BarChart', () => {
|
|
|
1996
2183
|
).toBeInTheDocument();
|
|
1997
2184
|
} );
|
|
1998
2185
|
} );
|
|
2186
|
+
|
|
2187
|
+
describe( 'defaultHiddenSeries', () => {
|
|
2188
|
+
it( 'renders a series hidden when named in defaultHiddenSeries', () => {
|
|
2189
|
+
render(
|
|
2190
|
+
<GlobalChartsProvider>
|
|
2191
|
+
<BarChartUnresponsive
|
|
2192
|
+
width={ 500 }
|
|
2193
|
+
height={ 300 }
|
|
2194
|
+
showLegend={ true }
|
|
2195
|
+
legend={ { interactive: true } }
|
|
2196
|
+
chartId="test-default-hidden-bar"
|
|
2197
|
+
defaultHiddenSeries={ [ 'Series B' ] }
|
|
2198
|
+
data={ [
|
|
2199
|
+
{
|
|
2200
|
+
label: 'Series A',
|
|
2201
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
2202
|
+
options: {},
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
label: 'Series B',
|
|
2206
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
2207
|
+
options: {},
|
|
2208
|
+
},
|
|
2209
|
+
] }
|
|
2210
|
+
/>
|
|
2211
|
+
</GlobalChartsProvider>
|
|
2212
|
+
);
|
|
2213
|
+
|
|
2214
|
+
const items = screen.getAllByRole( 'button' );
|
|
2215
|
+
expect( items[ 0 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
2216
|
+
expect( items[ 1 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
2217
|
+
} );
|
|
2218
|
+
|
|
2219
|
+
it( 'lets the user reveal a series seeded hidden', async () => {
|
|
2220
|
+
const user = userEvent.setup();
|
|
2221
|
+
|
|
2222
|
+
render(
|
|
2223
|
+
<GlobalChartsProvider>
|
|
2224
|
+
<BarChartUnresponsive
|
|
2225
|
+
width={ 500 }
|
|
2226
|
+
height={ 300 }
|
|
2227
|
+
showLegend={ true }
|
|
2228
|
+
legend={ { interactive: true } }
|
|
2229
|
+
chartId="test-default-hidden-reveal-bar"
|
|
2230
|
+
defaultHiddenSeries={ [ 'Series B' ] }
|
|
2231
|
+
data={ [
|
|
2232
|
+
{
|
|
2233
|
+
label: 'Series A',
|
|
2234
|
+
data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
|
|
2235
|
+
options: {},
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
label: 'Series B',
|
|
2239
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
2240
|
+
options: {},
|
|
2241
|
+
},
|
|
2242
|
+
] }
|
|
2243
|
+
/>
|
|
2244
|
+
</GlobalChartsProvider>
|
|
2245
|
+
);
|
|
2246
|
+
|
|
2247
|
+
await user.click( screen.getAllByRole( 'button' )[ 1 ] );
|
|
2248
|
+
|
|
2249
|
+
expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
2250
|
+
} );
|
|
2251
|
+
} );
|
|
1999
2252
|
} );
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
overflow: hidden;
|
|
15
15
|
color: var(--a8c-charts-color-label);
|
|
16
16
|
text-overflow: ellipsis;
|
|
17
|
-
font-size: var(--wpds-typography-font-size-xl,
|
|
17
|
+
font-size: var(--wpds-typography-font-size-xl, 20px);
|
|
18
18
|
font-style: normal;
|
|
19
|
-
font-weight: var(--wpds-typography-font-weight-emphasis,
|
|
19
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 600);
|
|
20
20
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
21
21
|
margin: 0;
|
|
22
22
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
text-overflow: ellipsis;
|
|
27
27
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
28
28
|
font-style: normal;
|
|
29
|
-
font-weight: var(--wpds-typography-font-weight-emphasis,
|
|
29
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 600);
|
|
30
30
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
31
31
|
margin: 0;
|
|
32
32
|
}
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
.step-rate {
|
|
67
67
|
color: var(--a8c-charts-color-label);
|
|
68
68
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
69
|
-
font-weight: var(--wpds-typography-font-weight-emphasis,
|
|
69
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 600);
|
|
70
70
|
line-height: var(--wpds-typography-line-height-xs, 16px);
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
color: var(--a8c-charts-color-label);
|
|
127
127
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
128
128
|
font-style: normal;
|
|
129
|
-
font-weight: var(--wpds-typography-font-weight-emphasis,
|
|
129
|
+
font-weight: var(--wpds-typography-font-weight-emphasis, 600);
|
|
130
130
|
line-height: var(--wpds-typography-line-height-sm, 20px);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
.empty-state {
|
|
134
134
|
text-align: center;
|
|
135
135
|
color: var(--a8c-charts-color-label-secondary);
|
|
136
|
-
font-size: var(--wpds-typography-font-size-lg,
|
|
136
|
+
font-size: var(--wpds-typography-font-size-lg, 15px);
|
|
137
137
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
.container {
|
|
2
2
|
position: relative;
|
|
3
|
+
|
|
4
|
+
// Google gives its HTML tooltip no width, so the box shrink-to-fits against
|
|
5
|
+
// whatever room is left beside the hovered feature. Near a map edge that
|
|
6
|
+
// breaks even a short label across lines. Sizing to content keeps each line
|
|
7
|
+
// whole and grows the box instead.
|
|
8
|
+
:global(.google-visualization-tooltip) {
|
|
9
|
+
white-space: nowrap;
|
|
10
|
+
}
|
|
3
11
|
}
|
|
@@ -103,6 +103,15 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
|
|
|
103
103
|
pointer-events: none;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Grid filler: keeps its slot so the grid reads as continuous, but stands for a
|
|
107
|
+
// day nothing was measured for — so it captures no pointer, and its track is
|
|
108
|
+
// faded to keep it apart from a measured day that simply scored zero. Mixed
|
|
109
|
+
// towards `transparent` rather than a lighter grey so it holds in either theme.
|
|
110
|
+
.heatmap-chart__cell--placeholder {
|
|
111
|
+
pointer-events: none;
|
|
112
|
+
background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
|
|
107
116
|
// an empty cell.
|
|
108
117
|
.heatmap-chart__cell--filled {
|
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
import { resolveCssVariable } from '../../utils/resolve-css-var';
|
|
22
22
|
import { Center } from '../private/center';
|
|
23
23
|
import { useChartChildren } from '../private/chart-composition';
|
|
24
|
+
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
24
25
|
import { ChartLayout } from '../private/chart-layout';
|
|
25
|
-
import { SingleChartContext } from '../private/single-chart-context';
|
|
26
26
|
import { withResponsive } from '../private/with-responsive';
|
|
27
27
|
import styles from './heatmap-chart.module.scss';
|
|
28
28
|
import {
|
|
@@ -136,8 +136,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
136
136
|
hideTooltip();
|
|
137
137
|
}, [ hideTooltip ] );
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
// Both empty-slot kinds are skipped by navigation: `hidden` paints nothing,
|
|
140
|
+
// `placeholder` paints an empty cell, and neither has a value to report.
|
|
141
|
+
const isCellInert = useCallback(
|
|
142
|
+
( col: number, row: number ) => {
|
|
143
|
+
const cell = data[ col ]?.data[ row ];
|
|
144
|
+
return cell?.hidden === true || cell?.placeholder === true;
|
|
145
|
+
},
|
|
141
146
|
[ data ]
|
|
142
147
|
);
|
|
143
148
|
|
|
@@ -163,7 +168,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
163
168
|
// Start at the first navigable cell (a calendar's leading edge
|
|
164
169
|
// slots may be hidden).
|
|
165
170
|
for ( let index = 0; index < columns * rows; index++ ) {
|
|
166
|
-
if ( !
|
|
171
|
+
if ( ! isCellInert( Math.floor( index / rows ), index % rows ) ) {
|
|
167
172
|
setSelectedIndex( index );
|
|
168
173
|
return;
|
|
169
174
|
}
|
|
@@ -191,7 +196,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
191
196
|
do {
|
|
192
197
|
col += stepCol;
|
|
193
198
|
row += stepRow;
|
|
194
|
-
} while ( col >= 0 && col < columns && row >= 0 && row < rows &&
|
|
199
|
+
} while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellInert( col, row ) );
|
|
195
200
|
|
|
196
201
|
if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
|
|
197
202
|
return;
|
|
@@ -199,7 +204,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
199
204
|
|
|
200
205
|
setSelectedIndex( col * rows + row );
|
|
201
206
|
},
|
|
202
|
-
[ rows, columns, selectedIndex, hideTooltip,
|
|
207
|
+
[ rows, columns, selectedIndex, hideTooltip, isCellInert ]
|
|
203
208
|
);
|
|
204
209
|
|
|
205
210
|
const handleCellMouseMove = useCallback(
|
|
@@ -313,7 +318,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
313
318
|
|
|
314
319
|
return (
|
|
315
320
|
<HeatmapContext.Provider value={ heatmapContext }>
|
|
316
|
-
<
|
|
321
|
+
<ChartInstanceContext.Provider value={ { chartId } }>
|
|
317
322
|
<ChartLayout
|
|
318
323
|
legendPosition="bottom"
|
|
319
324
|
// Legend renders via trailingContent, not the legend slot.
|
|
@@ -391,6 +396,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
391
396
|
);
|
|
392
397
|
}
|
|
393
398
|
|
|
399
|
+
// Filler: drawn like an empty cell so the grid fills its
|
|
400
|
+
// container, but it stands for a day nothing was measured
|
|
401
|
+
// for, so it reports nothing to a pointer or a screen
|
|
402
|
+
// reader either.
|
|
403
|
+
if ( cell?.placeholder ) {
|
|
404
|
+
return (
|
|
405
|
+
<div
|
|
406
|
+
key={ `cell-${ columnIndex }-${ rowIndex }` }
|
|
407
|
+
data-testid="heatmap-cell-placeholder"
|
|
408
|
+
aria-hidden="true"
|
|
409
|
+
className={ clsx(
|
|
410
|
+
styles[ 'heatmap-chart__cell' ],
|
|
411
|
+
styles[ 'heatmap-chart__cell--placeholder' ]
|
|
412
|
+
) }
|
|
413
|
+
/>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
394
417
|
const value = cell?.value ?? null;
|
|
395
418
|
const present = isPresent( value );
|
|
396
419
|
const normalized = present ? getNormalizedValue( value, extent ) : 0;
|
|
@@ -456,7 +479,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
456
479
|
</TooltipInPortal>
|
|
457
480
|
) }
|
|
458
481
|
</ChartLayout>
|
|
459
|
-
</
|
|
482
|
+
</ChartInstanceContext.Provider>
|
|
460
483
|
</HeatmapContext.Provider>
|
|
461
484
|
);
|
|
462
485
|
};
|
|
@@ -10,6 +10,36 @@ export type CalendarHeatmapResult = {
|
|
|
10
10
|
/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
|
|
11
11
|
const LABELLED_ROWS = [ 0, 2, 4 ];
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve one grid bound: an unparseable or narrowing value falls back to the
|
|
15
|
+
* series' own bound, so the grid can only ever be widened.
|
|
16
|
+
*
|
|
17
|
+
* @param bound - Requested bound as `yyyy-MM-dd`, if any.
|
|
18
|
+
* @param fallback - The series' own bound on this side.
|
|
19
|
+
* @param direction - Which way the bound is allowed to move.
|
|
20
|
+
* @return The bound to draw to.
|
|
21
|
+
*/
|
|
22
|
+
const widenTo = (
|
|
23
|
+
bound: string | undefined,
|
|
24
|
+
fallback: Date,
|
|
25
|
+
direction: 'earlier' | 'later'
|
|
26
|
+
): Date => {
|
|
27
|
+
if ( ! bound ) {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parsed = parseISO( bound );
|
|
32
|
+
if ( isNaN( parsed.getTime() ) ) {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if ( direction === 'earlier' ) {
|
|
37
|
+
return parsed < fallback ? parsed : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return parsed > fallback ? parsed : fallback;
|
|
41
|
+
};
|
|
42
|
+
|
|
13
43
|
const toDate = ( point: DataPointDate ): Date | null => {
|
|
14
44
|
if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
|
|
15
45
|
return point.date;
|
|
@@ -34,6 +64,16 @@ export const buildCalendarHeatmapData = (
|
|
|
34
64
|
* blank cells even when the series has no entry for them.
|
|
35
65
|
*/
|
|
36
66
|
hideOutOfRangeDays?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the
|
|
69
|
+
* series' own. Days inside the grid but outside the series become
|
|
70
|
+
* placeholder cells: painted as empty slots so a short series still
|
|
71
|
+
* fills its container, but reporting nothing, since they were never
|
|
72
|
+
* measured. A start bound is drawn from the beginning of its week, so
|
|
73
|
+
* the grid always opens on a whole column. Bounds narrower than the
|
|
74
|
+
* series are ignored — the grid never drops a day that carries data.
|
|
75
|
+
*/
|
|
76
|
+
gridSpan?: { start?: string; end?: string };
|
|
37
77
|
} = {}
|
|
38
78
|
): CalendarHeatmapResult => {
|
|
39
79
|
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
@@ -60,14 +100,27 @@ export const buildCalendarHeatmapData = (
|
|
|
60
100
|
}
|
|
61
101
|
}
|
|
62
102
|
|
|
63
|
-
const gridStart = startOfWeek( minDate, { weekStartsOn } );
|
|
64
|
-
const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1;
|
|
65
|
-
|
|
66
103
|
// Day-key bounds for the ragged-edge option: calendar-day comparison, so
|
|
67
104
|
// entries carrying a time of day can't shift the span.
|
|
68
105
|
const minDayKey = format( minDate, 'yyyy-MM-dd' );
|
|
69
106
|
const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
|
|
70
107
|
|
|
108
|
+
// The grid may run wider than the series, never narrower: a bound that would
|
|
109
|
+
// cut into the data is dropped rather than honoured.
|
|
110
|
+
const gridMinDate = widenTo( options.gridSpan?.start, minDate, 'earlier' );
|
|
111
|
+
const gridMaxDate = widenTo( options.gridSpan?.end, maxDate, 'later' );
|
|
112
|
+
|
|
113
|
+
const gridMaxDayKey = format( gridMaxDate, 'yyyy-MM-dd' );
|
|
114
|
+
|
|
115
|
+
const gridStart = startOfWeek( gridMinDate, { weekStartsOn } );
|
|
116
|
+
|
|
117
|
+
// A start bound rarely lands on a week start, and `gridStart` rounds it down.
|
|
118
|
+
// The days it rounds past were no more measured than the rest of the widened
|
|
119
|
+
// span, so they are filler too; treating them as a ragged edge instead would
|
|
120
|
+
// notch the first column by however far the bound sat into its week.
|
|
121
|
+
const gridMinDayKey = format( gridMinDate < minDate ? gridStart : gridMinDate, 'yyyy-MM-dd' );
|
|
122
|
+
const weekCount = differenceInCalendarWeeks( gridMaxDate, gridStart, { weekStartsOn } ) + 1;
|
|
123
|
+
|
|
71
124
|
const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
|
|
72
125
|
LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
|
|
73
126
|
);
|
|
@@ -104,8 +157,15 @@ export const buildCalendarHeatmapData = (
|
|
|
104
157
|
label: format( day, 'EEE, MMM d, yyyy' ),
|
|
105
158
|
value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
|
|
106
159
|
};
|
|
107
|
-
if (
|
|
108
|
-
|
|
160
|
+
if ( key < gridMinDayKey || key > gridMaxDayKey ) {
|
|
161
|
+
// The days completing the grid's first/last week: the calendar's
|
|
162
|
+
// ragged edge.
|
|
163
|
+
if ( hideOutOfRangeDays ) {
|
|
164
|
+
cell.hidden = true;
|
|
165
|
+
}
|
|
166
|
+
} else if ( key < minDayKey || key > maxDayKey ) {
|
|
167
|
+
// Inside the grid the caller asked for, outside the series: filler.
|
|
168
|
+
cell.placeholder = true;
|
|
109
169
|
}
|
|
110
170
|
cells.push( cell );
|
|
111
171
|
}
|