@automattic/charts 4.6.0 → 4.8.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/TOKENS.md +15 -10
  3. package/dist/index.cjs +427 -211
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +40 -26
  6. package/dist/index.d.cts +69 -29
  7. package/dist/index.d.ts +69 -29
  8. package/dist/index.js +427 -213
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
  20. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
  21. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  22. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
  23. package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
  24. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  25. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  26. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  27. package/src/charts/heatmap-chart/private/index.ts +2 -1
  28. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  29. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
  30. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  31. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  32. package/src/charts/heatmap-chart/types.ts +6 -9
  33. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  34. package/src/charts/line-chart/line-chart.module.scss +0 -5
  35. package/src/charts/line-chart/line-chart.tsx +10 -42
  36. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  37. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  38. package/src/charts/pie-chart/pie-chart.tsx +26 -80
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  42. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  43. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  44. package/src/charts/private/label-text-color.ts +116 -0
  45. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  46. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  47. package/src/charts/private/test/label-text-color.test.ts +189 -0
  48. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  49. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  50. package/src/components/tooltip/base-tooltip.tsx +31 -30
  51. package/src/components/tooltip/index.ts +3 -1
  52. package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
  53. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  54. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  55. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  56. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  57. package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
  58. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  59. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  60. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  61. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
  62. package/src/components/tooltip/tooltip-box.module.scss +21 -0
  63. package/src/components/tooltip/tooltip-box.tsx +43 -0
  64. package/src/components/tooltip/types.ts +7 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
  66. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  69. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  70. package/src/hooks/use-chart-margin.tsx +21 -18
  71. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  72. package/src/hooks/use-xychart-theme.ts +4 -12
  73. package/src/index.ts +8 -3
  74. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  75. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
  76. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  77. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  78. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  79. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  81. package/src/styles/chart-scope-class.ts +5 -0
  82. package/src/styles/chart-scope.scss +10 -6
  83. package/src/styles/test/chart-scope.test.ts +3 -3
  84. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  85. package/src/utils/color-utils.ts +2 -1
  86. package/src/utils/test/date-formatting.test.ts +0 -10
  87. package/src/visx/types.ts +1 -1
  88. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
  89. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  90. package/src/components/tooltip/base-tooltip.module.scss +0 -20
  91. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -15,7 +15,7 @@ import {
15
15
  useCallback,
16
16
  } from 'react';
17
17
  import { Legend, useChartLegendItems } from '../../components/legend';
18
- import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
18
+ import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
19
19
  import {
20
20
  useXYChartTheme,
21
21
  useChartDataTransform,
@@ -66,7 +66,7 @@ import type { RenderTooltipParams } from '../../visx/types';
66
66
  import type { ResponsiveConfig } from '../private/with-responsive';
67
67
  import type { TickFormatter } from '@visx/axis';
68
68
  import type { GlyphProps } from '@visx/xychart';
69
- import type { CSSProperties, FC, Ref } from 'react';
69
+ import type { FC, Ref } from 'react';
70
70
 
71
71
  const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
72
72
  return <DefaultGlyph { ...props } key={ props.key } />;
@@ -116,13 +116,11 @@ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution
116
116
  * one row per visible series (label + formatted value), sorted descending by
117
117
  * value. Reused by AreaChart, which has the same multi-series shape.
118
118
  *
119
- * @param params - visx tooltip data and the chart's optional `bucketInfo`.
120
- * @param contentStyle - Explicit tooltip content color overrides.
119
+ * @param params - visx tooltip data and the chart's optional `bucketInfo`.
121
120
  * @return Tooltip JSX, or `null` when no datum is hovered.
122
121
  */
123
122
  export const renderDefaultTooltip = (
124
- params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo },
125
- contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
123
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
126
124
  ) => {
127
125
  const { tooltipData, bucketInfo } = params;
128
126
  const nearestDatum = tooltipData?.nearestDatum?.datum;
@@ -141,11 +139,7 @@ export const renderDefaultTooltip = (
141
139
  } );
142
140
 
143
141
  return (
144
- <div
145
- className={ styles[ 'line-chart__tooltip' ] }
146
- data-testid="line-chart-tooltip-content"
147
- style={ contentStyle }
148
- >
142
+ <div data-testid="line-chart-tooltip-content">
149
143
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
150
144
  <TooltipDate
151
145
  date={ nearestDatum.date }
@@ -153,13 +147,7 @@ export const renderDefaultTooltip = (
153
147
  />
154
148
  </div>
155
149
  { tooltipPoints.map( point => (
156
- <Stack
157
- key={ point.key }
158
- direction="row"
159
- align="center"
160
- justify="space-between"
161
- className={ styles[ 'line-chart__tooltip-row' ] }
162
- >
150
+ <Stack key={ point.key } direction="row" align="center" justify="space-between">
163
151
  <span className={ styles[ 'line-chart__tooltip-label' ] }>{ point.key }:</span>
164
152
  <span className={ styles[ 'line-chart__tooltip-value' ] }>
165
153
  { formatReading( point.value ) }
@@ -577,32 +565,12 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
577
565
  yAccessor: ( d: DataPointDate ) => d?.value,
578
566
  };
579
567
 
580
- const resolvedTooltipStyle = useMemo( () => {
581
- if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
582
- if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
583
- return tooltipStyle;
584
- }
585
- return {
586
- backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
587
- ...tooltipStyle,
588
- };
589
- }, [ renderTooltip, tooltipStyle ] );
590
-
591
568
  // Augments every renderTooltip call with the chart's bucket classification,
592
569
  // default or custom, so a heading keyed on it can't disagree with the axis.
593
570
  const tooltipRenderer = useMemo(
594
571
  () => ( params: RenderTooltipParams< DataPointDate > ) =>
595
- renderTooltip === renderDefaultTooltip
596
- ? renderDefaultTooltip(
597
- { ...params, bucketInfo },
598
- {
599
- color: tooltipStyle?.color,
600
- background: tooltipStyle?.background,
601
- backgroundColor: tooltipStyle?.backgroundColor,
602
- }
603
- )
604
- : renderTooltip( { ...params, bucketInfo } ),
605
- [ renderTooltip, bucketInfo, tooltipStyle ]
572
+ renderTooltip( { ...params, bucketInfo } ),
573
+ [ renderTooltip, bucketInfo ]
606
574
  );
607
575
 
608
576
  if ( error ) {
@@ -816,11 +784,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
816
784
  </ZoomClip>
817
785
 
818
786
  { withTooltips && (
819
- <AccessibleTooltip
787
+ <XYChartTooltip
820
788
  detectBounds
821
789
  snapTooltipToDatumX
822
790
  tooltipPlacement={ tooltipPlacement }
823
- style={ resolvedTooltipStyle }
791
+ style={ tooltipStyle }
824
792
  snapTooltipToDatumY
825
793
  showSeriesGlyphs
826
794
  renderTooltip={ tooltipRenderer }
@@ -140,54 +140,26 @@ describe( 'LineChart', () => {
140
140
  } );
141
141
  }
142
142
  );
143
- test.each( [
144
- [
145
- { color: 'white' },
146
- 'rgb(255, 255, 255)',
147
- 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
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
- const content = screen.getByTestId( 'line-chart-tooltip-content' );
177
- expect( content ).toHaveStyle( {
178
- color,
179
- [ 'backgroundColor' in tooltipStyle ? 'background-color' : 'background' ]:
180
- 'background' in tooltipStyle || 'backgroundColor' in tooltipStyle
181
- ? background
182
- : 'var(--a8c-charts-color-surface)',
183
- } );
184
- const container = screen.getByTestId( 'bounded-tooltip' );
185
- expect( container ).toHaveStyle( {
186
- ...containerColorStyle,
187
- [ 'background' in tooltipStyle ? 'background' : 'background-color' ]: background,
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();
@@ -22,5 +22,14 @@
22
22
  &--on-light {
23
23
  fill: var(--a8c-charts-color-label, #1e1e1e);
24
24
  }
25
+
26
+ // Neither role reaches AA on this slice; see pickLabelTextColor.
27
+ &--black {
28
+ fill: #000;
29
+ }
30
+
31
+ &--white {
32
+ fill: #fff;
33
+ }
25
34
  }
26
35
  }
@@ -3,10 +3,11 @@ 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
- import { BaseTooltip } from '../../components/tooltip';
9
9
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
10
+ import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
10
11
  import {
11
12
  useDataWithPercentages,
12
13
  useLegendVisibilityData,
@@ -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 { SVGProps, MouseEvent, ReactNode, FC } from 'react';
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.
@@ -61,13 +57,13 @@ export type PieChartRenderTooltipParams = {
61
57
 
62
58
  /**
63
59
  * Default tooltip renderer for pie charts.
64
- * Renders a BaseTooltip with the hovered segment's data.
60
+ * Renders the default `label: value` tooltip content for the hovered segment.
65
61
  *
66
62
  * @param {PieChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
67
63
  * @return {ReactNode} The rendered tooltip content
68
64
  */
69
65
  const renderDefaultPieTooltip = ( { tooltipData }: PieChartRenderTooltipParams ): ReactNode => {
70
- return <BaseTooltip data={ tooltipData } top={ 0 } left={ 0 } renderContainer={ false } />;
66
+ return <LabelValueContent data={ tooltipData } />;
71
67
  };
72
68
 
73
69
  export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
@@ -131,7 +127,7 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
131
127
 
132
128
  /**
133
129
  * Custom render function for tooltip content.
134
- * When provided, replaces the default BaseTooltip with custom content.
130
+ * When provided, replaces the default `label: value` tooltip with custom content.
135
131
  */
136
132
  renderTooltip?: ( params: PieChartRenderTooltipParams ) => ReactNode;
137
133
  }
@@ -170,48 +166,12 @@ const validateData = ( data: DataPointPercentage[] ) => {
170
166
  return { isValid: true, message: '' };
171
167
  };
172
168
 
173
- /**
174
- * The label pointers, resolved at the pie chart's own root element so JS and CSS agree on what paints.
175
- */
176
- interface ResolvedLabelPointers {
177
- /** True while a label plate is set; the label text then always uses the inverse role. */
178
- hasPlate: boolean;
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
- const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
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 next: ResolvedLabelPointers = {
290
- hasPlate: rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false,
291
- labelHex: isLabelOpaque ? normalizeColorToHex( CATALOG_POINTERS.label, null, resolve ) : '',
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( styles[ 'pie-chart__label-text' ], {
542
- [ styles[ 'pie-chart__label-text--on-light' ] ]:
543
- labelNeedsDarkText( fill, labelPointers ),
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
- // Matches the slice fill exactly, so the inverse role loses to the default dark label role.
191
- injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #1e3a8a; }';
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: 'Dark', value: 100, color: '#1e3a8a' } ],
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( 'uses the label role on every slice when the inverse role is see-through', () => {
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
- .getAllByTestId( 'pie-label' )
252
- .forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
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', () => {
@@ -5,8 +5,8 @@ import { useTooltip } from '@visx/tooltip';
5
5
  import clsx from 'clsx';
6
6
  import { useCallback, useContext, useMemo, useRef } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
8
- import { BaseTooltip } from '../../components/tooltip';
9
8
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
9
+ import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
10
10
  import {
11
11
  useDataWithPercentages,
12
12
  useLegendVisibilityData,
@@ -53,7 +53,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
53
53
 
54
54
  /**
55
55
  * Default tooltip renderer for semi-circle pie charts.
56
- * Renders a BaseTooltip with the hovered segment's data.
56
+ * Renders the default `label: value` tooltip content for the hovered segment.
57
57
  *
58
58
  * @param {PieSemiCircleChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
59
59
  * @return {ReactNode} The rendered tooltip content
@@ -61,7 +61,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
61
61
  const renderDefaultPieSemiCircleTooltip = ( {
62
62
  tooltipData,
63
63
  }: PieSemiCircleChartRenderTooltipParams ): ReactNode => {
64
- return <BaseTooltip data={ tooltipData } top={ 0 } left={ 0 } renderContainer={ false } />;
64
+ return <LabelValueContent data={ tooltipData } />;
65
65
  };
66
66
 
67
67
  const PAD_ANGLE = 0.03; // Padding between segments
@@ -122,7 +122,7 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
122
122
 
123
123
  /**
124
124
  * Custom render function for tooltip content.
125
- * When provided, replaces the default BaseTooltip with custom content.
125
+ * When provided, replaces the default `label: value` tooltip with custom content.
126
126
  */
127
127
  renderTooltip?: ( params: PieSemiCircleChartRenderTooltipParams ) => ReactNode;
128
128
  }
@@ -96,7 +96,7 @@ describe( 'PieSemiCircleChart', () => {
96
96
 
97
97
  await user.hover( firstSegment );
98
98
 
99
- // Wait for tooltip to be visible - it should show in the BaseTooltip component
99
+ // Wait for tooltip to be visible - it should show the default label: value content
100
100
  const tooltip = await screen.findByRole( 'tooltip' );
101
101
  expect( tooltip ).toHaveTextContent( 'MacOS' );
102
102
 
@@ -1,4 +1,4 @@
1
- import type { FC, PropsWithChildren } from 'react';
1
+ import type { JSX, FC, PropsWithChildren } from 'react';
2
2
 
3
3
  /**
4
4
  * Compound component for HTML children in charts.
@@ -1,4 +1,4 @@
1
- import type { FC, PropsWithChildren } from 'react';
1
+ import type { JSX, FC, PropsWithChildren } from 'react';
2
2
 
3
3
  /**
4
4
  * Compound component for SVG children in charts.
@@ -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
+ };
@@ -9,6 +9,8 @@
9
9
  * - A white mask makes the area visible, while a black mask makes the area invisible.
10
10
  */
11
11
 
12
+ import type { JSX } from 'react';
13
+
12
14
  type Angle = `${ number }deg` | `${ number }rad` | `${ number }grad` | `${ number }turn` | 0 | '0';
13
15
 
14
16
  /**
@@ -1,6 +1,6 @@
1
1
  import { Center } from '../center';
2
2
  import styles from './svg-empty-state.module.scss';
3
- import type { FC, ReactNode } from 'react';
3
+ import type { JSX, FC, ReactNode } from 'react';
4
4
 
5
5
  interface SvgEmptyStateProps {
6
6
  /** X coordinate of the center point */