@automattic/charts 1.8.1 → 1.9.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/dist/index.cjs +530 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +122 -34
- package/dist/index.d.cts +97 -7
- package/dist/index.d.ts +97 -7
- package/dist/index.js +527 -54
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/charts/area-chart/area-chart.module.scss +3 -1
- package/src/charts/area-chart/area-chart.tsx +5 -2
- package/src/charts/bar-chart/bar-chart.module.scss +6 -2
- package/src/charts/bar-chart/private/comparison-bars.tsx +6 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
- package/src/charts/geo-chart/geo-chart.tsx +6 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
- package/src/charts/heatmap-chart/index.ts +4 -0
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
- package/src/charts/heatmap-chart/private/index.ts +5 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
- package/src/charts/heatmap-chart/types.ts +42 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
- package/src/charts/line-chart/line-chart.module.scss +6 -4
- package/src/charts/line-chart/line-chart.tsx +6 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
- package/src/charts/private/grid-control/grid-control.module.scss +1 -4
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
- package/src/charts/private/x-zoom.module.scss +6 -3
- package/src/components/legend/private/base-legend.module.scss +3 -1
- package/src/components/tooltip/base-tooltip.module.scss +4 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
- package/src/hooks/use-xychart-theme.ts +24 -0
- package/src/index.ts +12 -0
- package/src/providers/chart-context/themes.ts +29 -16
- package/src/types.ts +19 -2
- package/src/utils/color-utils.ts +36 -0
- package/src/utils/test/color-utils.test.ts +33 -0
|
@@ -72,6 +72,20 @@ describe( 'withResponsive', () => {
|
|
|
72
72
|
const wrapper = screen.getByTestId( 'responsive-wrapper' );
|
|
73
73
|
expect( wrapper ).toHaveStyle( { width: '100%', height: 'auto' } );
|
|
74
74
|
} );
|
|
75
|
+
|
|
76
|
+
test( 'wrapper expresses aspectRatio in CSS and caps at maxWidth', () => {
|
|
77
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } maxWidth={ 800 } /> );
|
|
78
|
+
const wrapper = screen.getByTestId( 'responsive-wrapper' );
|
|
79
|
+
// CSS aspect-ratio is width/height, so 1 / 0.5 = 2.
|
|
80
|
+
expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
test( 'wrapper omits the maxWidth cap when an explicit width is set', () => {
|
|
84
|
+
render( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } width={ 300 } /> );
|
|
85
|
+
const wrapper = screen.getByTestId( 'responsive-wrapper' );
|
|
86
|
+
expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } );
|
|
87
|
+
expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } );
|
|
88
|
+
} );
|
|
75
89
|
} );
|
|
76
90
|
|
|
77
91
|
describe( 'configuration', () => {
|
|
@@ -93,6 +93,17 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
93
93
|
const effectiveHeight = measuredHeight || height || 0;
|
|
94
94
|
|
|
95
95
|
const defaultHeight = hasAspectRatio ? 'auto' : '100%';
|
|
96
|
+
// Express the aspect ratio in CSS so the container height tracks its width
|
|
97
|
+
// fluidly, rather than snapping to a debounced measured height. Cap the width
|
|
98
|
+
// at maxWidth so the CSS-derived height matches the maxWidth-capped content
|
|
99
|
+
// (the wrapped chart is sized from the capped `measuredWidth`).
|
|
100
|
+
const aspectRatioStyle =
|
|
101
|
+
hasAspectRatio && aspectRatio
|
|
102
|
+
? {
|
|
103
|
+
aspectRatio: `${ 1 / aspectRatio }`,
|
|
104
|
+
maxWidth: width === undefined ? maxWidth : undefined,
|
|
105
|
+
}
|
|
106
|
+
: null;
|
|
96
107
|
|
|
97
108
|
return (
|
|
98
109
|
<div
|
|
@@ -102,6 +113,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
|
|
|
102
113
|
style={ {
|
|
103
114
|
width: width ?? '100%',
|
|
104
115
|
height: height ?? defaultHeight,
|
|
116
|
+
...aspectRatioStyle,
|
|
105
117
|
} }
|
|
106
118
|
>
|
|
107
119
|
<WrappedComponent
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
.x-zoom {
|
|
2
2
|
|
|
3
|
+
// The translucent rgba overlay colors (selection fill/stroke, reset
|
|
4
|
+
// background/border) have no WPDS equivalent and stay as-is; only the
|
|
5
|
+
// opaque foreground and focus colors are tokenized.
|
|
3
6
|
&__selection {
|
|
4
7
|
fill: var(--charts-zoom-selection-fill, rgba(56, 88, 233, 0.16));
|
|
5
8
|
stroke: var(--charts-zoom-selection-stroke, rgba(56, 88, 233, 0.65));
|
|
@@ -19,20 +22,20 @@
|
|
|
19
22
|
height: 28px;
|
|
20
23
|
padding: 0;
|
|
21
24
|
background: var(--charts-zoom-reset-bg, rgba(255, 255, 255, 0.92));
|
|
22
|
-
color: var(--charts-zoom-reset-fg, #1e1e1e);
|
|
25
|
+
color: var(--charts-zoom-reset-fg, var(--wpds-color-fg-content-neutral, #1e1e1e));
|
|
23
26
|
border:
|
|
24
27
|
var(--wpds-border-width-xs, 1px) solid
|
|
25
28
|
var(--charts-zoom-reset-border, rgba(0, 0, 0, 0.16));
|
|
26
29
|
border-radius: var(--wpds-border-radius-md, 4px);
|
|
27
30
|
cursor: pointer;
|
|
28
|
-
box-shadow: 0 1px
|
|
31
|
+
box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
|
|
29
32
|
|
|
30
33
|
&:hover {
|
|
31
34
|
background: var(--charts-zoom-reset-bg-hover, rgba(255, 255, 255, 1));
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
&:focus-visible {
|
|
35
|
-
outline: 2px solid var(--charts-zoom-reset-focus, #3858e9);
|
|
38
|
+
outline: 2px solid var(--charts-zoom-reset-focus, var(--wpds-color-stroke-focus-brand, #3858e9));
|
|
36
39
|
outline-offset: 1px;
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
.tooltip {
|
|
2
2
|
padding: var(--wpds-dimension-padding-sm, 8px);
|
|
3
|
+
// No WPDS token fits a translucent dark tooltip surface:
|
|
4
|
+
// bg-interactive-neutral-strong is opaque, and there is no
|
|
5
|
+
// white-on-dark content foreground token.
|
|
3
6
|
background-color: rgba(0, 0, 0, 0.85);
|
|
4
7
|
color: #fff;
|
|
5
8
|
border-radius: var(--wpds-border-radius-md, 4px);
|
|
6
9
|
font-size: var(--wpds-typography-font-size-md, 13px);
|
|
7
|
-
box-shadow: 0 1px 2px
|
|
10
|
+
box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
8
11
|
position: absolute;
|
|
9
12
|
pointer-events: none;
|
|
10
13
|
transform: translate(-50%, -100%);
|
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
font-weight: var(--wpds-typography-font-weight-medium, 499);
|
|
7
7
|
line-height: 1;
|
|
8
8
|
|
|
9
|
+
// Trend colors use the documented --charts-trend-* override API, falling
|
|
10
|
+
// back to the accessible WPDS semantic tokens.
|
|
9
11
|
&--up {
|
|
10
|
-
color: var(--charts-trend-up-color, #
|
|
12
|
+
color: var(--charts-trend-up-color, var(--wpds-color-fg-content-success-weak, #008030));
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
&--down {
|
|
14
|
-
color: var(--charts-trend-down-color, #
|
|
16
|
+
color: var(--charts-trend-down-color, var(--wpds-color-fg-content-error-weak, #cc1818));
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
&--neutral {
|
|
18
|
-
color: var(--charts-trend-neutral-color, #
|
|
20
|
+
color: var(--charts-trend-neutral-color, var(--wpds-color-fg-content-neutral-weak, #707070));
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
&__icon {
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { buildChartTheme } from '@visx/xychart';
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { useGlobalChartsTheme } from '../providers';
|
|
4
|
+
import { resolveCssVariable } from '../utils';
|
|
4
5
|
import type { SeriesData } from '../types';
|
|
5
6
|
|
|
7
|
+
// visx applies grid, axis, and tick-label colors as SVG presentation attributes,
|
|
8
|
+
// where CSS var() cannot resolve. Resolve WPDS tokens to concrete values (or their
|
|
9
|
+
// fallbacks) before handing the theme to buildChartTheme.
|
|
10
|
+
const resolveColor = ( value?: string ): string | undefined =>
|
|
11
|
+
value ? resolveCssVariable( value ) ?? value : value;
|
|
12
|
+
|
|
6
13
|
export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
7
14
|
const theme = useGlobalChartsTheme();
|
|
8
15
|
|
|
@@ -14,6 +21,23 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
|
|
|
14
21
|
return buildChartTheme( {
|
|
15
22
|
...theme,
|
|
16
23
|
colors: [ ...seriesColors, ...( theme.colors ?? [] ) ],
|
|
24
|
+
backgroundColor: resolveColor( theme.backgroundColor ),
|
|
25
|
+
gridStyles: theme.gridStyles && {
|
|
26
|
+
...theme.gridStyles,
|
|
27
|
+
stroke: resolveColor( theme.gridStyles.stroke ),
|
|
28
|
+
},
|
|
29
|
+
xAxisLineStyles: theme.xAxisLineStyles && {
|
|
30
|
+
...theme.xAxisLineStyles,
|
|
31
|
+
stroke: resolveColor( theme.xAxisLineStyles.stroke ),
|
|
32
|
+
},
|
|
33
|
+
xTickLineStyles: theme.xTickLineStyles && {
|
|
34
|
+
...theme.xTickLineStyles,
|
|
35
|
+
stroke: resolveColor( theme.xTickLineStyles.stroke ),
|
|
36
|
+
},
|
|
37
|
+
svgLabelSmall: theme.svgLabelSmall && {
|
|
38
|
+
...theme.svgLabelSmall,
|
|
39
|
+
fill: resolveColor( theme.svgLabelSmall.fill ),
|
|
40
|
+
},
|
|
17
41
|
} );
|
|
18
42
|
}, [ theme, data ] );
|
|
19
43
|
};
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,11 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
|
|
|
4
4
|
export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
|
|
5
5
|
export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
|
|
6
6
|
export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart';
|
|
7
|
+
export {
|
|
8
|
+
HeatmapChart,
|
|
9
|
+
HeatmapChartUnresponsive,
|
|
10
|
+
buildCalendarHeatmapData,
|
|
11
|
+
} from './charts/heatmap-chart';
|
|
7
12
|
export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
|
|
8
13
|
export { LineChart, LineChartUnresponsive } from './charts/line-chart';
|
|
9
14
|
export { PieChart, PieChartUnresponsive } from './charts/pie-chart';
|
|
@@ -96,6 +101,13 @@ export type {
|
|
|
96
101
|
MainMetricRenderProps,
|
|
97
102
|
TooltipRenderProps,
|
|
98
103
|
} from './charts/conversion-funnel-chart';
|
|
104
|
+
export type {
|
|
105
|
+
HeatmapChartProps,
|
|
106
|
+
HeatmapColumn,
|
|
107
|
+
HeatmapCell,
|
|
108
|
+
HeatmapTooltipData,
|
|
109
|
+
CalendarHeatmapResult,
|
|
110
|
+
} from './charts/heatmap-chart';
|
|
99
111
|
export type { LeaderboardChartProps } from './charts/leaderboard-chart';
|
|
100
112
|
export type {
|
|
101
113
|
LineChartProps,
|
|
@@ -4,22 +4,24 @@ import type { CompleteChartTheme } from '../../types';
|
|
|
4
4
|
* Default theme configuration
|
|
5
5
|
*/
|
|
6
6
|
const defaultTheme: CompleteChartTheme = {
|
|
7
|
-
backgroundColor: '#
|
|
7
|
+
backgroundColor: 'var(--wpds-color-bg-surface-neutral-strong, #fff)',
|
|
8
8
|
labelBackgroundColor: 'transparent', // label background color (transparent by default)
|
|
9
|
-
|
|
9
|
+
// White label text sits on top of arbitrary series colors, so it has no WPDS
|
|
10
|
+
// content-foreground equivalent and stays hardcoded (tokenization outlier).
|
|
11
|
+
labelTextColor: '#FFFFFF',
|
|
10
12
|
colors: [ '#98C8DF', '#006DAB', '#A6DC80', '#1F9828', '#FF8C8F' ],
|
|
11
13
|
gridStyles: {
|
|
12
|
-
stroke: '#
|
|
14
|
+
stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)',
|
|
13
15
|
strokeWidth: 1,
|
|
14
16
|
},
|
|
15
17
|
tickLength: 4,
|
|
16
18
|
gridColor: '',
|
|
17
19
|
gridColorDark: '',
|
|
18
|
-
xTickLineStyles: { stroke: '
|
|
19
|
-
xAxisLineStyles: { stroke: '#
|
|
20
|
+
xTickLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
|
|
21
|
+
xAxisLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
|
|
20
22
|
legend: {
|
|
21
23
|
labelStyles: {
|
|
22
|
-
color: 'var(--
|
|
24
|
+
color: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
|
|
23
25
|
},
|
|
24
26
|
containerStyles: {},
|
|
25
27
|
shapeStyles: [],
|
|
@@ -31,35 +33,40 @@ const defaultTheme: CompleteChartTheme = {
|
|
|
31
33
|
// that `buildChartTheme` injects as an inline style on SVG `<text>`
|
|
32
34
|
// elements for axis labels and ticks. Setting `inherit` lets SVG text
|
|
33
35
|
// pick up the host application's font-family via normal CSS inheritance.
|
|
34
|
-
svgLabelSmall: { fill: 'var(--
|
|
36
|
+
svgLabelSmall: { fill: 'var(--wpds-color-fg-content-neutral, #1e1e1e)', fontFamily: 'inherit' },
|
|
35
37
|
svgLabelBig: { fontFamily: 'inherit' },
|
|
36
38
|
annotationStyles: {
|
|
37
39
|
label: {
|
|
38
|
-
anchorLineStroke: 'var(--
|
|
39
|
-
backgroundFill: '#fff',
|
|
40
|
+
anchorLineStroke: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
|
|
41
|
+
backgroundFill: 'var(--wpds-color-bg-surface-neutral-strong, #fff)',
|
|
40
42
|
},
|
|
41
43
|
connector: {
|
|
42
|
-
stroke: 'var(--
|
|
44
|
+
stroke: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
|
|
43
45
|
},
|
|
44
46
|
circleSubject: {
|
|
45
47
|
stroke: 'transparent',
|
|
46
|
-
fill: 'var(--
|
|
48
|
+
fill: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
|
|
47
49
|
radius: 5,
|
|
48
50
|
},
|
|
49
51
|
},
|
|
50
52
|
geoChart: {
|
|
51
|
-
featureFillColor: 'var(--
|
|
53
|
+
featureFillColor: 'var(--wpds-color-bg-surface-neutral-weak, #f4f4f4)',
|
|
52
54
|
},
|
|
53
55
|
leaderboardChart: {
|
|
54
56
|
rowGap: 12,
|
|
55
57
|
columnGap: 4,
|
|
56
58
|
labelSpacing: 'xs',
|
|
57
|
-
|
|
59
|
+
// [negative, neutral, positive]
|
|
60
|
+
deltaColors: [
|
|
61
|
+
'var(--wpds-color-fg-content-error-weak, #cc1818)',
|
|
62
|
+
'var(--wpds-color-fg-content-neutral-weak, #707070)',
|
|
63
|
+
'var(--wpds-color-fg-content-success-weak, #008030)',
|
|
64
|
+
],
|
|
58
65
|
},
|
|
59
66
|
conversionFunnelChart: {
|
|
60
|
-
backgroundColor: '#
|
|
61
|
-
positiveChangeColor: '#
|
|
62
|
-
negativeChangeColor: '#
|
|
67
|
+
backgroundColor: 'var(--wpds-color-bg-surface-neutral-weak, #f4f4f4)',
|
|
68
|
+
positiveChangeColor: 'var(--wpds-color-fg-content-success-weak, #008030)',
|
|
69
|
+
negativeChangeColor: 'var(--wpds-color-fg-content-error-weak, #cc1818)',
|
|
63
70
|
},
|
|
64
71
|
lineChart: {
|
|
65
72
|
lineStyles: {
|
|
@@ -81,6 +88,12 @@ const defaultTheme: CompleteChartTheme = {
|
|
|
81
88
|
margin: { top: 2, right: 2, bottom: 2, left: 2 },
|
|
82
89
|
strokeWidth: 1.5,
|
|
83
90
|
},
|
|
91
|
+
// `primaryColor` is left unset so it falls back to the palette's `colors[0]`. The compact
|
|
92
|
+
// 11px square / 2px gap is the contribution-graph rhythm, which has no WPDS dimension.
|
|
93
|
+
heatmapChart: {
|
|
94
|
+
compactCellGap: 2,
|
|
95
|
+
compactCellSize: 11,
|
|
96
|
+
},
|
|
84
97
|
};
|
|
85
98
|
|
|
86
99
|
export { defaultTheme };
|
package/src/types.ts
CHANGED
|
@@ -249,8 +249,8 @@ export type GradientStop = {
|
|
|
249
249
|
|
|
250
250
|
export type SeriesDataOptions = {
|
|
251
251
|
gradient?: {
|
|
252
|
-
from
|
|
253
|
-
to
|
|
252
|
+
from?: string;
|
|
253
|
+
to?: string;
|
|
254
254
|
fromOpacity?: number;
|
|
255
255
|
toOpacity?: number;
|
|
256
256
|
stops?: GradientStop[];
|
|
@@ -413,6 +413,21 @@ export type ChartTheme = {
|
|
|
413
413
|
/** Stroke width for the sparkline line */
|
|
414
414
|
strokeWidth?: number;
|
|
415
415
|
};
|
|
416
|
+
/**
|
|
417
|
+
* HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
|
|
418
|
+
* WPDS tokens in CSS, so only the scale color and the compact sizing live here.
|
|
419
|
+
*/
|
|
420
|
+
heatmapChart?: {
|
|
421
|
+
/**
|
|
422
|
+
* Color the cell scale interpolates toward at the highest value (prop > this >
|
|
423
|
+
* palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color.
|
|
424
|
+
*/
|
|
425
|
+
primaryColor?: string;
|
|
426
|
+
/** Gap in px between cells in compact mode */
|
|
427
|
+
compactCellGap?: number;
|
|
428
|
+
/** Fixed square cell size in px for compact mode */
|
|
429
|
+
compactCellSize?: number;
|
|
430
|
+
};
|
|
416
431
|
};
|
|
417
432
|
|
|
418
433
|
/**
|
|
@@ -440,6 +455,8 @@ export type CompleteChartTheme = Required< ChartTheme > & {
|
|
|
440
455
|
sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
|
|
441
456
|
margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
|
|
442
457
|
};
|
|
458
|
+
heatmapChart: Omit< Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >, 'primaryColor' > &
|
|
459
|
+
Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >;
|
|
443
460
|
};
|
|
444
461
|
|
|
445
462
|
export type AxisOptions = {
|
package/src/utils/color-utils.ts
CHANGED
|
@@ -232,3 +232,39 @@ export const lightenHexColor = ( hex: string, blend: number ): string => {
|
|
|
232
232
|
.toString( 16 )
|
|
233
233
|
.padStart( 2, '0' ) }${ newB.toString( 16 ).padStart( 2, '0' ) }`;
|
|
234
234
|
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* WCAG relative luminance of a hex color (0 = black, 1 = white).
|
|
238
|
+
*
|
|
239
|
+
* @param hex - Hex color string (e.g., '#98C8DF')
|
|
240
|
+
* @return Relative luminance in the range [0, 1]
|
|
241
|
+
* @throws {Error} if hex string is malformed
|
|
242
|
+
*/
|
|
243
|
+
export const relativeLuminance = ( hex: string ): number => {
|
|
244
|
+
validateHexColor( hex );
|
|
245
|
+
|
|
246
|
+
const toLinear = ( value: number ): number => {
|
|
247
|
+
const channel = value / 255;
|
|
248
|
+
return channel <= 0.03928 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const r = toLinear( parseInt( hex.slice( 1, 3 ), 16 ) );
|
|
252
|
+
const g = toLinear( parseInt( hex.slice( 3, 5 ), 16 ) );
|
|
253
|
+
const b = toLinear( parseInt( hex.slice( 5, 7 ), 16 ) );
|
|
254
|
+
|
|
255
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether light text reads better than dark text on the given background, using the W3C
|
|
260
|
+
* luminance threshold (0.179) that maximizes contrast against black vs white.
|
|
261
|
+
*
|
|
262
|
+
* @param backgroundHex - Hex background color
|
|
263
|
+
* @return true if light text should be used; false (dark text) for malformed colors
|
|
264
|
+
*/
|
|
265
|
+
export const prefersLightText = ( backgroundHex: string ): boolean => {
|
|
266
|
+
if ( ! isValidHexColor( backgroundHex ) ) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
return relativeLuminance( backgroundHex ) <= 0.179;
|
|
270
|
+
};
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
parseHslString,
|
|
9
9
|
parseRgbString,
|
|
10
10
|
normalizeColorToHex,
|
|
11
|
+
relativeLuminance,
|
|
12
|
+
prefersLightText,
|
|
11
13
|
} from '../color-utils';
|
|
12
14
|
|
|
13
15
|
// Helper to convert hex to HSL tuple using d3-color
|
|
@@ -881,3 +883,34 @@ describe( 'normalizeColorToHex', () => {
|
|
|
881
883
|
} );
|
|
882
884
|
} );
|
|
883
885
|
} );
|
|
886
|
+
|
|
887
|
+
describe( 'relativeLuminance', () => {
|
|
888
|
+
it( 'returns 0 for black and 1 for white', () => {
|
|
889
|
+
expect( relativeLuminance( '#000000' ) ).toBeCloseTo( 0, 5 );
|
|
890
|
+
expect( relativeLuminance( '#ffffff' ) ).toBeCloseTo( 1, 5 );
|
|
891
|
+
} );
|
|
892
|
+
|
|
893
|
+
it( 'returns a higher luminance for a lighter color', () => {
|
|
894
|
+
expect( relativeLuminance( '#98c8df' ) ).toBeGreaterThan( relativeLuminance( '#006dab' ) );
|
|
895
|
+
} );
|
|
896
|
+
|
|
897
|
+
it( 'throws on a malformed hex', () => {
|
|
898
|
+
expect( () => relativeLuminance( 'not-a-color' ) ).toThrow();
|
|
899
|
+
} );
|
|
900
|
+
} );
|
|
901
|
+
|
|
902
|
+
describe( 'prefersLightText', () => {
|
|
903
|
+
it( 'prefers dark text on light backgrounds', () => {
|
|
904
|
+
expect( prefersLightText( '#ffffff' ) ).toBe( false );
|
|
905
|
+
expect( prefersLightText( '#98c8df' ) ).toBe( false );
|
|
906
|
+
} );
|
|
907
|
+
|
|
908
|
+
it( 'prefers light text on dark backgrounds', () => {
|
|
909
|
+
expect( prefersLightText( '#000000' ) ).toBe( true );
|
|
910
|
+
expect( prefersLightText( '#006dab' ) ).toBe( true );
|
|
911
|
+
} );
|
|
912
|
+
|
|
913
|
+
it( 'falls back to dark text for malformed colors', () => {
|
|
914
|
+
expect( prefersLightText( 'var(--token)' ) ).toBe( false );
|
|
915
|
+
} );
|
|
916
|
+
} );
|