@automattic/charts 4.4.0 → 4.6.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +524 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +22 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +524 -203
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +22 -10
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/geo-chart/geo-chart.tsx +8 -0
  14. package/src/charts/geo-chart/index.ts +7 -1
  15. package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
  16. package/src/charts/geo-chart/types.ts +17 -0
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  18. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  19. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  20. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  21. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  22. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  23. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  24. package/src/index.ts +7 -1
  25. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  26. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  27. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  28. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  29. package/src/providers/chart-context/private/series-palette.ts +1 -1
  30. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  31. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  32. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  33. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  34. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  35. package/src/utils/color-utils.ts +5 -3
  36. package/src/utils/test/date-formatting.test.ts +1 -1
  37. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
@@ -341,10 +341,41 @@ export const useKeyboardNavigation = ( {
341
341
  [ isNavigating, selectedIndex, setSelectedIndex ]
342
342
  );
343
343
 
344
+ // The point the pointer took over a keyboard selection at, where the next arrow key continues from.
345
+ const pointerIndex = useRef< number | undefined >( undefined );
346
+
347
+ // The pointer is the latest input, so it ends a keyboard selection instead of competing with it.
348
+ const onChartPointerMove = useCallback(
349
+ ( index: number ) => {
350
+ if ( selectedIndex === undefined ) {
351
+ if ( pointerIndex.current !== undefined ) {
352
+ pointerIndex.current = index;
353
+ }
354
+ return;
355
+ }
356
+ const root = getChartRoot();
357
+ const activeElement = root?.ownerDocument.activeElement;
358
+ // Focus elsewhere on the page stays there; a hover must not pull it back into the chart.
359
+ if ( activeElement && root.contains( activeElement ) ) {
360
+ pointerIndex.current = index;
361
+ focusWithoutScrollIfNeeded( root );
362
+ }
363
+ setSelectedIndex( undefined );
364
+ setIsNavigating( false );
365
+ },
366
+ [ selectedIndex, setSelectedIndex, setIsNavigating, getChartRoot, focusWithoutScrollIfNeeded ]
367
+ );
368
+
344
369
  // On each blur of chart, keyboard navigation should restart from first tooltip
345
- const onChartBlur = useCallback( () => {
346
- setIsNavigating( false );
347
- }, [ setIsNavigating ] );
370
+ const onChartBlur = useCallback(
371
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
372
+ setIsNavigating( false );
373
+ if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
374
+ pointerIndex.current = undefined;
375
+ }
376
+ },
377
+ [ setIsNavigating ]
378
+ );
348
379
 
349
380
  const onChartKeyDown = useCallback(
350
381
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
@@ -356,12 +387,16 @@ export const useKeyboardNavigation = ( {
356
387
  focusWithoutScrollIfNeeded( getChartRoot() );
357
388
  setSelectedIndex( undefined );
358
389
  setIsNavigating( false );
390
+ pointerIndex.current = undefined;
359
391
  return;
360
392
  }
361
393
 
362
394
  if ( totalPoints === 0 ) return;
363
395
 
364
- const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
396
+ const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
397
+ if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) {
398
+ pointerIndex.current = undefined;
399
+ }
365
400
 
366
401
  // WAI-ARIA grid: arrows stop at the first and last cell.
367
402
  if ( event.key === 'ArrowRight' ) {
@@ -393,5 +428,6 @@ export const useKeyboardNavigation = ( {
393
428
  onChartFocus,
394
429
  onChartBlur,
395
430
  onChartKeyDown,
431
+ onChartPointerMove,
396
432
  };
397
433
  };
@@ -1,6 +1,6 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { act, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { useRef, useState } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
4
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
5
5
  import { GlobalChartsProvider } from '../../../providers';
6
6
  import { ChartScopeContext } from '../../../providers/chart-scope';
@@ -128,11 +128,17 @@ describe( 'AccessibleTooltip', () => {
128
128
  } );
129
129
 
130
130
  // Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
131
- const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
131
+ const NavigationHarness = ( {
132
+ totalPoints,
133
+ trackBlur = false,
134
+ }: {
135
+ totalPoints: number;
136
+ trackBlur?: boolean;
137
+ } ) => {
132
138
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
133
139
  const [ isNavigating, setIsNavigating ] = useState( false );
134
140
  const chartRef = useRef< HTMLDivElement >( null );
135
- const { onChartKeyDown } = useKeyboardNavigation( {
141
+ const { onChartKeyDown, onChartBlur, onChartPointerMove } = useKeyboardNavigation( {
136
142
  selectedIndex,
137
143
  setSelectedIndex,
138
144
  isNavigating,
@@ -140,14 +146,24 @@ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
140
146
  chartRef,
141
147
  totalPoints,
142
148
  } );
149
+ const moveToFour = useCallback( () => onChartPointerMove( 4 ), [ onChartPointerMove ] );
150
+ const moveToOne = useCallback( () => onChartPointerMove( 1 ), [ onChartPointerMove ] );
143
151
 
144
152
  return (
145
153
  <>
146
- <div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
154
+ <div
155
+ role="grid"
156
+ aria-label="Harness"
157
+ tabIndex={ 0 }
158
+ onKeyDown={ onChartKeyDown }
159
+ onBlur={ trackBlur ? onChartBlur : undefined }
160
+ >
147
161
  <div ref={ chartRef } data-testid="selected-index">
148
162
  { selectedIndex ?? 'none' }
149
163
  <button type="button">Inside</button>
150
164
  </div>
165
+ <div data-testid="point-4" onPointerMove={ moveToFour } />
166
+ <div data-testid="point-1" onPointerMove={ moveToOne } />
151
167
  </div>
152
168
  <button type="button">Outside</button>
153
169
  </>
@@ -207,6 +223,67 @@ describe( 'useKeyboardNavigation', () => {
207
223
  expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
208
224
  } );
209
225
 
226
+ it( 'hands a keyboard selection to the pointer and continues from where the pointer is', async () => {
227
+ const { user } = await navigate( 2 );
228
+
229
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
230
+ await user.hover( screen.getByTestId( 'point-4' ) );
231
+
232
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
233
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
234
+
235
+ await user.keyboard( '{ArrowRight}' );
236
+
237
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '5' );
238
+ } );
239
+
240
+ it( 'continues from the last point the pointer reached after taking over', async () => {
241
+ const { user } = await navigate( 2 );
242
+
243
+ await user.hover( screen.getByTestId( 'point-4' ) );
244
+ await user.hover( screen.getByTestId( 'point-1' ) );
245
+ await user.keyboard( '{ArrowRight}' );
246
+
247
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
248
+ } );
249
+
250
+ it( 'restarts from the first point once focus leaves after the pointer took over', async () => {
251
+ const user = userEvent.setup();
252
+ render( <NavigationHarness totalPoints={ 6 } trackBlur /> );
253
+ const grid = screen.getByRole( 'grid', { name: 'Harness' } );
254
+
255
+ act( () => grid.focus() );
256
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
257
+ await user.hover( screen.getByTestId( 'point-4' ) );
258
+ act( () => screen.getByRole( 'button', { name: 'Outside' } ).focus() );
259
+ act( () => grid.focus() );
260
+ await user.keyboard( '{ArrowRight}' );
261
+
262
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
263
+ } );
264
+
265
+ it( 'leaves focus outside the chart when the pointer ends a selection', async () => {
266
+ const { user } = await navigate( 2 );
267
+ const outside = screen.getByRole( 'button', { name: 'Outside' } );
268
+
269
+ outside.focus();
270
+ await user.hover( screen.getByTestId( 'point-4' ) );
271
+
272
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
273
+ expect( outside ).toHaveFocus();
274
+ } );
275
+
276
+ it( 'ignores the pointer when there is no keyboard selection', async () => {
277
+ const user = userEvent.setup();
278
+ render( <NavigationHarness totalPoints={ 6 } /> );
279
+
280
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
281
+ await user.hover( screen.getByTestId( 'point-4' ) );
282
+ await user.keyboard( '{ArrowRight}' );
283
+
284
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '0' );
285
+ } );
286
+
210
287
  // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
211
288
  const recordPageKeyDown = () => {
212
289
  const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
package/src/index.ts CHANGED
@@ -82,7 +82,13 @@ export type {
82
82
  PieSemiCircleChartProps,
83
83
  PieSemiCircleChartRenderTooltipParams,
84
84
  } from './charts/pie-semi-circle-chart';
85
- export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './charts/geo-chart';
85
+ export type {
86
+ GeoChartProps,
87
+ GeoRegion,
88
+ GeoResolution,
89
+ GeoDisplayMode,
90
+ GeoChartError,
91
+ } from './charts/geo-chart';
86
92
  export type { LegendValueDisplay, BaseLegendItem } from './components/legend';
87
93
  export type { TrendIndicatorProps, TrendDirection } from './components/trend-indicator';
88
94
  export type { LineStyles, GridStyles, EventHandlerParams } from '@visx/xychart';
@@ -1,4 +1,4 @@
1
- import { hsl as d3Hsl } from '@visx/vendor/d3-color';
1
+ import { color as d3Color } from '@visx/vendor/d3-color';
2
2
  import {
3
3
  createContext,
4
4
  useCallback,
@@ -17,6 +17,7 @@ import {
17
17
  getItemShapeStyles,
18
18
  getSeriesBarStyles,
19
19
  getSeriesLineStyles,
20
+ isValidHexColor,
20
21
  mergeThemes,
21
22
  resolveCssVariable,
22
23
  normalizeColorToHex,
@@ -24,13 +25,39 @@ import {
24
25
  import { sanitizeFormatting } from '../../utils/date-formatting';
25
26
  // Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
26
27
  import { ChartScopeContext } from '../chart-scope/chart-scope-context';
27
- import { getChartColor, type ColorCache } from './private/get-chart-color';
28
- import { SERIES_PALETTE_POINTERS } from './private/series-palette';
28
+ import {
29
+ BACKGROUND_FALLBACK,
30
+ CATALOG_POINTERS,
31
+ LABEL_FALLBACK,
32
+ LABEL_INVERSE_FALLBACK,
33
+ } from './private/catalog-pointers';
34
+ import { createPaletteGenerator } from './private/palette-generator';
35
+ import { SERIES_PALETTE_POINTERS, SERIES_SLOT_1_FALLBACK } from './private/series-palette';
29
36
  import { defaultTheme } from './themes';
30
37
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
31
38
  import type { ChartTheme, CompleteChartTheme } from '../../types';
32
39
  import type { FC, ReactNode } from 'react';
33
40
 
41
+ interface ColorCache {
42
+ colors: string[];
43
+ background: string;
44
+ labelColors: string[];
45
+ colorAt: ( index: number ) => string;
46
+ }
47
+
48
+ // A see-through color (transparent, or any alpha below 1) says nothing about what it will look like
49
+ // over the chart, so it resolves to null rather than let its RGB leak into the palette.
50
+ const resolveOpaqueHex = ( pointer: string, element: HTMLElement | null ): string | null => {
51
+ const raw = resolveCssVariable( pointer, element );
52
+ if ( ! raw || d3Color( raw )?.opacity !== 1 ) {
53
+ return null;
54
+ }
55
+ const hex = normalizeColorToHex( pointer, element, resolveCssVariable );
56
+ return isValidHexColor( hex ) ? hex : null;
57
+ };
58
+
59
+ const PLACEHOLDER_LABEL_COLORS = [ LABEL_FALLBACK, LABEL_INVERSE_FALLBACK ];
60
+
34
61
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
35
62
 
36
63
  export interface GlobalChartsProviderProps {
@@ -81,12 +108,17 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
81
108
  // Cache expensive color computations that only change when theme colors change
82
109
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
83
110
  // in <style> tags are applied to the DOM before we try to resolve them
111
+ // Seeded with what slot 1 resolves to where no DOM answers, so a one-series first render
112
+ // never builds the candidate grid and SSR matches the unthemed client palette.
84
113
  const [ colorCache, setColorCache ] = useState< ColorCache >( () => ( {
85
114
  colors: [],
86
- hues: [],
87
- existingHslColors: [],
88
- minHue: 360,
89
- maxHue: 0,
115
+ background: BACKGROUND_FALLBACK,
116
+ labelColors: PLACEHOLDER_LABEL_COLORS,
117
+ colorAt: createPaletteGenerator(
118
+ [ SERIES_SLOT_1_FALLBACK ],
119
+ BACKGROUND_FALLBACK,
120
+ PLACEHOLDER_LABEL_COLORS
121
+ ),
90
122
  } ) );
91
123
 
92
124
  // Track if the color palette has been resolved from the DOM
@@ -100,45 +132,29 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
100
132
  useLayoutEffect( () => {
101
133
  setIsColorPaletteResolved( false );
102
134
  const resolvedColors: string[] = [];
103
- const hues: number[] = [];
104
- const existingHslColors: Array< [ number, number, number ] > = [];
105
- let minHue = 360;
106
- let maxHue = 0;
107
135
 
108
136
  for ( const color of SERIES_PALETTE_POINTERS ) {
109
- // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
110
- // This uses normalizeColorToHex which resolves CSS variables and converts
111
- // rgb(), rgba(), hsl() formats to hex
112
137
  const normalizedColor = normalizeColorToHex( color, wrapperRef.current, resolveCssVariable );
113
138
 
114
- // Only process valid hex colors. An unset palette slot returns its own
115
- // `var()` unchanged, so this is also what compacts the palette: slots the
116
- // consumer never set drop out here and `getChartColor` generates past
117
- // whatever survived.
118
- if ( normalizedColor.startsWith( '#' ) ) {
139
+ // An unset palette slot returns its own `var()` unchanged, so this is also what
140
+ // compacts the palette: a slot the consumer never set drops out here.
141
+ if ( isValidHexColor( normalizedColor ) ) {
119
142
  resolvedColors.push( normalizedColor );
120
- const hslColor = d3Hsl( normalizedColor );
121
- // d3Hsl returns NaN values for invalid colors
122
- if ( ! isNaN( hslColor.h ) ) {
123
- const hslTuple: [ number, number, number ] = [
124
- hslColor.h,
125
- hslColor.s * 100,
126
- hslColor.l * 100,
127
- ];
128
- hues.push( hslTuple[ 0 ] );
129
- existingHslColors.push( hslTuple );
130
- minHue = Math.min( minHue, hslTuple[ 0 ] );
131
- maxHue = Math.max( maxHue, hslTuple[ 0 ] );
132
- }
133
143
  }
134
144
  }
135
145
 
146
+ const backgroundHex =
147
+ resolveOpaqueHex( CATALOG_POINTERS.background, wrapperRef.current ) ?? BACKGROUND_FALLBACK;
148
+ // The two roles pie labels choose between on a fill; one left see-through is never painted there.
149
+ const labelColors = [ CATALOG_POINTERS.label, CATALOG_POINTERS.labelInverse ]
150
+ .map( pointer => resolveOpaqueHex( pointer, wrapperRef.current ) )
151
+ .filter( ( hex ): hex is string => hex !== null );
152
+
136
153
  setColorCache( {
137
154
  colors: resolvedColors,
138
- hues,
139
- existingHslColors,
140
- minHue,
141
- maxHue,
155
+ background: backgroundHex,
156
+ labelColors,
157
+ colorAt: createPaletteGenerator( resolvedColors, backgroundHex, labelColors ),
142
158
  } );
143
159
  }, [] );
144
160
 
@@ -152,9 +168,9 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
152
168
  () => new Map()
153
169
  );
154
170
 
155
- // Keyed on the resolved colors rather than the cache object, so a consumer passing an inline
156
- // `theme` cannot reset the map on every render.
157
- const paletteKey = colorCache.colors.join( ',' );
171
+ // Keyed on the resolved colors, background and labels, not the cache object, so a consumer passing an
172
+ // inline `theme` cannot reset the map on every render.
173
+ const paletteKey = `${ colorCache.colors.join( ',' ) }|${ colorCache.background }|${ colorCache.labelColors.join( ',' ) }`;
158
174
 
159
175
  useEffect( () => {
160
176
  // Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems
@@ -206,13 +222,13 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( {
206
222
  // Use map size as index to assign colors sequentially (0, 1, 2...)
207
223
  // ensuring each new group gets the next available palette color
208
224
  const assignedCount = groupToColorMap.size;
209
- const color = getChartColor( assignedCount, colorCache );
225
+ const color = colorCache.colorAt( assignedCount );
210
226
  groupToColorMap.set( group, color );
211
227
 
212
228
  return color;
213
229
  }
214
230
 
215
- return getChartColor( index, colorCache );
231
+ return colorCache.colorAt( index );
216
232
  },
217
233
  [ colorCache, groupToColorMap ]
218
234
  );
@@ -4,9 +4,17 @@ import { SERIES_PALETTE_POINTERS } from './series-palette';
4
4
  * The `var()` chain for each color JS has to hand to something. Nothing here is resolved in JS —
5
5
  * the chain lands on the element; the terminal literal covers SSR and jsdom. See TOKENS.md.
6
6
  */
7
+ /** Terminal literals the provider also generates from before its first read, so SSR and the first client render agree. */
8
+ export const BACKGROUND_FALLBACK = '#ffffff';
9
+ export const LABEL_FALLBACK = '#1e1e1e';
10
+ export const LABEL_INVERSE_FALLBACK = '#f0f0f0';
11
+
7
12
  export const CATALOG_POINTERS = {
8
- background: 'var(--a8c-charts-color-background, #fff)',
13
+ background: `var(--a8c-charts-color-background, ${ BACKGROUND_FALLBACK })`,
14
+ label: `var(--a8c-charts-color-label, ${ LABEL_FALLBACK })`,
9
15
  labelAxis: 'var(--a8c-charts-color-label-axis, #1e1e1e)',
16
+ labelInverse: `var(--a8c-charts-color-label-inverse, ${ LABEL_INVERSE_FALLBACK })`,
17
+ labelBackground: 'var(--a8c-charts-color-label-background, transparent)',
10
18
  grid: 'var(--a8c-charts-color-grid, #dbdbdb)',
11
19
  // The y pair resolves to `none`: that axis carries labels only until a consumer declares them.
12
20
  axisX: 'var(--a8c-charts-color-axis-x, #dbdbdb)',