@automattic/charts 4.9.0 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/TOKENS.md +7 -3
- package/dist/index.cjs +496 -774
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +66 -3
- package/dist/index.d.cts +65 -60
- package/dist/index.d.ts +65 -60
- package/dist/index.js +467 -726
- package/dist/index.js.map +1 -1
- package/dist/visx/group/index.js +1 -2
- package/dist/visx/legend/index.js +1 -2
- package/dist/visx/text/index.js +1 -2
- package/package.json +13 -12
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
- package/src/charts/area-chart/types.ts +0 -2
- package/src/charts/bar-chart/bar-chart.tsx +7 -2
- package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
- package/src/charts/bar-chart/types.ts +0 -2
- package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
- package/src/charts/conversion-funnel-chart/types.ts +1 -1
- package/src/charts/geo-chart/geo-chart.module.scss +27 -0
- package/src/charts/geo-chart/geo-chart.tsx +85 -45
- package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
- package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
- package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
- package/src/charts/geo-chart/types.ts +1 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
- package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
- package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
- package/src/charts/heatmap-chart/types.ts +1 -3
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
- package/src/charts/leaderboard-chart/types.ts +1 -0
- package/src/charts/line-chart/line-chart.tsx +3 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
- package/src/charts/line-chart/types.ts +0 -2
- package/src/charts/pie-chart/pie-chart.module.scss +3 -0
- package/src/charts/pie-chart/pie-chart.tsx +0 -5
- package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
- package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
- package/src/charts/test/accessible-name.test.tsx +181 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
- package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
- package/src/components/legend/private/base-legend.module.scss +5 -0
- package/src/components/legend/private/base-legend.tsx +35 -17
- package/src/components/legend/test/legend.test.tsx +42 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/tooltip/_surface.scss +19 -0
- package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
- package/src/components/tooltip/tooltip-box.module.scss +3 -17
- package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
- package/src/hooks/use-xychart-theme.ts +33 -2
- package/src/index.ts +1 -0
- package/src/styles/chart-scope.scss +6 -4
- package/src/styles/test/chart-scope.test.ts +6 -2
- package/src/types.ts +19 -1
- package/src/utils/resolve-font-size.ts +9 -10
- package/src/utils/test/resolve-font-size.test.ts +11 -2
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
} from '../../../providers';
|
|
9
9
|
import { formatPercentage } from '../../../utils';
|
|
10
10
|
import type {
|
|
11
|
+
ComparisonItemLabel,
|
|
11
12
|
SeriesData,
|
|
12
13
|
DataPointDate,
|
|
13
14
|
DataPointPercentageCalculated,
|
|
14
15
|
LegendShape,
|
|
16
|
+
SeriesChartLegendConfig,
|
|
15
17
|
} from '../../../types';
|
|
16
18
|
import type { BaseLegendItem } from '../types';
|
|
17
19
|
import type { GlyphProps } from '@visx/xychart';
|
|
@@ -36,7 +38,9 @@ export interface ChartLegendOptions {
|
|
|
36
38
|
* `options.type === 'comparison'`. Skipped when that series already has its own
|
|
37
39
|
* item. Pass a string to replace the default label.
|
|
38
40
|
*/
|
|
39
|
-
comparisonItem?:
|
|
41
|
+
comparisonItem?: SeriesChartLegendConfig[ 'comparisonItem' ];
|
|
42
|
+
/** One static item per comparison series instead; see `SeriesChartLegendConfig`. */
|
|
43
|
+
comparisonSeriesItems?: SeriesChartLegendConfig[ 'comparisonSeriesItems' ];
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -276,6 +280,48 @@ function buildComparisonLegendItem(
|
|
|
276
280
|
return { label, color, shapeStyle: shapeStyles, interactive: false };
|
|
277
281
|
}
|
|
278
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Builds one static item per comparison series folded into another series' item, named after
|
|
285
|
+
* that item and styled like the comparison series itself.
|
|
286
|
+
* @param seriesData - The series data to search for comparison series
|
|
287
|
+
* @param items - The legend items already built from the series
|
|
288
|
+
* @param formatLabel - Names an item after the label of the item it was folded into
|
|
289
|
+
* @param getElementStyles - Function to get element styles
|
|
290
|
+
* @param legendShape - The shape type for legend items (string literal or React component)
|
|
291
|
+
* @return The legend items, in series order
|
|
292
|
+
*/
|
|
293
|
+
function buildPerSeriesComparisonItems(
|
|
294
|
+
seriesData: SeriesData[],
|
|
295
|
+
items: BaseLegendItem[],
|
|
296
|
+
formatLabel: ComparisonItemLabel,
|
|
297
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles,
|
|
298
|
+
legendShape?: LegendShape< SeriesData[], number >
|
|
299
|
+
): BaseLegendItem[] {
|
|
300
|
+
return seriesData.flatMap( ( series, index ) => {
|
|
301
|
+
const owner = items.find( item => item.seriesLabels?.includes( series.label ) );
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
series.options?.type !== 'comparison' ||
|
|
305
|
+
! owner ||
|
|
306
|
+
owner.seriesLabels?.[ 0 ] === series.label
|
|
307
|
+
) {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const { color, shapeStyles } = getElementStyles( { data: series, index, legendShape } );
|
|
312
|
+
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
label: formatLabel( owner.label ),
|
|
316
|
+
color,
|
|
317
|
+
shapeStyle: shapeStyles,
|
|
318
|
+
interactive: false,
|
|
319
|
+
describesSeries: series.label,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
} );
|
|
323
|
+
}
|
|
324
|
+
|
|
279
325
|
/**
|
|
280
326
|
* Processes point data into legend items
|
|
281
327
|
* @param pointData - The point data to process
|
|
@@ -339,6 +385,7 @@ export function useChartLegendItems<
|
|
|
339
385
|
glyphSize = 8,
|
|
340
386
|
collapseGroups = false,
|
|
341
387
|
comparisonItem = false,
|
|
388
|
+
comparisonSeriesItems,
|
|
342
389
|
renderGlyph,
|
|
343
390
|
} = options;
|
|
344
391
|
const { getElementStyles } = useGlobalChartsContext();
|
|
@@ -361,6 +408,20 @@ export function useChartLegendItems<
|
|
|
361
408
|
renderGlyph,
|
|
362
409
|
legendShape
|
|
363
410
|
);
|
|
411
|
+
|
|
412
|
+
if ( comparisonSeriesItems ) {
|
|
413
|
+
return [
|
|
414
|
+
...items,
|
|
415
|
+
...buildPerSeriesComparisonItems(
|
|
416
|
+
seriesData,
|
|
417
|
+
items,
|
|
418
|
+
comparisonSeriesItems,
|
|
419
|
+
getElementStyles,
|
|
420
|
+
legendShape
|
|
421
|
+
),
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
|
|
364
425
|
const comparison = comparisonItem
|
|
365
426
|
? buildComparisonLegendItem(
|
|
366
427
|
seriesData,
|
|
@@ -396,6 +457,7 @@ export function useChartLegendItems<
|
|
|
396
457
|
glyphSize,
|
|
397
458
|
collapseGroups,
|
|
398
459
|
comparisonItem,
|
|
460
|
+
comparisonSeriesItems,
|
|
399
461
|
renderGlyph,
|
|
400
462
|
legendShape,
|
|
401
463
|
] );
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
forwardRef,
|
|
12
12
|
useCallback,
|
|
13
13
|
useContext,
|
|
14
|
+
useMemo,
|
|
14
15
|
} from 'react';
|
|
15
16
|
import { ChartInstanceContext } from '../../../charts/private/chart-instance-context';
|
|
16
17
|
import { useTextTruncation } from '../../../hooks';
|
|
@@ -115,7 +116,7 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
115
116
|
> = forwardRef< HTMLDivElement, BaseLegendProps >(
|
|
116
117
|
(
|
|
117
118
|
{
|
|
118
|
-
items,
|
|
119
|
+
items: allItems,
|
|
119
120
|
className,
|
|
120
121
|
orientation = 'horizontal',
|
|
121
122
|
alignment = 'center',
|
|
@@ -154,6 +155,31 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
154
155
|
const chartInstanceContext = useContext( ChartInstanceContext );
|
|
155
156
|
const standaloneScopeClass = useStandaloneScopeClass();
|
|
156
157
|
|
|
158
|
+
// Disabling interaction for the whole legend still preserves its series' visibility state.
|
|
159
|
+
const isSeriesVisible = useCallback(
|
|
160
|
+
( seriesLabel: string ) => {
|
|
161
|
+
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
162
|
+
return chartInstanceContext.isSeriesVisible( seriesLabel );
|
|
163
|
+
}
|
|
164
|
+
if ( ! chartId || ! context ) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return context.isSeriesVisible( chartId, seriesLabel );
|
|
168
|
+
},
|
|
169
|
+
[ chartId, chartInstanceContext, context ]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const items = useMemo(
|
|
173
|
+
() =>
|
|
174
|
+
allItems.filter(
|
|
175
|
+
item =>
|
|
176
|
+
item.interactive !== false ||
|
|
177
|
+
! item.describesSeries ||
|
|
178
|
+
isSeriesVisible( item.describesSeries )
|
|
179
|
+
),
|
|
180
|
+
[ allItems, isSeriesVisible ]
|
|
181
|
+
);
|
|
182
|
+
|
|
157
183
|
// Keep duplicate labels: a static comparison item can share a metric's name.
|
|
158
184
|
const domain = items.map( item => item.label );
|
|
159
185
|
const legendScale = scaleOrdinal( {
|
|
@@ -189,20 +215,6 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
189
215
|
[ interactive, chartId, context ]
|
|
190
216
|
);
|
|
191
217
|
|
|
192
|
-
// Disabling interaction for the whole legend still preserves its series' visibility state.
|
|
193
|
-
const isSeriesVisible = useCallback(
|
|
194
|
-
( seriesLabel: string ) => {
|
|
195
|
-
if ( chartInstanceContext?.isSeriesVisible ) {
|
|
196
|
-
return chartInstanceContext.isSeriesVisible( seriesLabel );
|
|
197
|
-
}
|
|
198
|
-
if ( ! chartId || ! context ) {
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
return context.isSeriesVisible( chartId, seriesLabel );
|
|
202
|
-
},
|
|
203
|
-
[ chartId, chartInstanceContext, context ]
|
|
204
|
-
);
|
|
205
|
-
|
|
206
218
|
// Create event handlers to avoid inline arrow functions
|
|
207
219
|
const createClickHandler = useCallback(
|
|
208
220
|
( seriesLabels: string[], itemInteractive: boolean ) => {
|
|
@@ -245,13 +257,19 @@ export const BaseLegend: ForwardRefExoticComponent<
|
|
|
245
257
|
<Stack
|
|
246
258
|
ref={ ref }
|
|
247
259
|
direction={ orientation === 'vertical' ? 'column' : 'row' }
|
|
248
|
-
gap
|
|
260
|
+
// Stack's gap is inline; horizontal gaps live in CSS so consumers can override them.
|
|
261
|
+
gap={ orientation === 'vertical' ? 'sm' : undefined }
|
|
249
262
|
align={ orientation === 'vertical' ? flexAlignment : undefined }
|
|
250
263
|
justify={ orientation === 'horizontal' ? flexAlignment : undefined }
|
|
251
264
|
wrap={ orientation === 'horizontal' ? 'wrap' : undefined }
|
|
252
265
|
role={ interactive ? undefined : 'list' }
|
|
253
266
|
data-testid={ `legend-${ orientation }` }
|
|
254
|
-
className={ clsx(
|
|
267
|
+
className={ clsx(
|
|
268
|
+
standaloneScopeClass,
|
|
269
|
+
styles.legend,
|
|
270
|
+
orientation === 'horizontal' && styles[ 'legend--horizontal' ],
|
|
271
|
+
className
|
|
272
|
+
) }
|
|
255
273
|
style={ theme.legend?.containerStyles }
|
|
256
274
|
>
|
|
257
275
|
{ labels.map( ( label, i ) => {
|
|
@@ -801,6 +801,48 @@ describe( 'BaseLegend', () => {
|
|
|
801
801
|
expect( staticItem ).not.toHaveClass( 'legend-item--inactive' );
|
|
802
802
|
} );
|
|
803
803
|
|
|
804
|
+
it( 'leaves out a static item while its series is hidden, keeping later swatches aligned', async () => {
|
|
805
|
+
const user = userEvent.setup();
|
|
806
|
+
const items = [
|
|
807
|
+
{ label: 'Views', color: '#0000ff', seriesLabels: [ 'Views', 'Views · previous' ] },
|
|
808
|
+
{
|
|
809
|
+
label: 'Visitors',
|
|
810
|
+
color: '#00ff00',
|
|
811
|
+
seriesLabels: [ 'Visitors', 'Visitors · previous' ],
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
label: 'Views (Prev. 7 days)',
|
|
815
|
+
color: '#000088',
|
|
816
|
+
interactive: false,
|
|
817
|
+
describesSeries: 'Views · previous',
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
label: 'Visitors (Prev. 7 days)',
|
|
821
|
+
color: '#008800',
|
|
822
|
+
interactive: false,
|
|
823
|
+
describesSeries: 'Visitors · previous',
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
render(
|
|
828
|
+
<GlobalChartsProvider>
|
|
829
|
+
<BaseLegend items={ items } interactive chartId="test-chart" shape={ TestShape } />
|
|
830
|
+
</GlobalChartsProvider>
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
|
|
834
|
+
|
|
835
|
+
expect( screen.getAllByTestId( 'legend-item' ).map( item => item.textContent ) ).toEqual( [
|
|
836
|
+
'Views',
|
|
837
|
+
'Visitors',
|
|
838
|
+
'Visitors (Prev. 7 days)',
|
|
839
|
+
] );
|
|
840
|
+
expect( screen.getAllByTestId( 'legend-marker' )[ 2 ] ).toHaveAttribute( 'fill', '#008800' );
|
|
841
|
+
|
|
842
|
+
await user.click( screen.getByRole( 'button', { name: /^Views/ } ) );
|
|
843
|
+
expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 4 );
|
|
844
|
+
} );
|
|
845
|
+
|
|
804
846
|
it( 'keeps a comparison item distinct when its label collides', async () => {
|
|
805
847
|
const user = userEvent.setup();
|
|
806
848
|
const Example = () => {
|
|
@@ -63,4 +63,6 @@ export type BaseLegendItem = {
|
|
|
63
63
|
seriesLabels?: string[];
|
|
64
64
|
/** Set to false for a static item that cannot be toggled or dimmed. */
|
|
65
65
|
interactive?: boolean;
|
|
66
|
+
/** Label of the series this static item describes; the item is left out while it is hidden. */
|
|
67
|
+
describesSeries?: string;
|
|
66
68
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The chart tooltip box: the look every chart tooltip shares.
|
|
2
|
+
@mixin surface {
|
|
3
|
+
padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm);
|
|
4
|
+
background-color: var(--a8c-charts-color-surface);
|
|
5
|
+
color: var(--a8c-charts-color-label);
|
|
6
|
+
border-radius: var(--wpds-border-radius-md);
|
|
7
|
+
font-family: var(--wpds-typography-font-family-body);
|
|
8
|
+
font-size: var(--wpds-typography-font-size-sm);
|
|
9
|
+
line-height: 1.4;
|
|
10
|
+
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
11
|
+
pointer-events: none;
|
|
12
|
+
// Beats a host `max-width` on `.visx-tooltip`, even an `!important`
|
|
13
|
+
// one: WordPress.com's Help Center caps it at 150px.
|
|
14
|
+
min-width: fit-content;
|
|
15
|
+
|
|
16
|
+
@media (forced-colors: active) {
|
|
17
|
+
border: var(--wpds-border-width-xs) solid CanvasText;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -17,7 +17,7 @@ export type BoundedTooltipProps = Omit< TooltipBoxProps, 'children' > & {
|
|
|
17
17
|
placement?: TooltipPlacement;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
const DEFAULT_OFFSET = 10;
|
|
20
|
+
export const DEFAULT_OFFSET = 10;
|
|
21
21
|
// Above anything else layered inside a chart, such as the zoom reset button (2).
|
|
22
22
|
// Chart wrappers isolate their stacking context, so it never reaches page chrome.
|
|
23
23
|
export const TOOLTIP_Z_INDEX = 3;
|
|
@@ -43,7 +43,7 @@ const findLayoutParent = ( node: Element ): Element | null => {
|
|
|
43
43
|
// The first ancestor that cuts its overflow off. The box may leave the chart
|
|
44
44
|
// wrapper but never this element, which is what a body-level portal used to
|
|
45
45
|
// guarantee by never being inside one.
|
|
46
|
-
const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
46
|
+
export const findClippingAncestor = ( wrapper: Element ): Element | null => {
|
|
47
47
|
let element = wrapper.parentElement;
|
|
48
48
|
while ( element && element !== document.body ) {
|
|
49
49
|
if ( isClipping( element ) ) {
|
|
@@ -1,21 +1,7 @@
|
|
|
1
1
|
// `.surface` is the look alone, for a chart that positions the box itself.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
@use "surface";
|
|
3
|
+
|
|
4
4
|
.surface {
|
|
5
|
-
padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm);
|
|
6
|
-
background-color: var(--a8c-charts-color-surface);
|
|
7
|
-
color: var(--a8c-charts-color-label);
|
|
8
|
-
border-radius: var(--wpds-border-radius-md);
|
|
9
|
-
font-family: var(--wpds-typography-font-family-body);
|
|
10
|
-
font-size: var(--wpds-typography-font-size-sm);
|
|
11
|
-
line-height: 1.4;
|
|
12
|
-
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
13
|
-
pointer-events: none;
|
|
14
|
-
// Beats a host `max-width` on `.visx-tooltip`, even an `!important`
|
|
15
|
-
// one: WordPress.com's Help Center caps it at 150px.
|
|
16
|
-
min-width: fit-content;
|
|
17
5
|
|
|
18
|
-
@
|
|
19
|
-
border: var(--wpds-border-width-xs) solid CanvasText;
|
|
20
|
-
}
|
|
6
|
+
@include surface.surface;
|
|
21
7
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../providers';
|
|
2
3
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
3
4
|
import { ChartScopeContext } from '../../providers/chart-scope';
|
|
4
5
|
import { useXYChartTheme } from '../use-xychart-theme';
|
|
@@ -68,6 +69,27 @@ describe( 'useXYChartTheme', () => {
|
|
|
68
69
|
document.body.removeChild( scope );
|
|
69
70
|
} );
|
|
70
71
|
|
|
72
|
+
it.each( [
|
|
73
|
+
[ -12, 11 ],
|
|
74
|
+
[ Infinity, 11 ],
|
|
75
|
+
[ 14, 14 ],
|
|
76
|
+
[ '-12px', 11 ],
|
|
77
|
+
[ '12.px', 11 ],
|
|
78
|
+
[ '9'.repeat( 400 ) + 'px', 11 ],
|
|
79
|
+
[ '0.875rem', '0.875rem' ],
|
|
80
|
+
[ 'var(--size)', 'var(--size)' ],
|
|
81
|
+
] )( 'renders a theme fontSize of %p as %p', ( fontSize, expected ) => {
|
|
82
|
+
const wrapper = ( { children }: { children: ReactNode } ) => (
|
|
83
|
+
<GlobalChartsProvider theme={ { svgLabelSmall: { fontSize } } }>
|
|
84
|
+
{ children }
|
|
85
|
+
</GlobalChartsProvider>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
|
|
89
|
+
|
|
90
|
+
expect( result.current.axisStyles.x.bottom.tickLabel.fontSize ).toBe( expected );
|
|
91
|
+
} );
|
|
92
|
+
|
|
71
93
|
it( 'gives the y axis its own roles, resolving to none', () => {
|
|
72
94
|
const { result } = renderHook( () => useXYChartTheme( [] ) );
|
|
73
95
|
|
|
@@ -3,9 +3,36 @@ import { useMemo } from 'react';
|
|
|
3
3
|
import { useGlobalChartsTheme } from '../providers';
|
|
4
4
|
import { CATALOG_POINTERS } from '../providers/chart-context/private/catalog-pointers';
|
|
5
5
|
import { useChartScopeElement } from '../providers/chart-scope';
|
|
6
|
-
import { createCssVariableResolver } from '../utils';
|
|
6
|
+
import { createCssVariableResolver, resolveFontSize } from '../utils';
|
|
7
7
|
import type { SeriesData } from '../types';
|
|
8
8
|
|
|
9
|
+
type LabelStyle = { fontSize?: number | string };
|
|
10
|
+
|
|
11
|
+
const STARTS_LIKE_A_NUMBER = /^[+-]?[\d.]/;
|
|
12
|
+
const NON_NEGATIVE_CSS_NUMBER = /^\+?(?:\d*\.)?\d+(?:e[+-]?\d+)?(?:[a-z]+|%)?$/i;
|
|
13
|
+
|
|
14
|
+
const isRenderableFontSize = ( fontSize: number | string ): boolean => {
|
|
15
|
+
if ( typeof fontSize === 'number' ) {
|
|
16
|
+
return resolveFontSize( fontSize ) !== undefined;
|
|
17
|
+
}
|
|
18
|
+
// A pattern, not `CSS.supports`, so SSR and the browser agree; `resolveFontSize` would drop renderable `rem`. Keywords, `var()` and `calc()` pass through.
|
|
19
|
+
const value = fontSize.trim();
|
|
20
|
+
if ( ! STARTS_LIKE_A_NUMBER.test( value ) ) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return NON_NEGATIVE_CSS_NUMBER.test( value ) && Number.isFinite( parseFloat( value ) );
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// visx writes `fontSize` straight onto `<text>`, where an invalid one renders at the browser's 16px default; dropping it lets visx use its own default.
|
|
27
|
+
const withRenderableFontSize = < T extends LabelStyle >( style?: T ): T | undefined => {
|
|
28
|
+
if ( style?.fontSize === undefined || isRenderableFontSize( style.fontSize ) ) {
|
|
29
|
+
return style;
|
|
30
|
+
}
|
|
31
|
+
const rest = { ...style };
|
|
32
|
+
delete rest.fontSize;
|
|
33
|
+
return rest;
|
|
34
|
+
};
|
|
35
|
+
|
|
9
36
|
export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
10
37
|
const theme = useGlobalChartsTheme();
|
|
11
38
|
const scopeElement = useChartScopeElement();
|
|
@@ -47,7 +74,11 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
47
74
|
xTickLineStyles: { ...theme.xTickLineStyles, stroke: CATALOG_POINTERS.tickX },
|
|
48
75
|
yAxisLineStyles: { stroke: CATALOG_POINTERS.axisY },
|
|
49
76
|
yTickLineStyles: { stroke: CATALOG_POINTERS.tickY },
|
|
50
|
-
svgLabelSmall: {
|
|
77
|
+
svgLabelSmall: {
|
|
78
|
+
...withRenderableFontSize( theme.svgLabelSmall ),
|
|
79
|
+
fill: CATALOG_POINTERS.labelAxis,
|
|
80
|
+
},
|
|
81
|
+
svgLabelBig: withRenderableFontSize( theme.svgLabelBig ),
|
|
51
82
|
} );
|
|
52
83
|
}, [ theme, seriesColorKey, scopeElement ] );
|
|
53
84
|
};
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
// the same element wins. Chart roots deliberately do NOT re-declare these: an
|
|
11
11
|
// element's own declaration beats an inherited one, so re-declaring lower down
|
|
12
12
|
// would shadow a consumer override set inside the provider tree.
|
|
13
|
-
// The tooltip
|
|
14
|
-
// .a8c-charts-scope does not land on the dark box.
|
|
15
|
-
:where(.a8c-charts-scope, .a8c-charts-tooltip-scope) {
|
|
13
|
+
// The tooltip boxes (ours and Google's GeoChart one) re-declare the catalog on
|
|
14
|
+
// themselves, so a rule on .a8c-charts-scope does not land on the dark box.
|
|
15
|
+
:where(.a8c-charts-scope, .a8c-charts-tooltip-scope, .geo-chart .google-visualization-tooltip) {
|
|
16
16
|
// The series palette. GlobalChartsProvider resolves these five slots at its
|
|
17
17
|
// wrapper and seeds ColorCache with whatever resolves; a slot that resolves
|
|
18
18
|
// to nothing is skipped, so setting only slots 1 and 3 gives a two-color
|
|
@@ -87,11 +87,13 @@
|
|
|
87
87
|
|
|
88
88
|
--a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
|
|
89
89
|
--a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
|
|
90
|
+
--a8c-charts-dimension-legend-column-gap: var(--wpds-dimension-gap-lg);
|
|
91
|
+
--a8c-charts-dimension-legend-row-gap: var(--wpds-dimension-gap-sm);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
@media (forced-colors: active) {
|
|
93
95
|
|
|
94
|
-
:where(.a8c-charts-scope, .a8c-charts-tooltip-scope) {
|
|
96
|
+
:where(.a8c-charts-scope, .a8c-charts-tooltip-scope, .geo-chart .google-visualization-tooltip) {
|
|
95
97
|
--a8c-charts-color-label-axis: CanvasText;
|
|
96
98
|
--a8c-charts-color-grid: GrayText;
|
|
97
99
|
--a8c-charts-color-axis-x: GrayText;
|
|
@@ -127,7 +127,9 @@ describe( 'chart scope catalog', () => {
|
|
|
127
127
|
expect( mediaRules[ 0 ].cssRules ).toHaveLength( 1 );
|
|
128
128
|
|
|
129
129
|
const rule = mediaRules[ 0 ].cssRules[ 0 ] as CSSStyleRule;
|
|
130
|
-
expect( rule.selectorText ).toBe(
|
|
130
|
+
expect( rule.selectorText ).toBe(
|
|
131
|
+
':where(.a8c-charts-scope, .a8c-charts-tooltip-scope, .geo-chart .google-visualization-tooltip)'
|
|
132
|
+
);
|
|
131
133
|
expect(
|
|
132
134
|
Object.fromEntries(
|
|
133
135
|
Array.from( rule.style ).map( property => [
|
|
@@ -172,7 +174,9 @@ describe( 'chart scope catalog', () => {
|
|
|
172
174
|
} );
|
|
173
175
|
|
|
174
176
|
it( 'scopes the catalog to the chart and tooltip scope classes rather than :root', () => {
|
|
175
|
-
expect( stylesheet ).toMatch(
|
|
177
|
+
expect( stylesheet ).toMatch(
|
|
178
|
+
/:where\(\.a8c-charts-scope, \.a8c-charts-tooltip-scope, \.geo-chart \.google-visualization-tooltip\)\s*{/
|
|
179
|
+
);
|
|
176
180
|
expect( stylesheet ).not.toMatch( /(^|\s):root\s*{/ );
|
|
177
181
|
} );
|
|
178
182
|
|
package/src/types.ts
CHANGED
|
@@ -394,9 +394,15 @@ export type ChartTheme = {
|
|
|
394
394
|
*
|
|
395
395
|
* `fontFamily: 'inherit'` is load-bearing here — it overrides the font stack
|
|
396
396
|
* `buildChartTheme` injects inline on SVG `<text>`.
|
|
397
|
+
*
|
|
398
|
+
* Give `fontSize` in pixels (`12` or `'12px'`). Margins and pie labels cannot measure `rem`, `em`, `%` or `var()`, so they use the default size and labels can clip (CHARTS-303). An invalid size is dropped.
|
|
397
399
|
*/
|
|
398
400
|
svgLabelSmall?: Omit< TextProps, 'fill' >;
|
|
399
|
-
/**
|
|
401
|
+
/**
|
|
402
|
+
* Styles for large SVG text (eg. axis titles), passed through to the XYChart theme.
|
|
403
|
+
*
|
|
404
|
+
* Give `fontSize` in pixels, as for `svgLabelSmall`.
|
|
405
|
+
*/
|
|
400
406
|
svgLabelBig?: TextProps;
|
|
401
407
|
annotationStyles?: ThemeAnnotationStyles;
|
|
402
408
|
/** LeaderboardChart specific settings */
|
|
@@ -654,6 +660,9 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
|
|
|
654
660
|
shapeStyles?: LegendShapeStyles;
|
|
655
661
|
};
|
|
656
662
|
|
|
663
|
+
/** Names a comparison series' legend item after the label of the series it is compared with. */
|
|
664
|
+
export type ComparisonItemLabel = ( seriesLabel: string ) => string;
|
|
665
|
+
|
|
657
666
|
/**
|
|
658
667
|
* Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
|
|
659
668
|
* of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
|
|
@@ -674,6 +683,11 @@ export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
|
|
|
674
683
|
* Pass a string to replace the default label.
|
|
675
684
|
*/
|
|
676
685
|
comparisonItem?: boolean | string;
|
|
686
|
+
/**
|
|
687
|
+
* Append one static item per comparison series instead, named by calling this with the label
|
|
688
|
+
* of the series it is compared with, and left out while that series is hidden.
|
|
689
|
+
*/
|
|
690
|
+
comparisonSeriesItems?: ComparisonItemLabel;
|
|
677
691
|
};
|
|
678
692
|
|
|
679
693
|
/**
|
|
@@ -704,6 +718,10 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
|
|
|
704
718
|
* Additional CSS class name for the chart container
|
|
705
719
|
*/
|
|
706
720
|
className?: string;
|
|
721
|
+
/**
|
|
722
|
+
* Accessible name of the chart. Name it after what it shows, and give each chart on a page a different name. Each chart type has a localized default, such as "Line chart".
|
|
723
|
+
*/
|
|
724
|
+
ariaLabel?: string;
|
|
707
725
|
/**
|
|
708
726
|
* Width of the chart container in pixels. When omitted, the chart fills its parent's width.
|
|
709
727
|
*/
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
const toFontSize = ( size: number ): number | undefined =>
|
|
2
|
+
Number.isFinite( size ) && size >= 0 ? size : undefined;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Resolve a theme `fontSize` value into a plain number suitable for
|
|
3
6
|
* canvas-based measurement (e.g. `getStringWidth`).
|
|
4
7
|
*
|
|
5
8
|
* Accepts:
|
|
6
|
-
* - A number — returned as-is
|
|
9
|
+
* - A finite, non-negative number — returned as-is
|
|
7
10
|
* - A pixel string like `"12px"` — parsed and returned as a number
|
|
8
11
|
*
|
|
9
|
-
* Returns `undefined` for any other input (missing value,
|
|
10
|
-
* relative units like `rem`/`em`/`%`/`vh`) so callers can fall back to
|
|
12
|
+
* Returns `undefined` for any other input (missing value, a non-finite or
|
|
13
|
+
* negative size, or relative units like `rem`/`em`/`%`/`vh`) so callers can fall back to
|
|
11
14
|
* their own default. Relative units are intentionally rejected because
|
|
12
15
|
* we cannot resolve them to absolute pixels here without a parent
|
|
13
16
|
* computed style, and silently returning the unitless prefix
|
|
@@ -18,19 +21,15 @@
|
|
|
18
21
|
*/
|
|
19
22
|
export const resolveFontSize = ( val?: number | string ): number | undefined => {
|
|
20
23
|
if ( typeof val === 'number' ) {
|
|
21
|
-
return
|
|
24
|
+
return toFontSize( val );
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
if ( typeof val === 'string' ) {
|
|
25
28
|
// Only accept plain numeric strings or pixel values.
|
|
26
29
|
// Reject rem, em, %, vh, vw, etc. — they cannot be resolved to
|
|
27
30
|
// absolute pixels without a computed style context.
|
|
28
|
-
const match = val.trim().match(
|
|
29
|
-
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
const parsed = parseFloat( match[ 1 ] );
|
|
33
|
-
return isNaN( parsed ) ? undefined : parsed;
|
|
31
|
+
const match = val.trim().match( /^\+?((?:\d*\.)?\d+)(px)?$/ );
|
|
32
|
+
return match ? toFontSize( parseFloat( match[ 1 ] ) ) : undefined;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
return undefined;
|
|
@@ -11,8 +11,8 @@ describe( 'resolveFontSize', () => {
|
|
|
11
11
|
expect( resolveFontSize( 13.5 ) ).toBe( 13.5 );
|
|
12
12
|
} );
|
|
13
13
|
|
|
14
|
-
it( 'returns undefined for
|
|
15
|
-
expect( resolveFontSize(
|
|
14
|
+
it.each( [ NaN, Infinity, -12 ] )( 'returns undefined for %p', val => {
|
|
15
|
+
expect( resolveFontSize( val ) ).toBeUndefined();
|
|
16
16
|
} );
|
|
17
17
|
} );
|
|
18
18
|
|
|
@@ -25,8 +25,17 @@ describe( 'resolveFontSize', () => {
|
|
|
25
25
|
it( 'parses pixel strings', () => {
|
|
26
26
|
expect( resolveFontSize( '12px' ) ).toBe( 12 );
|
|
27
27
|
expect( resolveFontSize( '13.5px' ) ).toBe( 13.5 );
|
|
28
|
+
expect( resolveFontSize( '.5px' ) ).toBe( 0.5 );
|
|
29
|
+
expect( resolveFontSize( '+12px' ) ).toBe( 12 );
|
|
28
30
|
} );
|
|
29
31
|
|
|
32
|
+
it.each( [ '12.px', '12.', '-12px', '-.5', '9'.repeat( 400 ) + 'px' ] )(
|
|
33
|
+
'rejects %p, which is not a valid font size',
|
|
34
|
+
val => {
|
|
35
|
+
expect( resolveFontSize( val ) ).toBeUndefined();
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
30
39
|
it( 'trims whitespace before parsing', () => {
|
|
31
40
|
expect( resolveFontSize( ' 14px ' ) ).toBe( 14 );
|
|
32
41
|
} );
|