@automattic/charts 4.6.0 → 4.7.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 +16 -0
- package/TOKENS.md +12 -10
- package/dist/index.cjs +244 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -20
- package/dist/index.d.cts +10 -10
- package/dist/index.d.ts +10 -10
- package/dist/index.js +245 -146
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +4 -2
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -1
- package/src/charts/conversion-funnel-chart/types.ts +3 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
- package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
- package/src/charts/heatmap-chart/types.ts +2 -6
- package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
- package/src/charts/line-chart/line-chart.module.scss +0 -5
- package/src/charts/line-chart/line-chart.tsx +8 -40
- package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
- package/src/charts/pie-chart/pie-chart.module.scss +9 -0
- package/src/charts/pie-chart/pie-chart.tsx +22 -76
- package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
- package/src/charts/private/chart-composition/chart-html.tsx +1 -1
- package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
- package/src/charts/private/label-text-color.ts +116 -0
- package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +191 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/components/tooltip/base-tooltip.module.scss +12 -8
- package/src/components/tooltip/base-tooltip.tsx +11 -9
- package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
- package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
- package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
- package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
- package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
- package/src/components/trend-indicator/trend-indicator.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-xychart-theme.ts +4 -12
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
- package/src/providers/chart-context/private/perceptual-color.ts +45 -4
- package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
- package/src/styles/chart-scope-class.ts +5 -0
- package/src/styles/chart-scope.scss +9 -6
- package/src/styles/test/chart-scope.test.ts +3 -3
- package/src/utils/color-utils.ts +2 -1
- package/src/visx/types.ts +1 -1
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
|
@@ -140,54 +140,26 @@ describe( 'LineChart', () => {
|
|
|
140
140
|
} );
|
|
141
141
|
}
|
|
142
142
|
);
|
|
143
|
-
test
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{ color: 'rgb(255, 255, 255)' },
|
|
149
|
-
],
|
|
150
|
-
[ { backgroundColor: 'white' }, 'var(--a8c-charts-color-label)', 'rgb(255, 255, 255)', {} ],
|
|
151
|
-
[ { background: 'white' }, 'var(--a8c-charts-color-label)', 'white', {} ],
|
|
152
|
-
[
|
|
153
|
-
{ color: 'white', background: 'black' },
|
|
154
|
-
'rgb(255, 255, 255)',
|
|
155
|
-
'black',
|
|
156
|
-
{ color: 'rgb(255, 255, 255)' },
|
|
157
|
-
],
|
|
158
|
-
] )(
|
|
159
|
-
'applies directional tooltip color defaults: %j',
|
|
160
|
-
async ( tooltipStyle, color, background, containerColorStyle ) => {
|
|
161
|
-
const user = userEvent.setup();
|
|
162
|
-
const defaults = document.createElement( 'style' );
|
|
163
|
-
defaults.textContent = `.line-chart__tooltip {
|
|
164
|
-
color: var(--a8c-charts-color-label);
|
|
165
|
-
background: var(--a8c-charts-color-surface);
|
|
166
|
-
}`;
|
|
167
|
-
renderWithTheme( { tooltipStyle } );
|
|
168
|
-
const chart = screen.getByTestId( 'line-chart' );
|
|
169
|
-
chart.appendChild( defaults );
|
|
170
|
-
chart.style.setProperty( '--a8c-charts-color-label', '#000' );
|
|
171
|
-
chart.style.setProperty( '--a8c-charts-color-label-axis', '#aaa' );
|
|
172
|
-
chart.style.setProperty( '--a8c-charts-color-surface', '#fff' );
|
|
173
|
-
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
174
|
-
await user.keyboard( '{ArrowRight}' );
|
|
143
|
+
test( 'passes a color-only tooltipStyle through with no background added', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
renderWithTheme( { tooltipStyle: { color: 'white' } } );
|
|
146
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
147
|
+
await user.keyboard( '{ArrowRight}' );
|
|
175
148
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
149
|
+
const box = screen.getByTestId( 'bounded-tooltip' );
|
|
150
|
+
expect( box ).toHaveStyle( { color: 'rgb(255, 255, 255)' } );
|
|
151
|
+
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background' ) );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
test( 'paints a tooltipStyle background on the box, not on the default content', async () => {
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
renderWithTheme( { tooltipStyle: { background: 'black' } } );
|
|
157
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
158
|
+
await user.keyboard( '{ArrowRight}' );
|
|
159
|
+
|
|
160
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'black' } );
|
|
161
|
+
expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).not.toHaveAttribute( 'style' );
|
|
162
|
+
} );
|
|
191
163
|
|
|
192
164
|
test( 'preserves the existing tooltip colors for noncolor overrides', async () => {
|
|
193
165
|
const user = userEvent.setup();
|
|
@@ -3,6 +3,7 @@ import { Pie } from '@visx/shape';
|
|
|
3
3
|
import { useTooltip } from '@visx/tooltip';
|
|
4
4
|
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
+
import isEqual from 'fast-deep-equal';
|
|
6
7
|
import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
7
8
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
8
9
|
import { BaseTooltip } from '../../components/tooltip';
|
|
@@ -21,20 +22,14 @@ import {
|
|
|
21
22
|
GlobalChartsContext,
|
|
22
23
|
} from '../../providers';
|
|
23
24
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
24
|
-
import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
|
|
25
25
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
26
|
-
import {
|
|
27
|
-
attachSubComponents,
|
|
28
|
-
createCssVariableResolver,
|
|
29
|
-
isValidHexColor,
|
|
30
|
-
normalizeColorToHex,
|
|
31
|
-
resolveFontSize,
|
|
32
|
-
} from '../../utils';
|
|
26
|
+
import { attachSubComponents, createCssVariableResolver, resolveFontSize } from '../../utils';
|
|
33
27
|
import { getStringWidth } from '../../visx/text';
|
|
34
28
|
import { Center } from '../private/center';
|
|
35
29
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
36
30
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
37
31
|
import { ChartLayout } from '../private/chart-layout';
|
|
32
|
+
import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-color';
|
|
38
33
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
|
|
39
34
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
40
35
|
import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
|
|
@@ -47,7 +42,8 @@ import type {
|
|
|
47
42
|
Optional,
|
|
48
43
|
} from '../../types';
|
|
49
44
|
import type { ChartComponentWithComposition } from '../private/chart-composition';
|
|
50
|
-
import type {
|
|
45
|
+
import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
|
|
46
|
+
import type { JSX, SVGProps, MouseEvent, ReactNode, FC } from 'react';
|
|
51
47
|
|
|
52
48
|
/**
|
|
53
49
|
* Parameters passed to the renderTooltip function for pie charts.
|
|
@@ -170,48 +166,12 @@ const validateData = ( data: DataPointPercentage[] ) => {
|
|
|
170
166
|
return { isValid: true, message: '' };
|
|
171
167
|
};
|
|
172
168
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
labelHex: string;
|
|
180
|
-
labelInverseHex: string;
|
|
181
|
-
/** True when the inverse role is see-through, so only the `label` role can be seen on a fill. */
|
|
182
|
-
isInverseSeeThrough: boolean;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
|
|
187
|
-
*
|
|
188
|
-
* A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
|
|
189
|
-
* Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
|
|
190
|
-
* yet resolved, or labels off) keep the default inverse role.
|
|
191
|
-
*
|
|
192
|
-
* @param fill - The slice's resolved fill color.
|
|
193
|
-
* @param pointers - The label pointers, resolved at the chart's own root element.
|
|
194
|
-
* @return Whether the label needs dark text.
|
|
195
|
-
*/
|
|
196
|
-
const labelNeedsDarkText = ( fill: string, pointers: ResolvedLabelPointers | null ): boolean => {
|
|
197
|
-
if ( ! pointers || pointers.hasPlate ) {
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const fillHex = normalizeColorToHex( fill );
|
|
202
|
-
if ( ! isValidHexColor( fillHex ) || ! isValidHexColor( pointers.labelHex ) ) {
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
if ( pointers.isInverseSeeThrough ) {
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
|
-
if ( ! isValidHexColor( pointers.labelInverseHex ) ) {
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return (
|
|
213
|
-
contrastRatio( fillHex, pointers.labelHex ) > contrastRatio( fillHex, pointers.labelInverseHex )
|
|
214
|
-
);
|
|
169
|
+
// `label-inverse` is the stylesheet's default, so it needs no modifier.
|
|
170
|
+
const LABEL_TEXT_MODIFIER: Record< LabelTextColor, string | undefined > = {
|
|
171
|
+
label: styles[ 'pie-chart__label-text--on-light' ],
|
|
172
|
+
'label-inverse': undefined,
|
|
173
|
+
black: styles[ 'pie-chart__label-text--black' ],
|
|
174
|
+
white: styles[ 'pie-chart__label-text--white' ],
|
|
215
175
|
};
|
|
216
176
|
|
|
217
177
|
/**
|
|
@@ -258,7 +218,8 @@ const PieChartInternal = ( {
|
|
|
258
218
|
// The element the chart's own `className` lands on, so an override set there reaches this
|
|
259
219
|
// decision the same way it reaches CSS.
|
|
260
220
|
const rootRef = useRef< HTMLDivElement >( null );
|
|
261
|
-
|
|
221
|
+
// Null until read, and while a label plate is set: text on the plate keeps the inverse role.
|
|
222
|
+
const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
|
|
262
223
|
|
|
263
224
|
const onMouseLeave = useCallback( () => {
|
|
264
225
|
if ( ! withTooltips ) {
|
|
@@ -279,28 +240,11 @@ const PieChartInternal = ( {
|
|
|
279
240
|
|
|
280
241
|
const resolve = createCssVariableResolver( rootRef.current );
|
|
281
242
|
const rawLabelBackground = resolve( CATALOG_POINTERS.labelBackground );
|
|
282
|
-
// Hex drops alpha, so a see-through label role would win the comparison and paint nothing.
|
|
283
|
-
const rawLabel = resolve( CATALOG_POINTERS.label );
|
|
284
|
-
const isLabelOpaque = rawLabel ? d3Color( rawLabel )?.opacity === 1 : false;
|
|
285
|
-
const rawLabelInverse = resolve( CATALOG_POINTERS.labelInverse );
|
|
286
|
-
const labelInverseColor = rawLabelInverse ? d3Color( rawLabelInverse ) : null;
|
|
287
243
|
// A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
|
|
288
244
|
const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
labelInverseHex: normalizeColorToHex( CATALOG_POINTERS.labelInverse, null, resolve ),
|
|
293
|
-
isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false,
|
|
294
|
-
};
|
|
295
|
-
setLabelPointers( previous =>
|
|
296
|
-
previous &&
|
|
297
|
-
previous.hasPlate === next.hasPlate &&
|
|
298
|
-
previous.labelHex === next.labelHex &&
|
|
299
|
-
previous.labelInverseHex === next.labelInverseHex &&
|
|
300
|
-
previous.isInverseSeeThrough === next.isInverseSeeThrough
|
|
301
|
-
? previous
|
|
302
|
-
: next
|
|
303
|
-
);
|
|
245
|
+
const hasPlate = rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false;
|
|
246
|
+
const next = hasPlate ? null : resolveLabelRoles( resolve );
|
|
247
|
+
setLabelRoles( previous => ( isEqual( previous, next ) ? previous : next ) );
|
|
304
248
|
}, [ showLabels, className, isColorPaletteResolved, isValid ] );
|
|
305
249
|
|
|
306
250
|
// Calculate percentages from values (single source of truth)
|
|
@@ -538,10 +482,12 @@ const PieChartInternal = ( {
|
|
|
538
482
|
pointerEvents="none"
|
|
539
483
|
/>
|
|
540
484
|
<text
|
|
541
|
-
className={ clsx(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
485
|
+
className={ clsx(
|
|
486
|
+
styles[ 'pie-chart__label-text' ],
|
|
487
|
+
LABEL_TEXT_MODIFIER[
|
|
488
|
+
pickLabelTextColor( fill, labelRoles, 'label-inverse' )
|
|
489
|
+
]
|
|
490
|
+
) }
|
|
545
491
|
data-testid="pie-label"
|
|
546
492
|
x={ centroidX }
|
|
547
493
|
y={ centroidY }
|
|
@@ -187,17 +187,17 @@ describe( 'PieChart', () => {
|
|
|
187
187
|
|
|
188
188
|
test( "compares against a label-inverse override set on the chart's own class", () => {
|
|
189
189
|
injectedStyle = document.createElement( 'style' );
|
|
190
|
-
//
|
|
191
|
-
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #
|
|
190
|
+
// Black out-contrasts the dark label role on a light slice, where the default inverse would lose.
|
|
191
|
+
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #000000; }';
|
|
192
192
|
document.head.appendChild( injectedStyle );
|
|
193
193
|
|
|
194
194
|
renderWithTheme( {
|
|
195
|
-
data: [ { label: '
|
|
195
|
+
data: [ { label: 'Light', value: 100, color: '#f5d76e' } ],
|
|
196
196
|
className: 'overridden-pie',
|
|
197
197
|
} );
|
|
198
198
|
|
|
199
199
|
const [ label ] = screen.getAllByTestId( 'pie-label' );
|
|
200
|
-
expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
200
|
+
expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
201
201
|
} );
|
|
202
202
|
|
|
203
203
|
test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
|
|
@@ -239,7 +239,7 @@ describe( 'PieChart', () => {
|
|
|
239
239
|
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
240
240
|
} );
|
|
241
241
|
|
|
242
|
-
test( '
|
|
242
|
+
test( 'never picks a see-through inverse role, falling back to white where the label role fails', () => {
|
|
243
243
|
injectedStyle = document.createElement( 'style' );
|
|
244
244
|
injectedStyle.textContent =
|
|
245
245
|
'.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
|
|
@@ -247,9 +247,22 @@ describe( 'PieChart', () => {
|
|
|
247
247
|
|
|
248
248
|
renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
|
|
249
249
|
|
|
250
|
-
screen
|
|
251
|
-
|
|
252
|
-
|
|
250
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
251
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
252
|
+
expect( dark ).toHaveClass( 'pie-chart__label-text--white' );
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
test( 'falls back to black or white on a slice where neither label role reaches AA', () => {
|
|
256
|
+
renderWithTheme( {
|
|
257
|
+
data: [
|
|
258
|
+
{ label: 'Mid-light', value: 50, color: '#516dec' },
|
|
259
|
+
{ label: 'Mid-dark', value: 50, color: '#4663ea' },
|
|
260
|
+
],
|
|
261
|
+
} );
|
|
262
|
+
|
|
263
|
+
const [ midLight, midDark ] = screen.getAllByTestId( 'pie-label' );
|
|
264
|
+
expect( midLight ).toHaveClass( 'pie-chart__label-text--black' );
|
|
265
|
+
expect( midDark ).toHaveClass( 'pie-chart__label-text--white' );
|
|
253
266
|
} );
|
|
254
267
|
|
|
255
268
|
test( 'keeps the inverse label color when the fill cannot be resolved', () => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
2
|
+
import isEqual from 'fast-deep-equal';
|
|
3
|
+
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
4
|
+
import { MIN_LABEL_CONTRAST } from '../../providers/chart-context/private/palette-generator';
|
|
5
|
+
import {
|
|
6
|
+
blendRgb,
|
|
7
|
+
hexToRgb,
|
|
8
|
+
luminanceContrastRatio,
|
|
9
|
+
rgbLuminance,
|
|
10
|
+
} from '../../providers/chart-context/private/perceptual-color';
|
|
11
|
+
import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
|
|
12
|
+
import type { Rgb } from '../../providers/chart-context/private/perceptual-color';
|
|
13
|
+
|
|
14
|
+
// The browser paints the fill on 8-bit channels, which can cost a mid-tone ~0.03 of contrast.
|
|
15
|
+
const ROUNDING_HEADROOM = 0.05;
|
|
16
|
+
|
|
17
|
+
/** Which color a label drawn on a fill paints with: one of the two catalog roles, or the black/white floor. */
|
|
18
|
+
export type LabelTextColor = 'label' | 'label-inverse' | 'black' | 'white';
|
|
19
|
+
|
|
20
|
+
/** A label role as the chart's own element resolves it. `unreadable` keeps the raw value, which CSS may still paint. */
|
|
21
|
+
export type ResolvedRole =
|
|
22
|
+
{ kind: 'color'; rgb: Rgb; alpha: number } | { kind: 'unreadable'; raw: string | null };
|
|
23
|
+
|
|
24
|
+
export interface LabelRoles {
|
|
25
|
+
label: ResolvedRole;
|
|
26
|
+
labelInverse: ResolvedRole;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const resolveRole = (
|
|
30
|
+
pointer: string,
|
|
31
|
+
resolve: ( value: string ) => string | null
|
|
32
|
+
): ResolvedRole => {
|
|
33
|
+
const raw = resolve( pointer );
|
|
34
|
+
// d3 reads no CSS Color 4 syntax (`rgb(0 0 0)`, `oklch()`), though the browser paints it.
|
|
35
|
+
const parsed = raw ? d3Color( raw ) : null;
|
|
36
|
+
if ( ! parsed ) {
|
|
37
|
+
return { kind: 'unreadable', raw };
|
|
38
|
+
}
|
|
39
|
+
// d3 gives `transparent` NaN channels; with no alpha they never reach the composite anyway.
|
|
40
|
+
const { r, g, b } = parsed.opacity > 0 ? parsed.rgb() : { r: 0, g: 0, b: 0 };
|
|
41
|
+
return { kind: 'color', rgb: [ r, g, b ], alpha: parsed.opacity };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads both label roles at the element the label text inherits from, so JS and CSS agree on what paints.
|
|
46
|
+
*
|
|
47
|
+
* @param resolve - A CSS variable resolver bound to that element.
|
|
48
|
+
* @return The resolved label roles.
|
|
49
|
+
*/
|
|
50
|
+
export const resolveLabelRoles = ( resolve: ( value: string ) => string | null ): LabelRoles => ( {
|
|
51
|
+
label: resolveRole( CATALOG_POINTERS.label, resolve ),
|
|
52
|
+
labelInverse: resolveRole( CATALOG_POINTERS.labelInverse, resolve ),
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
// Text with alpha shows the fill through it, so it is measured as the blend the browser paints.
|
|
56
|
+
const roleContrast = ( role: ResolvedRole, fill: Rgb, fillLuminance: number ): number => {
|
|
57
|
+
if ( role.kind === 'unreadable' ) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return luminanceContrastRatio(
|
|
61
|
+
fillLuminance,
|
|
62
|
+
rgbLuminance( blendRgb( role.rgb, fill, role.alpha ) )
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Picks the label color for text drawn on a fill: the role that contrasts more, or black/white when neither reaches AA.
|
|
68
|
+
*
|
|
69
|
+
* Both roles set to one color is a host's choice to keep that color, so it never falls back.
|
|
70
|
+
*
|
|
71
|
+
* @param fill - The fill the text sits on, as unrounded sRGB channels.
|
|
72
|
+
* @param roles - The label roles resolved at the chart's element, or null before they are read.
|
|
73
|
+
* @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
|
|
74
|
+
* @return The color the label should paint with.
|
|
75
|
+
*/
|
|
76
|
+
export const pickLabelTextColorForFill = (
|
|
77
|
+
fill: Rgb,
|
|
78
|
+
roles: LabelRoles | null,
|
|
79
|
+
defaultRole: 'label' | 'label-inverse'
|
|
80
|
+
): LabelTextColor => {
|
|
81
|
+
if ( ! roles || isEqual( roles.label, roles.labelInverse ) ) {
|
|
82
|
+
return defaultRole;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fillLuminance = rgbLuminance( fill );
|
|
86
|
+
const labelContrast = roleContrast( roles.label, fill, fillLuminance );
|
|
87
|
+
const inverseContrast = roleContrast( roles.labelInverse, fill, fillLuminance );
|
|
88
|
+
if ( Math.max( labelContrast, inverseContrast ) >= MIN_LABEL_CONTRAST + ROUNDING_HEADROOM ) {
|
|
89
|
+
return labelContrast > inverseContrast ? 'label' : 'label-inverse';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On any opaque fill one of these reaches at least 4.58:1.
|
|
93
|
+
return luminanceContrastRatio( fillLuminance, 0 ) > luminanceContrastRatio( fillLuminance, 1 )
|
|
94
|
+
? 'black'
|
|
95
|
+
: 'white';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `pickLabelTextColorForFill` for a fill given as a color.
|
|
100
|
+
*
|
|
101
|
+
* @param fill - The fill, in any color syntax `normalizeColorToHex` reads.
|
|
102
|
+
* @param roles - The label roles resolved at the chart's element, or null before they are read.
|
|
103
|
+
* @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
|
|
104
|
+
* @return The color the label should paint with.
|
|
105
|
+
*/
|
|
106
|
+
export const pickLabelTextColor = (
|
|
107
|
+
fill: string,
|
|
108
|
+
roles: LabelRoles | null,
|
|
109
|
+
defaultRole: 'label' | 'label-inverse'
|
|
110
|
+
): LabelTextColor => {
|
|
111
|
+
const fillHex = normalizeColorToHex( fill );
|
|
112
|
+
if ( ! isValidHexColor( fillHex ) ) {
|
|
113
|
+
return defaultRole;
|
|
114
|
+
}
|
|
115
|
+
return pickLabelTextColorForFill( hexToRgb( fillHex ), roles, defaultRole );
|
|
116
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { MIN_LABEL_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
|
|
2
|
+
import {
|
|
3
|
+
blendRgb,
|
|
4
|
+
hexToRgb,
|
|
5
|
+
luminanceContrastRatio,
|
|
6
|
+
rgbLuminance,
|
|
7
|
+
} from '../../../providers/chart-context/private/perceptual-color';
|
|
8
|
+
import {
|
|
9
|
+
pickLabelTextColor,
|
|
10
|
+
pickLabelTextColorForFill,
|
|
11
|
+
resolveLabelRoles,
|
|
12
|
+
} from '../label-text-color';
|
|
13
|
+
import type { Rgb } from '../../../providers/chart-context/private/perceptual-color';
|
|
14
|
+
import type { LabelRoles, LabelTextColor, ResolvedRole } from '../label-text-color';
|
|
15
|
+
|
|
16
|
+
const color = ( hex: string, alpha = 1 ): ResolvedRole => ( {
|
|
17
|
+
kind: 'color',
|
|
18
|
+
rgb: hexToRgb( hex ),
|
|
19
|
+
alpha,
|
|
20
|
+
} );
|
|
21
|
+
|
|
22
|
+
const unreadable = ( raw: string | null ): ResolvedRole => ( { kind: 'unreadable', raw } );
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ROLES: LabelRoles = { label: color( '#1e1e1e' ), labelInverse: color( '#f0f0f0' ) };
|
|
25
|
+
|
|
26
|
+
const WHITE = hexToRgb( '#ffffff' );
|
|
27
|
+
const MID_BLUE = hexToRgb( '#3858e9' );
|
|
28
|
+
|
|
29
|
+
describe( 'resolveLabelRoles', () => {
|
|
30
|
+
const resolverFor =
|
|
31
|
+
( label: string | null, labelInverse: string | null ) =>
|
|
32
|
+
( value: string ): string | null =>
|
|
33
|
+
value.includes( '--a8c-charts-color-label-inverse' ) ? labelInverse : label;
|
|
34
|
+
|
|
35
|
+
it( 'resolves opaque roles in any syntax d3 reads', () => {
|
|
36
|
+
expect( resolveLabelRoles( resolverFor( '#1E1E1E', 'rgb(240, 240, 240)' ) ) ).toEqual(
|
|
37
|
+
DEFAULT_ROLES
|
|
38
|
+
);
|
|
39
|
+
} );
|
|
40
|
+
|
|
41
|
+
it( "keeps a role's alpha", () => {
|
|
42
|
+
expect( resolveLabelRoles( resolverFor( 'rgba(30, 30, 30, 0.87)', '#f0f0f0' ) ).label ).toEqual(
|
|
43
|
+
color( '#1e1e1e', 0.87 )
|
|
44
|
+
);
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'reads transparent as a role with no alpha', () => {
|
|
48
|
+
expect( resolveLabelRoles( resolverFor( 'transparent', '#f0f0f0' ) ).label ).toEqual(
|
|
49
|
+
color( '#000000', 0 )
|
|
50
|
+
);
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'keeps the raw value of a role it cannot read', () => {
|
|
54
|
+
expect( resolveLabelRoles( resolverFor( null, 'oklch(98% 0 0)' ) ) ).toEqual( {
|
|
55
|
+
label: unreadable( null ),
|
|
56
|
+
labelInverse: unreadable( 'oklch(98% 0 0)' ),
|
|
57
|
+
} );
|
|
58
|
+
} );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
describe( 'pickLabelTextColorForFill', () => {
|
|
62
|
+
it( 'keeps the default role before the roles are read', () => {
|
|
63
|
+
expect( pickLabelTextColorForFill( MID_BLUE, null, 'label' ) ).toBe( 'label' );
|
|
64
|
+
expect( pickLabelTextColorForFill( MID_BLUE, null, 'label-inverse' ) ).toBe( 'label-inverse' );
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
it( 'picks the role that contrasts more when it reaches AA', () => {
|
|
68
|
+
expect( pickLabelTextColorForFill( WHITE, DEFAULT_ROLES, 'label-inverse' ) ).toBe( 'label' );
|
|
69
|
+
expect( pickLabelTextColorForFill( MID_BLUE, DEFAULT_ROLES, 'label' ) ).toBe( 'label-inverse' );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'falls back to black or white when neither role reaches AA', () => {
|
|
73
|
+
expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), DEFAULT_ROLES, 'label' ) ).toBe(
|
|
74
|
+
'black'
|
|
75
|
+
);
|
|
76
|
+
expect( pickLabelTextColorForFill( hexToRgb( '#4663ea' ), DEFAULT_ROLES, 'label' ) ).toBe(
|
|
77
|
+
'white'
|
|
78
|
+
);
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
it( 'measures a translucent role as it paints over the fill', () => {
|
|
82
|
+
// Opaque, this label would read 16:1 on white; at 30% it paints a pale gray that fails AA.
|
|
83
|
+
const faintLabel: LabelRoles = {
|
|
84
|
+
label: color( '#1e1e1e', 0.3 ),
|
|
85
|
+
labelInverse: color( '#f0f0f0' ),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
expect( pickLabelTextColorForFill( WHITE, faintLabel, 'label-inverse' ) ).toBe( 'black' );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
it( 'never picks a fully transparent role', () => {
|
|
92
|
+
const clearLabel: LabelRoles = {
|
|
93
|
+
label: color( '#000000', 0 ),
|
|
94
|
+
labelInverse: color( '#f0f0f0' ),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
expect( pickLabelTextColorForFill( WHITE, clearLabel, 'label' ) ).toBe( 'black' );
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
it( 'treats two different translucent roles as two colors', () => {
|
|
101
|
+
const translucent: LabelRoles = {
|
|
102
|
+
label: color( '#000000', 0.87 ),
|
|
103
|
+
labelInverse: color( '#ffffff', 0.9 ),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect( pickLabelTextColorForFill( MID_BLUE, translucent, 'label' ) ).toBe( 'label-inverse' );
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
it( 'uses a readable role when the other cannot be read, else black or white', () => {
|
|
110
|
+
const unreadableInverse: LabelRoles = {
|
|
111
|
+
label: color( '#1e1e1e' ),
|
|
112
|
+
labelInverse: unreadable( 'rgb(255 255 255)' ),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
expect( pickLabelTextColorForFill( WHITE, unreadableInverse, 'label-inverse' ) ).toBe(
|
|
116
|
+
'label'
|
|
117
|
+
);
|
|
118
|
+
expect( pickLabelTextColorForFill( MID_BLUE, unreadableInverse, 'label' ) ).toBe( 'white' );
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
it.each( [
|
|
122
|
+
[ 'readable', color( '#767676' ) ],
|
|
123
|
+
[ 'unreadable', unreadable( 'oklch(55% 0 0)' ) ],
|
|
124
|
+
] )( 'never falls back when both roles are the same %s color', ( _name, role ) => {
|
|
125
|
+
const pinned: LabelRoles = { label: role, labelInverse: { ...role } };
|
|
126
|
+
|
|
127
|
+
expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label' ) ).toBe( 'label' );
|
|
128
|
+
expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label-inverse' ) ).toBe(
|
|
129
|
+
'label-inverse'
|
|
130
|
+
);
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
describe.each( [
|
|
134
|
+
[ 'white', '#ffffff' ],
|
|
135
|
+
[ 'dark', '#1e1e1e' ],
|
|
136
|
+
] )( 'across a heatmap scale on a %s background', ( _name, background ) => {
|
|
137
|
+
const painted: Record< LabelTextColor, number > = {
|
|
138
|
+
label: rgbLuminance( hexToRgb( '#1e1e1e' ) ),
|
|
139
|
+
'label-inverse': rgbLuminance( hexToRgb( '#f0f0f0' ) ),
|
|
140
|
+
black: 0,
|
|
141
|
+
white: 1,
|
|
142
|
+
};
|
|
143
|
+
// The Storybook custom accent, then the wp-admin scheme colors.
|
|
144
|
+
const primaries = [
|
|
145
|
+
'#4a19ab',
|
|
146
|
+
'#2271b1',
|
|
147
|
+
'#007cba',
|
|
148
|
+
'#3858e9',
|
|
149
|
+
'#437aa8',
|
|
150
|
+
'#916745',
|
|
151
|
+
'#646c3e',
|
|
152
|
+
'#cf4339',
|
|
153
|
+
'#567958',
|
|
154
|
+
'#ad631e',
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
it.each( primaries )( 'reaches AA on every intensity of %s', primary => {
|
|
158
|
+
for ( let step = 0; step <= 200; step++ ) {
|
|
159
|
+
const fill = blendRgb(
|
|
160
|
+
hexToRgb( primary ),
|
|
161
|
+
hexToRgb( background ),
|
|
162
|
+
0.15 + 0.85 * ( step / 200 )
|
|
163
|
+
);
|
|
164
|
+
const choice = pickLabelTextColorForFill( fill, DEFAULT_ROLES, 'label' );
|
|
165
|
+
// Checked on the 8-bit channels the browser paints as well as the exact mix.
|
|
166
|
+
const [ r, g, b ] = fill.map( Math.round );
|
|
167
|
+
const painted8Bit: Rgb = [ r, g, b ];
|
|
168
|
+
|
|
169
|
+
for ( const shown of [ fill, painted8Bit ] ) {
|
|
170
|
+
expect(
|
|
171
|
+
luminanceContrastRatio( rgbLuminance( shown ), painted[ choice ] )
|
|
172
|
+
).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} );
|
|
176
|
+
} );
|
|
177
|
+
} );
|
|
178
|
+
|
|
179
|
+
describe( 'pickLabelTextColor', () => {
|
|
180
|
+
it( 'reads the fill from any color syntax', () => {
|
|
181
|
+
expect( pickLabelTextColor( 'rgb(255, 255, 255)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
|
|
182
|
+
'label'
|
|
183
|
+
);
|
|
184
|
+
} );
|
|
185
|
+
|
|
186
|
+
it( 'keeps the default role for a fill it cannot read', () => {
|
|
187
|
+
expect( pickLabelTextColor( 'var(--not-set)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
|
|
188
|
+
'label-inverse'
|
|
189
|
+
);
|
|
190
|
+
} );
|
|
191
|
+
} );
|
|
@@ -3,6 +3,7 @@ import { useContext } from 'react';
|
|
|
3
3
|
import { ChartScopeContext } from '../../../../providers/chart-scope';
|
|
4
4
|
import { withResponsive } from '../index';
|
|
5
5
|
import type { BaseChartProps } from '../../../../types';
|
|
6
|
+
import type { JSX } from 'react';
|
|
6
7
|
|
|
7
8
|
// Mock the useParentSize hook
|
|
8
9
|
jest.mock( '@visx/responsive', () => ( {
|