@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.6.0",
3
+ "version": "4.8.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -12,7 +12,7 @@ import {
12
12
  useCallback,
13
13
  } from 'react';
14
14
  import { Legend, useChartLegendItems } from '../../components/legend';
15
- import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
15
+ import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
16
16
  import {
17
17
  useXYChartTheme,
18
18
  useChartDataTransform,
@@ -509,7 +509,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
509
509
 
510
510
  { withTooltips && (
511
511
  <>
512
- <AccessibleTooltip
512
+ <XYChartTooltip
513
513
  detectBounds
514
514
  snapTooltipToDatumX
515
515
  // Stacked mode: yAccessor returns raw value, not stacked y — snapping mispositions.
@@ -817,7 +817,7 @@ describe( 'AreaChart', () => {
817
817
  } );
818
818
 
819
819
  describe( 'Hover glyphs', () => {
820
- // Trigger the AccessibleTooltip's keyboard nav so a tooltip is opened
820
+ // Trigger the XYChartTooltip's keyboard nav so a tooltip is opened
821
821
  // against a known datum index, which is the only reliable way to
822
822
  // surface the visx TooltipContext state in a jsdom environment.
823
823
  const focusFirstDatum = async () => {
@@ -19,7 +19,7 @@ type CapturedRenderTooltip = ( params: RenderTooltipParams< DataPointDate > ) =>
19
19
  let capturedRenderTooltip: CapturedRenderTooltip | undefined;
20
20
  jest.mock( '../../../components/tooltip', () => ( {
21
21
  ...jest.requireActual( '../../../components/tooltip' ),
22
- AccessibleTooltip: ( props: { renderTooltip?: CapturedRenderTooltip } ) => {
22
+ XYChartTooltip: ( props: { renderTooltip?: CapturedRenderTooltip } ) => {
23
23
  capturedRenderTooltip = props.renderTooltip;
24
24
  return null;
25
25
  },
@@ -4,6 +4,13 @@
4
4
  overflow: visible;
5
5
  }
6
6
 
7
+ // The CSS property, not the attribute: an SVG geometry attribute does
8
+ // not substitute var().
9
+ :global(.visx-bar),
10
+ :global(.bar-chart__comparison-bar) {
11
+ rx: var(--a8c-charts-border-radius-bar-chart);
12
+ }
13
+
7
14
  // Keep the collapsed state in `from` and never on the element: a dropped
8
15
  // animation then fails visible. `both`, not `forwards`, for backwards fill.
9
16
  &--animated rect {
@@ -4,7 +4,7 @@ import { __, sprintf } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import { useCallback, useContext, useState, useRef, useMemo } from 'react';
6
6
  import { Legend, useChartLegendItems } from '../../components/legend';
7
- import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
7
+ import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
8
8
  import {
9
9
  useXYChartTheme,
10
10
  useChartDataTransform,
@@ -242,7 +242,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
242
242
  );
243
243
 
244
244
  // The keyboard-navigation index space and the highlight CSS both stride over primary
245
- // bars only; the accessible tooltip must use the same list, or its datum diverges from
245
+ // bars only; `XYChartTooltip` must use the same list, or its datum diverges from
246
246
  // the highlighted bar once a comparison series shifts the indices.
247
247
  const primarySeries = useMemo(
248
248
  () => primaryEntries.map( ( { series } ) => series ),
@@ -474,7 +474,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
474
474
  // so a patterned shadow gets the same outline as its primary bar.
475
475
  return `
476
476
  .visx-bar[fill="url(#${ patternId })"],
477
- .bar-chart__comparison-bars rect[fill="url(#${ patternId })"] {
477
+ .bar-chart__comparison-bar[fill="url(#${ patternId })"] {
478
478
  stroke: ${ color };
479
479
  stroke-width: 1;
480
480
  }
@@ -767,23 +767,29 @@ const BarChartInternal: FC< BarChartProps > = ( {
767
767
  <WholeNumberTicks { ...wholeNumberTicksProps }>
768
768
  { valueTicks => (
769
769
  <>
770
- <Axis
771
- { ...chartOptions.axis.x }
772
- { ...( horizontal && valueTicks ? { tickValues: valueTicks } : {} ) }
773
- />
774
- <Axis
775
- { ...chartOptions.axis.y }
776
- { ...( ! horizontal && valueTicks
777
- ? { tickValues: valueTicks }
778
- : {} ) }
779
- />
770
+ { chartOptions.axis.x.display !== false && (
771
+ <Axis
772
+ { ...chartOptions.axis.x }
773
+ { ...( horizontal && valueTicks
774
+ ? { tickValues: valueTicks }
775
+ : {} ) }
776
+ />
777
+ ) }
778
+ { chartOptions.axis.y.display !== false && (
779
+ <Axis
780
+ { ...chartOptions.axis.y }
781
+ { ...( ! horizontal && valueTicks
782
+ ? { tickValues: valueTicks }
783
+ : {} ) }
784
+ />
785
+ ) }
780
786
  </>
781
787
  ) }
782
788
  </WholeNumberTicks>
783
789
  ) }
784
790
 
785
791
  { withTooltips && (
786
- <AccessibleTooltip
792
+ <XYChartTooltip
787
793
  tooltipPlacement={ tooltipPlacement }
788
794
  tooltipAnchorTop={ tooltipAnchorTop }
789
795
  style={ tooltipStyle }
@@ -127,6 +127,7 @@ export const ComparisonBars: FC< {
127
127
  rects.push(
128
128
  <rect
129
129
  key={ `${ index }-${ i }` }
130
+ className="bar-chart__comparison-bar"
130
131
  data-testid={ `bar-chart-comparison-${ index }-${ i }` }
131
132
  x={ rect.x }
132
133
  y={ rect.y }
@@ -806,6 +806,31 @@ describe( 'BarChart', () => {
806
806
  );
807
807
  } );
808
808
 
809
+ describe( 'Axis display', () => {
810
+ test.each( [
811
+ [ 'vertical', 'x', 'y' ],
812
+ [ 'vertical', 'y', 'x' ],
813
+ [ 'horizontal', 'x', 'y' ],
814
+ [ 'horizontal', 'y', 'x' ],
815
+ ] )( 'skips a hidden axis (%s, hidden %s)', ( orientation, hidden, shown ) => {
816
+ renderWithTheme( {
817
+ orientation,
818
+ options: {
819
+ axis: {
820
+ [ hidden ]: { display: false, axisClassName: 'test-hidden-axis' },
821
+ [ shown ]: { axisClassName: 'test-shown-axis' },
822
+ },
823
+ },
824
+ } );
825
+ const chart = screen.getByRole( 'grid' );
826
+
827
+ // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
828
+ expect( chart.querySelector( '.test-hidden-axis' ) ).toBeNull();
829
+ // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
830
+ expect( chart.querySelector( '.test-shown-axis' ) ).not.toBeNull();
831
+ } );
832
+ } );
833
+
809
834
  describe( 'Grid Visibility', () => {
810
835
  test( 'renders with different grid visibility options', () => {
811
836
  const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
@@ -114,16 +114,6 @@
114
114
  }
115
115
  }
116
116
 
117
- .tooltip-wrapper {
118
- background: var(--a8c-charts-color-surface);
119
-
120
- // Override .visx-tooltip inline styles.
121
- border-radius: var(--wpds-border-radius-md) !important;
122
- padding: var(--wpds-dimension-padding-md) !important;
123
- 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) !important;
124
-
125
- }
126
-
127
117
  .tooltip-title {
128
118
  color: var(--a8c-charts-color-label);
129
119
  font-size: var(--wpds-typography-font-size-sm);
@@ -36,6 +36,7 @@ import type { FunnelStep, ConversionFunnelChartProps } from './types';
36
36
  * @param props.renderStepRate - Custom render function for step rates
37
37
  * @param props.renderMainMetric - Custom render function for the entire main metric section
38
38
  * @param props.renderTooltip - Custom render function for tooltip content
39
+ * @param props.tooltipStyle - Inline styles merged over the tooltip box defaults
39
40
  * @return JSX element representing the conversion funnel chart
40
41
  */
41
42
  const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
@@ -52,6 +53,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
52
53
  renderStepRate,
53
54
  renderMainMetric,
54
55
  renderTooltip,
56
+ tooltipStyle,
55
57
  } ) => {
56
58
  const chartId = useChartId( providedChartId );
57
59
  const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
@@ -412,7 +414,6 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
412
414
  index: steps.findIndex( s => s.id === ( tooltipData as FunnelStep ).id ),
413
415
  top: tooltipTop,
414
416
  left: tooltipLeft,
415
- className: styles[ 'tooltip-wrapper' ],
416
417
  } )
417
418
  : renderDefaultTooltip( tooltipData as FunnelStep );
418
419
 
@@ -423,9 +424,10 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
423
424
  <BoundedTooltip
424
425
  top={ tooltipTop }
425
426
  left={ tooltipLeft }
426
- className={ clsx( standaloneScopeClass, styles[ 'tooltip-wrapper' ] ) }
427
+ className={ standaloneScopeClass }
428
+ style={ tooltipStyle }
427
429
  >
428
- { tooltipContent }
430
+ <div role="tooltip">{ tooltipContent }</div>
429
431
  </BoundedTooltip>
430
432
  );
431
433
  } )() }
@@ -427,10 +427,29 @@ describe( 'ConversionFunnelChart', () => {
427
427
  index: 1,
428
428
  top: expect.any( Number ),
429
429
  left: expect.any( Number ),
430
- className: 'tooltip-wrapper',
431
430
  } );
432
431
  } );
433
432
 
433
+ it( 'exposes the tooltip content with role="tooltip"', async () => {
434
+ const user = userEvent.setup();
435
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
436
+
437
+ await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
438
+
439
+ expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Cart' );
440
+ } );
441
+
442
+ it( 'merges tooltipStyle onto the tooltip box', async () => {
443
+ const user = userEvent.setup();
444
+ renderWithoutTheme(
445
+ <ConversionFunnelChart { ...defaultProps } tooltipStyle={ { background: 'purple' } } />
446
+ );
447
+
448
+ await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
449
+
450
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'purple' } );
451
+ } );
452
+
434
453
  it( 'disables tooltip when renderTooltip returns null', async () => {
435
454
  const user = userEvent.setup();
436
455
  const customRenderTooltip = jest.fn( () => null );
@@ -50,6 +50,7 @@ export interface TooltipRenderProps {
50
50
  index: number;
51
51
  top: number;
52
52
  left: number;
53
+ /** @deprecated The tooltip box draws the package surface; this is no longer set. */
53
54
  className?: string;
54
55
  }
55
56
 
@@ -80,4 +81,6 @@ export interface ConversionFunnelChartProps extends Pick<
80
81
  renderMainMetric?: ( props: MainMetricRenderProps ) => React.ReactNode;
81
82
  /** Custom render function for tooltip content */
82
83
  renderTooltip?: ( props: TooltipRenderProps ) => React.ReactNode;
84
+ /** Inline styles merged over the tooltip box defaults. */
85
+ tooltipStyle?: React.CSSProperties;
83
86
  }
@@ -3,6 +3,10 @@
3
3
  // module, so it needs no runtime cascade and never lands on the DOM.
4
4
  $focus-ring-width: var(--wpds-border-width-focus);
5
5
 
6
+ // JS sets the ends (getHeatmapScale): 3:1 against background and empty cell at
7
+ // the low end. Unresolved, the floor is 15% of the primary.
8
+ $heatmap-fill: color-mix(in sRGB, var(--a8c-charts-color-heatmap-high, var(--a8c-charts-color-heatmap-primary)) calc(var(--a8c-charts-heatmap-cell-intensity) * 100%), var(--a8c-charts-color-heatmap-low, color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) 15%, var(--a8c-charts-color-background, #fff))));
9
+
6
10
  .heatmap-chart {
7
11
  position: relative;
8
12
  // Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
@@ -165,10 +169,8 @@ $focus-ring-width: var(--wpds-border-width-focus);
165
169
  margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
166
170
  }
167
171
 
168
- // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
169
- // an empty cell.
170
172
  .heatmap-chart__cell--filled {
171
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
173
+ background: $heatmap-fill;
172
174
  }
173
175
 
174
176
  .heatmap-chart__cell--selected {
@@ -183,12 +185,19 @@ $focus-ring-width: var(--wpds-border-width-focus);
183
185
  font-size: var(--wpds-typography-font-size-md);
184
186
  }
185
187
 
186
- // Light text on fills dark enough to out-contrast dark text (decided in JS from
187
- // the blended fill luminance).
188
- .heatmap-chart__cell--strong .heatmap-chart__cell-value {
188
+ // Chosen per cell in JS from the blended fill; see pickLabelTextColorForFill.
189
+ .heatmap-chart__cell-value--inverse {
189
190
  color: var(--a8c-charts-color-label-inverse);
190
191
  }
191
192
 
193
+ .heatmap-chart__cell-value--black {
194
+ color: #000;
195
+ }
196
+
197
+ .heatmap-chart__cell-value--white {
198
+ color: #fff;
199
+ }
200
+
192
201
  .heatmap-chart__legend-label {
193
202
  color: var(--a8c-charts-color-label);
194
203
  }
@@ -197,7 +206,7 @@ $focus-ring-width: var(--wpds-border-width-focus);
197
206
  width: var(--wpds-dimension-size-3xs);
198
207
  height: var(--wpds-dimension-size-3xs);
199
208
  border-radius: var(--a8c-charts-border-radius-cell);
200
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
209
+ background: $heatmap-fill;
201
210
  }
202
211
 
203
212
  // One band: the steps butt against each other and only the band's ends round.
@@ -1,10 +1,11 @@
1
1
  import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
2
- import { defaultStyles as visxTooltipStyles, useTooltip } from '@visx/tooltip';
2
+ import { useTooltip } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
+ import isEqual from 'fast-deep-equal';
5
6
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
- import tooltipStyles from '../../components/tooltip/base-tooltip.module.scss';
7
7
  import { BoundedTooltip, TOOLTIP_Z_INDEX } from '../../components/tooltip/private/bounded-tooltip';
8
+ import { useIsomorphicLayoutEffect } from '../../hooks';
8
9
  import {
9
10
  GlobalChartsProvider,
10
11
  useChartId,
@@ -12,27 +13,30 @@ import {
12
13
  useGlobalChartsContext,
13
14
  GlobalChartsContext,
14
15
  } from '../../providers';
15
- import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
16
+ import {
17
+ BACKGROUND_FALLBACK,
18
+ CATALOG_POINTERS,
19
+ } from '../../providers/chart-context/private/catalog-pointers';
20
+ import { blendRgb, hexToRgb } from '../../providers/chart-context/private/perceptual-color';
21
+ import { resolveOpaqueHex } from '../../providers/chart-context/private/resolve-opaque-hex';
16
22
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
17
23
  import { attachSubComponents } from '../../utils';
18
- import {
19
- isValidHexColor,
20
- mixHexColors,
21
- normalizeColorToHex,
22
- prefersLightText,
23
- } from '../../utils/color-utils';
24
- import { resolveCssVariable } from '../../utils/resolve-css-var';
24
+ import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
25
+ import { createCssVariableResolver } from '../../utils/resolve-css-var';
25
26
  import { Center } from '../private/center';
26
27
  import { useChartChildren } from '../private/chart-composition';
27
28
  import { ChartInstanceContext } from '../private/chart-instance-context';
28
29
  import { ChartLayout } from '../private/chart-layout';
30
+ import { pickLabelTextColorForFill, resolveLabelRoles } from '../private/label-text-color';
29
31
  import { withResponsive } from '../private/with-responsive';
30
32
  import styles from './heatmap-chart.module.scss';
31
33
  import {
34
+ getHeatmapScale,
32
35
  getValueExtent,
33
36
  getNormalizedValue,
34
37
  HeatmapContext,
35
38
  HeatmapLegend,
39
+ isEmptyValue,
36
40
  isPresent,
37
41
  resolveColumnGroups,
38
42
  } from './private';
@@ -46,22 +50,23 @@ import {
46
50
  import type { HeatmapContextValue } from './private';
47
51
  import type { CellBlock, CellPosition } from './private/keyboard-navigation';
48
52
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
53
+ import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
49
54
  import type { ResponsiveConfig } from '../private/with-responsive';
50
55
  import type { CSSProperties, FC } from 'react';
51
56
 
52
- // Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
53
- // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
54
- const CELL_MIX_FLOOR = 0.15;
57
+ // `label` is the stylesheet's default, so it needs no modifier.
58
+ const CELL_VALUE_MODIFIER: Record< LabelTextColor, string | undefined > = {
59
+ label: undefined,
60
+ 'label-inverse': styles[ 'heatmap-chart__cell-value--inverse' ],
61
+ black: styles[ 'heatmap-chart__cell-value--black' ],
62
+ white: styles[ 'heatmap-chart__cell-value--white' ],
63
+ };
55
64
 
56
65
  // One instance, not a `[]` default in the signature: `buildTooltipData` keys on
57
66
  // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
58
67
  const NO_ROW_LABELS: string[] = [];
59
68
 
60
- // The dark variant is painted by the `.surface` class, so its style carries no box.
61
- const TOOLTIP_BOX_STYLES: Record< 'light' | 'dark', CSSProperties > = {
62
- light: { ...visxTooltipStyles, zIndex: TOOLTIP_Z_INDEX },
63
- dark: { zIndex: TOOLTIP_Z_INDEX },
64
- };
69
+ const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
65
70
 
66
71
  // The cell's own label wins; otherwise the group, column and row labels name it.
67
72
  const cellName = ( info: HeatmapTooltipData ) =>
@@ -88,14 +93,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
88
93
  gap = 'md',
89
94
  withTooltips = false,
90
95
  renderTooltip,
91
- tooltipVariant = 'light',
92
96
  tooltipStyle,
93
97
  children,
94
98
  } ) => {
95
99
  const chartId = useChartId( providedChartId );
96
100
  const tooltipBoxStyle = tooltipStyle
97
- ? { ...TOOLTIP_BOX_STYLES[ tooltipVariant ], ...tooltipStyle }
98
- : TOOLTIP_BOX_STYLES[ tooltipVariant ];
101
+ ? { ...TOOLTIP_BOX_STYLE, ...tooltipStyle }
102
+ : TOOLTIP_BOX_STYLE;
99
103
  const { getElementStyles, theme } = useGlobalChartsContext();
100
104
  const scopeElement = useChartScopeElement();
101
105
  const { heatmapChart: heatmapChartSettings } = theme;
@@ -106,6 +110,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
106
110
  useTooltip< HeatmapTooltipData >();
107
111
  const standaloneScopeClass = useStandaloneScopeClass();
108
112
  const containerRef = useRef< HTMLDivElement >( null );
113
+ const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
109
114
  // The chart root positions the tooltip, so pointer and cell coordinates are
110
115
  // measured against it — found by its id rather than by walking up, so
111
116
  // whatever ChartLayout wraps the grid in cannot shift the origin.
@@ -122,32 +127,56 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
122
127
  overrideColor: primaryColor,
123
128
  } );
124
129
 
125
- // The cell blend substitutes this role at the cell; this read happens at the scope
126
- // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
127
- const chartBackgroundHex = normalizeColorToHex(
128
- CATALOG_POINTERS.background,
129
- scopeElement,
130
- resolveCssVariable
131
- );
130
+ // Both read at the scope element, so an override on the chart's own class reaches neither the
131
+ // fill scale nor the text color (CHARTS-255). See-through counts as white, as for the palette.
132
+ const resolveAtScope = createCssVariableResolver( scopeElement );
133
+ const chartBackgroundHex =
134
+ resolveOpaqueHex( CATALOG_POINTERS.background, resolveAtScope ) ?? BACKGROUND_FALLBACK;
135
+ const emptyCellHex = resolveOpaqueHex( CATALOG_POINTERS.track, resolveAtScope );
132
136
 
133
- // Choose text color from the blended fill, not the raw value.
134
- // If either color cannot resolve to hex, keep dark text.
137
+ // If the primary cannot resolve to hex, the stylesheet falls back to its own blend.
135
138
  const primaryHex = normalizeColorToHex( primaryColorHex );
136
- const cellHasLightText = ( intensity: number ): boolean =>
137
- isValidHexColor( primaryHex ) &&
138
- isValidHexColor( chartBackgroundHex ) &&
139
- prefersLightText(
140
- mixHexColors(
141
- primaryHex,
142
- chartBackgroundHex,
143
- 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity )
144
- )
145
- );
139
+ const scale = useMemo(
140
+ () =>
141
+ isValidHexColor( primaryHex )
142
+ ? getHeatmapScale( primaryHex, chartBackgroundHex, emptyCellHex )
143
+ : null,
144
+ [ primaryHex, chartBackgroundHex, emptyCellHex ]
145
+ );
146
+ const fillVars = useMemo(
147
+ () => ( {
148
+ '--a8c-charts-color-heatmap-primary': primaryColorHex,
149
+ ...( scale && {
150
+ '--a8c-charts-color-heatmap-low': scale.low,
151
+ '--a8c-charts-color-heatmap-high': scale.high,
152
+ } ),
153
+ } ),
154
+ [ primaryColorHex, scale ]
155
+ );
156
+ // Choose text color from the fill the cell paints, mirroring .heatmap-chart__cell--filled.
157
+ const cellTextColor = ( intensity: number ): LabelTextColor =>
158
+ scale
159
+ ? pickLabelTextColorForFill(
160
+ blendRgb( hexToRgb( scale.high ), hexToRgb( scale.low ), intensity ),
161
+ labelRoles,
162
+ 'label'
163
+ )
164
+ : 'label';
165
+
166
+ // Read inside the chart's own class, where the cell text inherits them. Keyed on `data` too,
167
+ // because the grid is not mounted while there is nothing to draw.
168
+ useIsomorphicLayoutEffect( () => {
169
+ if ( ! containerRef.current ) {
170
+ return;
171
+ }
172
+ const next = resolveLabelRoles( createCssVariableResolver( containerRef.current ) );
173
+ setLabelRoles( previous => ( isEqual( previous, next ) ? previous : next ) );
174
+ }, [ data, className, primaryColorHex, chartBackgroundHex ] );
146
175
 
147
176
  const extent = useMemo( () => getValueExtent( data ), [ data ] );
148
177
  const heatmapContext = useMemo< HeatmapContextValue >(
149
- () => ( { extent, primaryColorHex } ),
150
- [ extent, primaryColorHex ]
178
+ () => ( { extent, fillVars } ),
179
+ [ extent, fillVars ]
151
180
  );
152
181
 
153
182
  const columns = data.length;
@@ -371,7 +400,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
371
400
  )
372
401
  .join( ' ' );
373
402
  const gridStyle: Record< string, string | number > = {
374
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
403
+ ...fillVars,
375
404
  gridTemplateColumns: `auto ${ columnTracks }`,
376
405
  gridTemplateRows: `${ hasColumnLabels ? 'auto ' : '' }repeat(${ rows }, ${ rowTrack })${
377
406
  hasGroups ? ' auto' : ''
@@ -523,7 +552,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
523
552
  const value = cell?.value ?? null;
524
553
  const present = isPresent( value );
525
554
  // A summary cell is on another scale, so it takes no fill.
526
- const filled = present && ! column.summary;
555
+ const filled = present && ! column.summary && ! isEmptyValue( value, extent );
527
556
  const normalized = filled ? getNormalizedValue( value, extent ) : 0;
528
557
  const info = buildTooltipData( columnIndex, rowIndex );
529
558
  const accessibleLabel = `${ cellName( info ) }: ${
@@ -547,8 +576,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
547
576
  data-row={ rowIndex }
548
577
  className={ clsx( styles[ 'heatmap-chart__cell' ], {
549
578
  [ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
550
- [ styles[ 'heatmap-chart__cell--strong' ] ]:
551
- filled && cellHasLightText( normalized ),
552
579
  [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
553
580
  ...summaryGaps( columnIndex ),
554
581
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
@@ -566,7 +593,12 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
566
593
  onMouseLeave={ handleCellMouseLeave }
567
594
  >
568
595
  { ( drawValues || column.summary ) && present && (
569
- <span className={ styles[ 'heatmap-chart__cell-value' ] }>
596
+ <span
597
+ className={ clsx(
598
+ styles[ 'heatmap-chart__cell-value' ],
599
+ filled && CELL_VALUE_MODIFIER[ cellTextColor( normalized ) ]
600
+ ) }
601
+ >
570
602
  { /* Compact display; tooltip and aria-label keep full precision. */ }
571
603
  { formatNumberCompact( value ) }
572
604
  </span>
@@ -596,12 +628,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
596
628
  ) }
597
629
  </div>
598
630
  { withTooltips && tooltipOpen && tooltipData && (
599
- <BoundedTooltip
600
- top={ tooltipTop }
601
- left={ tooltipLeft }
602
- className={ tooltipVariant === 'dark' ? tooltipStyles.surface : undefined }
603
- style={ tooltipBoxStyle }
604
- >
631
+ <BoundedTooltip top={ tooltipTop } left={ tooltipLeft } style={ tooltipBoxStyle }>
605
632
  <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
606
633
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
607
634
  </div>
@@ -2,8 +2,8 @@ import { createContext } from 'react';
2
2
 
3
3
  export type HeatmapContextValue = {
4
4
  extent: [ number, number ];
5
- /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
6
- primaryColorHex: string;
5
+ /** The custom properties the fill reads, set on the grid and on each legend swatch. */
6
+ fillVars: Record< string, string >;
7
7
  };
8
8
 
9
9
  /** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
@@ -29,7 +29,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
29
29
  if ( ! context ) {
30
30
  return null;
31
31
  }
32
- const { primaryColorHex } = context;
32
+ const { fillVars } = context;
33
33
  const labelStyle = legend.labelStyles;
34
34
 
35
35
  return (
@@ -57,7 +57,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( {
57
57
  className={ styles[ 'heatmap-chart__legend-swatch' ] }
58
58
  style={
59
59
  {
60
- '--a8c-charts-color-heatmap-primary': primaryColorHex,
60
+ ...fillVars,
61
61
  '--a8c-charts-heatmap-cell-intensity': intensity,
62
62
  } as CSSProperties
63
63
  }