@automattic/charts 4.1.0 → 4.2.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 (80) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +7 -5
  3. package/dist/index.cjs +1115 -471
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +39 -1
  6. package/dist/index.d.cts +116 -16
  7. package/dist/index.d.ts +116 -16
  8. package/dist/index.js +1115 -473
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -11
  11. package/src/charts/area-chart/area-chart.tsx +2 -1
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +67 -17
  15. package/src/charts/bar-chart/index.ts +1 -1
  16. package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
  17. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  18. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  19. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  20. package/src/charts/bar-chart/private/index.ts +2 -0
  21. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
  22. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  23. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  24. package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
  25. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  26. package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
  27. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  28. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  29. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  30. package/src/charts/geo-chart/types.ts +4 -2
  31. package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +15 -45
  32. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  33. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  34. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  35. package/src/charts/heatmap-chart/index.ts +15 -3
  36. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  37. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  38. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  39. package/src/charts/heatmap-chart/private/index.ts +2 -2
  40. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  41. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +37 -1
  42. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  43. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  44. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  45. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  46. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
  47. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  48. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
  49. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  50. package/src/charts/heatmap-chart/types.ts +88 -3
  51. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
  52. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  53. package/src/charts/leaderboard-chart/types.ts +13 -14
  54. package/src/charts/line-chart/line-chart.tsx +3 -2
  55. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  56. package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
  57. package/src/charts/line-chart/types.ts +2 -2
  58. package/src/charts/private/time-axis-options.ts +1 -1
  59. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  60. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  61. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  62. package/src/components/legend/private/base-legend.tsx +1 -1
  63. package/src/components/tooltip/accessible-tooltip.tsx +146 -37
  64. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  65. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  66. package/src/components/tooltip/test/accessible-tooltip.test.tsx +160 -0
  67. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +29 -3
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +11 -11
  70. package/src/hooks/test/use-element-size.test.tsx +13 -13
  71. package/src/hooks/use-xychart-theme.ts +1 -1
  72. package/src/index.ts +7 -1
  73. package/src/providers/chart-context/themes.ts +1 -0
  74. package/src/styles/chart-scope.scss +11 -3
  75. package/src/styles/test/chart-scope.test.ts +36 -2
  76. package/src/types.ts +6 -13
  77. package/src/utils/format-metric-value.ts +4 -4
  78. package/src/utils/get-edge-tick-widths.ts +1 -1
  79. package/src/utils/get-styles.ts +1 -1
  80. package/src/visx/types.ts +7 -10
@@ -14,6 +14,9 @@ export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPos
14
14
  };
15
15
 
16
16
  const DEFAULT_OFFSET = 10;
17
+ // Above anything else layered inside a chart, such as the zoom reset button (2).
18
+ // Chart wrappers isolate their stacking context, so it never reaches page chrome.
19
+ export const TOOLTIP_Z_INDEX = 3;
17
20
  const POINTER_HEIGHT = 6;
18
21
 
19
22
  const clamp = ( position: number, min: number, max: number, size: number ) =>
@@ -41,7 +44,8 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
41
44
  /**
42
45
  * Where the box goes, in wrapper coordinates. Automatic placement flips to
43
46
  * the side that clips less, then clamps inside `bounds`; below-axis placement
44
- * is centered on the anchor and clamped horizontally.
47
+ * is centered on the anchor and clamped horizontally; beside placement preserves
48
+ * the anchor's y and only flips horizontally before bounds clamping.
45
49
  *
46
50
  * The flip measures against the visible part of the wrapper, its intersection
47
51
  * with `bounds`. Only the clamp is bound by the page: a box that fits inside the
@@ -55,7 +59,7 @@ const findClippingAncestor = ( wrapper: Element ): Element | null => {
55
59
  * @param params.offsetTop - Gap between the anchor and the box, vertically.
56
60
  * @param params.box - Rendered size of the box.
57
61
  * @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
58
- * @param params.placement - Fixed below-axis placement or automatic flipping.
62
+ * @param params.placement - Below-axis, beside without vertical flipping, or automatic flipping.
59
63
  * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
60
64
  * @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
61
65
  */
@@ -103,7 +107,10 @@ export const getBoundedPosition = ( {
103
107
  const upY = top - offsetTop - box.height;
104
108
  const downOverflow = downY + box.height - fit.bottom;
105
109
  const upOverflow = fit.top - upY;
106
- let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
110
+ let y = top;
111
+ if ( placement !== 'beside' ) {
112
+ y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
113
+ }
107
114
 
108
115
  x = clamp( x, bounds.left, bounds.right, box.width );
109
116
  y = clamp( y, bounds.top, bounds.bottom, box.height );
@@ -122,8 +129,7 @@ export const getBoundedPosition = ( {
122
129
  * alone would let the box spill into an `overflow: hidden` card and be cut off.
123
130
  *
124
131
  * Re-measures on every render, so a box whose content changes width between
125
- * two hovers is placed for its current size. See `getBoundedPosition` for the
126
- * below-axis exception.
132
+ * two hovers is placed for its current size.
127
133
  *
128
134
  * @param props - visx `Tooltip` props.
129
135
  * @param props.left - Anchor x, in wrapper coordinates.
@@ -133,7 +139,7 @@ export const getBoundedPosition = ( {
133
139
  * @param props.style - Box styles; visx's defaults unless `unstyled`.
134
140
  * @param props.unstyled - Skip `style` and leave the box bare.
135
141
  * @param props.children - Box content.
136
- * @param props.placement - Fixed below-axis placement or automatic flipping.
142
+ * @param props.placement - Below-axis, beside without vertical flipping, or automatic flipping.
137
143
  * @return The tooltip box.
138
144
  */
139
145
  export const BoundedTooltip = ( {
@@ -1,8 +1,10 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import { useRef, useState } from 'react';
3
4
  import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
4
5
  import { GlobalChartsProvider } from '../../../providers';
5
6
  import { ChartScopeContext } from '../../../providers/chart-scope';
7
+ import { useKeyboardNavigation } from '../accessible-tooltip';
6
8
  import type { ReactNode } from 'react';
7
9
 
8
10
  // A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
@@ -86,9 +88,167 @@ describe( 'AccessibleTooltip', () => {
86
88
  }
87
89
  } );
88
90
 
91
+ it.each( [
92
+ [ 'Tab', '{Tab}' ],
93
+ [ 'Escape', '{Escape}' ],
94
+ ] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
95
+ const user = userEvent.setup();
96
+ renderChart( undefined, 'below-axis' );
97
+ await openTooltip();
98
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
99
+ try {
100
+ await user.keyboard( keys );
101
+ expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
102
+ } finally {
103
+ focus.mockRestore();
104
+ }
105
+ } );
106
+
107
+ it.each( [
108
+ [ 'Tab', '{Tab}' ],
109
+ [ 'Escape', '{Escape}' ],
110
+ ] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
111
+ const user = userEvent.setup();
112
+ renderChart();
113
+ await openTooltip();
114
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
115
+ try {
116
+ await user.keyboard( keys );
117
+ expect( focus ).toHaveBeenCalledWith();
118
+ } finally {
119
+ focus.mockRestore();
120
+ }
121
+ } );
122
+
89
123
  it( 'falls back to the catalog default when the role is unset', async () => {
90
124
  renderChart();
91
125
 
92
126
  await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
93
127
  } );
94
128
  } );
129
+
130
+ // Mirrors the charts' markup: the focusable grid wraps the element `chartRef` points at.
131
+ const NavigationHarness = ( { totalPoints }: { totalPoints: number } ) => {
132
+ const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
133
+ const [ isNavigating, setIsNavigating ] = useState( false );
134
+ const chartRef = useRef< HTMLDivElement >( null );
135
+ const { onChartKeyDown } = useKeyboardNavigation( {
136
+ selectedIndex,
137
+ setSelectedIndex,
138
+ isNavigating,
139
+ setIsNavigating,
140
+ chartRef,
141
+ totalPoints,
142
+ } );
143
+
144
+ return (
145
+ <>
146
+ <div role="grid" aria-label="Harness" tabIndex={ 0 } onKeyDown={ onChartKeyDown }>
147
+ <div ref={ chartRef } data-testid="selected-index">
148
+ { selectedIndex ?? 'none' }
149
+ <button type="button">Inside</button>
150
+ </div>
151
+ </div>
152
+ <button type="button">Outside</button>
153
+ </>
154
+ );
155
+ };
156
+
157
+ describe( 'useKeyboardNavigation', () => {
158
+ const navigate = async ( presses: number ) => {
159
+ const user = userEvent.setup();
160
+ const view = render( <NavigationHarness totalPoints={ 6 } /> );
161
+
162
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
163
+ for ( let i = 0; i < presses; i++ ) {
164
+ await user.keyboard( '{ArrowRight}' );
165
+ }
166
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( String( presses - 1 ) );
167
+
168
+ return { user, view };
169
+ };
170
+
171
+ it( 'moves the selection to the last point when the count shrinks while the grid has focus', async () => {
172
+ const { view } = await navigate( 6 );
173
+
174
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
175
+
176
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( '2' );
177
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
178
+ } );
179
+
180
+ // In range rather than past the end: the effect reconciles on any count change, not only an overflow.
181
+ it( 'clears an in-range selection when the count changes after focus has left the chart', async () => {
182
+ const { view } = await navigate( 2 );
183
+
184
+ screen.getByRole( 'button', { name: 'Outside' } ).focus();
185
+ view.rerender( <NavigationHarness totalPoints={ 3 } /> );
186
+
187
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
188
+ } );
189
+
190
+ it( 'returns focus to the grid on Escape when there are no points', async () => {
191
+ const user = userEvent.setup();
192
+ render( <NavigationHarness totalPoints={ 0 } /> );
193
+
194
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
195
+ await user.keyboard( '{Escape}' );
196
+
197
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
198
+ } );
199
+
200
+ it( 'returns focus to the grid when the count drops to zero while focus is in the chart', async () => {
201
+ const { view } = await navigate( 2 );
202
+
203
+ screen.getByRole( 'button', { name: 'Inside' } ).focus();
204
+ view.rerender( <NavigationHarness totalPoints={ 0 } /> );
205
+
206
+ expect( screen.getByTestId( 'selected-index' ) ).toHaveTextContent( 'none' );
207
+ expect( screen.getByRole( 'grid', { name: 'Harness' } ) ).toHaveFocus();
208
+ } );
209
+
210
+ // The page sees the event after the handler, so this is what a surrounding scroll or Modal gets.
211
+ const recordPageKeyDown = () => {
212
+ const onKeyDown = jest.fn< void, [ KeyboardEvent ] >();
213
+ document.addEventListener( 'keydown', onKeyDown );
214
+ return { onKeyDown, stop: () => document.removeEventListener( 'keydown', onKeyDown ) };
215
+ };
216
+
217
+ it( 'leaves keys it does not handle available to the page', async () => {
218
+ const user = userEvent.setup();
219
+ render( <NavigationHarness totalPoints={ 6 } /> );
220
+ const { onKeyDown, stop } = recordPageKeyDown();
221
+
222
+ try {
223
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
224
+ await user.keyboard( '{ArrowDown}{PageDown}[Space]' );
225
+
226
+ expect( onKeyDown ).toHaveBeenCalledTimes( 3 );
227
+ for ( const [ event ] of onKeyDown.mock.calls ) {
228
+ expect( event.defaultPrevented ).toBe( false );
229
+ }
230
+ } finally {
231
+ stop();
232
+ }
233
+ } );
234
+
235
+ // `@wordpress/components` Modal skips its own close-on-Escape once the event is defaultPrevented.
236
+ it( 'leaves Escape to the page when there is no selection to dismiss', async () => {
237
+ const user = userEvent.setup();
238
+ render( <NavigationHarness totalPoints={ 6 } /> );
239
+ const { onKeyDown, stop } = recordPageKeyDown();
240
+
241
+ try {
242
+ screen.getByRole( 'grid', { name: 'Harness' } ).focus();
243
+ await user.keyboard( '{Escape}' );
244
+
245
+ expect( onKeyDown.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe( false );
246
+
247
+ await user.keyboard( '{ArrowRight}{Escape}' );
248
+
249
+ expect( onKeyDown.mock.calls.at( -1 )?.[ 0 ].defaultPrevented ).toBe( true );
250
+ } finally {
251
+ stop();
252
+ }
253
+ } );
254
+ } );
@@ -5,6 +5,23 @@ const BOX = { width: 208, height: 36 };
5
5
  const OFFSETS = { offsetLeft: 10, offsetTop: 10 };
6
6
 
7
7
  describe( 'getBoundedPosition', () => {
8
+ test.each( [
9
+ [ 40, 50 ],
10
+ [ 700, 425 ],
11
+ ] )( 'keeps a beside tooltip at its anchor and flips horizontally at %s', ( left, x ) => {
12
+ expect(
13
+ getBoundedPosition( {
14
+ left,
15
+ top: -20,
16
+ ...OFFSETS,
17
+ box: { width: 265, height: 382 },
18
+ wrapper: { left: 0, top: 0, right: 800, bottom: 300 },
19
+ bounds: { left: 0, top: -100, right: 800, bottom: 500 },
20
+ placement: 'beside',
21
+ } )
22
+ ).toEqual( { x, y: -20 } );
23
+ } );
24
+
8
25
  test( 'sits below and to the right of the anchor when that fits', () => {
9
26
  expect(
10
27
  getBoundedPosition( {
@@ -1,9 +1,9 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
- import { BarSeries, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
2
+ import { BarSeries, buildChartTheme, LineSeries, TooltipContext, XYChart } from '@visx/xychart';
3
3
  import { useContext, useEffect } from 'react';
4
4
  import { XyChartTooltip } from '../xy-chart-tooltip';
5
5
  import type { XyChartTooltipProps } from '../../../visx/types';
6
- import type { EventHandlerParams } from '@visx/xychart';
6
+ import type { EventHandlerParams, XYChartTheme } from '@visx/xychart';
7
7
 
8
8
  type Datum = { x: number; y: number };
9
9
 
@@ -76,7 +76,8 @@ const renderChart = (
76
76
  params: EventHandlerParams< Datum >[] = [
77
77
  { key: 'A', index: 1, datum: SERIES_A[ 1 ], svgPoint: { x: 123, y: 45 } },
78
78
  ],
79
- margin = NO_MARGIN
79
+ margin = NO_MARGIN,
80
+ theme?: XYChartTheme
80
81
  ) =>
81
82
  render(
82
83
  <div data-testid="wrapper" style={ { position: 'relative' } }>
@@ -84,6 +85,7 @@ const renderChart = (
84
85
  width={ 200 }
85
86
  height={ 100 }
86
87
  margin={ margin }
88
+ theme={ theme }
87
89
  xScale={ { type: 'linear', domain: [ 0, 10 ] } }
88
90
  yScale={ { type: 'linear', domain: [ 0, 100 ] } }
89
91
  >
@@ -100,6 +102,23 @@ const renderChart = (
100
102
  );
101
103
 
102
104
  describe( 'XyChartTooltip', () => {
105
+ test.each( [
106
+ [ 'CanvasText', '0 1px 2px #22222255' ],
107
+ [ '#123456', '0 1px 2px #12345655' ],
108
+ ] )( 'renders a valid shadow for htmlLabel color %s', async ( color, boxShadow ) => {
109
+ const theme = buildChartTheme( {
110
+ backgroundColor: '#ffffff',
111
+ colors: [ '#123456' ],
112
+ tickLength: 4,
113
+ gridColor: '#dddddd',
114
+ gridColorDark: '#222222',
115
+ htmlLabel: { color },
116
+ } );
117
+ renderChart( {}, undefined, undefined, theme );
118
+
119
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( { boxShadow } );
120
+ } );
121
+
103
122
  test( 'renders the tooltip box inside the chart wrapper, not in a body-level portal', async () => {
104
123
  const { container } = renderChart();
105
124
 
@@ -120,6 +139,13 @@ describe( 'XyChartTooltip', () => {
120
139
  } );
121
140
  } );
122
141
 
142
+ test( 'pins beside placement to the explicit SVG top anchor', async () => {
143
+ renderChart( { tooltipPlacement: 'beside', tooltipAnchorTop: 20, snapTooltipToDatumY: true } );
144
+ await expect( screen.findByTestId( 'tooltip-box' ) ).resolves.toHaveStyle( {
145
+ transform: 'translate(133px, 20px)',
146
+ } );
147
+ } );
148
+
123
149
  test( 'snaps the box to the nearest datum', async () => {
124
150
  renderChart( { snapTooltipToDatumX: true, snapTooltipToDatumY: true } );
125
151
 
@@ -2,7 +2,8 @@ import { Tooltip, defaultStyles } from '@visx/tooltip';
2
2
  import { DataContext, TooltipContext } from '@visx/xychart';
3
3
  import { useCallback, useContext, useState } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import { BoundedTooltip } from './private/bounded-tooltip';
5
+ import { isValidHexColor } from '../../utils';
6
+ import { BoundedTooltip, TOOLTIP_Z_INDEX } from './private/bounded-tooltip';
6
7
  import type {
7
8
  CrosshairStyle,
8
9
  RenderTooltipGlyphProps,
@@ -35,9 +36,6 @@ const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
35
36
  };
36
37
  const DEFAULT_GLYPH_RADIUS = 4;
37
38
  const FALLBACK_COLOR = '#222';
38
- // Above the chart's own overlays (the zoom reset button sits at 2). The chart
39
- // wrapper isolates its stacking context, so this never competes with page chrome.
40
- const DEFAULT_TOOLTIP_Z_INDEX = 3;
41
39
  // Portal-era options, accepted for compatibility and dropped before the box
42
40
  // renders: it sits in the chart wrapper and moves with it, and nothing
43
41
  // measures it any more.
@@ -109,7 +107,8 @@ const XyChartTooltipContent = < Datum extends object >( {
109
107
  horizontalCrosshairStyle,
110
108
  detectBounds = true,
111
109
  tooltipPlacement = 'auto',
112
- zIndex = DEFAULT_TOOLTIP_Z_INDEX,
110
+ tooltipAnchorTop,
111
+ zIndex = TOOLTIP_Z_INDEX,
113
112
  style,
114
113
  ...rest
115
114
  }: XyChartTooltipContentProps< Datum > ) => {
@@ -170,7 +169,9 @@ const XyChartTooltipContent = < Datum extends object >( {
170
169
  const size = Number( glyphStyle?.radius ?? DEFAULT_GLYPH_RADIUS );
171
170
  const labelColor = theme?.htmlLabel?.color ?? FALLBACK_COLOR;
172
171
  // visx colours a lone nearest-datum glyph like the gridlines and series glyphs like labels.
173
- const fallbackColor = showSeriesGlyphs ? labelColor : theme?.gridStyles?.stroke ?? labelColor;
172
+ const fallbackColor = showSeriesGlyphs
173
+ ? labelColor
174
+ : ( theme?.gridStyles?.stroke ?? labelColor );
174
175
  let entries: Array< { key: string; datum: Datum; index: number } > = [];
175
176
  if ( showSeriesGlyphs ) {
176
177
  entries = Object.values( tooltipContext.tooltipData?.datumByKey ?? {} );
@@ -213,14 +214,13 @@ const XyChartTooltipContent = < Datum extends object >( {
213
214
  const marginTop = margin?.top ?? 0;
214
215
  const marginLeft = margin?.left ?? 0;
215
216
 
216
- const TooltipComponent =
217
- detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
217
+ const TooltipComponent = detectBounds || tooltipPlacement !== 'auto' ? BoundedTooltip : Tooltip;
218
218
  const boxStyle: CSSProperties = {
219
219
  ...defaultStyles,
220
220
  zIndex,
221
221
  backgroundColor: theme?.backgroundColor ?? 'white',
222
222
  boxShadow: `0 1px 2px ${
223
- theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
223
+ isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
224
224
  }`,
225
225
  ...theme?.htmlLabel,
226
226
  ...style,
@@ -264,12 +264,12 @@ const XyChartTooltipContent = < Datum extends object >( {
264
264
  top={
265
265
  tooltipPlacement === 'below-axis'
266
266
  ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
267
- : tooltipTop
267
+ : ( tooltipAnchorTop ?? tooltipTop )
268
268
  }
269
269
  style={ boxStyle }
270
270
  applyPositionStyle
271
271
  { ...tooltipProps }
272
- { ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
272
+ { ...( tooltipPlacement !== 'auto' && { placement: tooltipPlacement } ) }
273
273
  >
274
274
  { tooltipContent }
275
275
  </TooltipComponent>,
@@ -1,4 +1,4 @@
1
- import { renderHook, waitFor } from '@testing-library/react';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { useElementSize } from '../use-element-size';
3
3
 
4
4
  // Mock ResizeObserver
@@ -66,7 +66,7 @@ describe( 'useElementSize', () => {
66
66
  const [ refCallback ] = result.current;
67
67
 
68
68
  // Attach the element
69
- refCallback( mockElement as unknown as HTMLDivElement );
69
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
70
70
 
71
71
  await waitFor( () => {
72
72
  expect( result.current[ 1 ] ).toBe( 300 );
@@ -84,7 +84,7 @@ describe( 'useElementSize', () => {
84
84
  const { result } = renderHook( () => useElementSize() );
85
85
  const [ refCallback ] = result.current;
86
86
 
87
- refCallback( mockElement as unknown as HTMLDivElement );
87
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
88
88
 
89
89
  await waitFor( () => {
90
90
  expect( result.current[ 1 ] ).toBe( 0 );
@@ -100,7 +100,7 @@ describe( 'useElementSize', () => {
100
100
  const { result } = renderHook( () => useElementSize() );
101
101
  const [ refCallback ] = result.current;
102
102
 
103
- refCallback( mockElement as unknown as HTMLDivElement );
103
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
104
104
 
105
105
  await waitFor( () => {
106
106
  expect( result.current[ 1 ] ).toBe( 0 );
@@ -129,10 +129,10 @@ describe( 'useElementSize', () => {
129
129
  const [ refCallback ] = result.current;
130
130
 
131
131
  // Attach first element
132
- refCallback( mockElement1 as unknown as HTMLDivElement );
132
+ act( () => refCallback( mockElement1 as unknown as HTMLDivElement ) );
133
133
 
134
134
  // Attach second element
135
- refCallback( mockElement2 as unknown as HTMLDivElement );
135
+ act( () => refCallback( mockElement2 as unknown as HTMLDivElement ) );
136
136
 
137
137
  expect( disconnectSpy ).toHaveBeenCalled();
138
138
  } );
@@ -155,10 +155,10 @@ describe( 'useElementSize', () => {
155
155
  const [ refCallback ] = result.current;
156
156
 
157
157
  // Attach element
158
- refCallback( mockElement as unknown as HTMLDivElement );
158
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
159
159
 
160
160
  // Remove element
161
- refCallback( null as unknown as HTMLDivElement );
161
+ act( () => refCallback( null as unknown as HTMLDivElement ) );
162
162
 
163
163
  expect( disconnectSpy ).toHaveBeenCalled();
164
164
  } );
@@ -180,7 +180,7 @@ describe( 'useElementSize', () => {
180
180
  const { result } = renderHook( () => useElementSize() );
181
181
  const [ refCallback ] = result.current;
182
182
 
183
- refCallback( mockElement as unknown as HTMLDivElement );
183
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
184
184
 
185
185
  expect( globalThis.ResizeObserver ).toHaveBeenCalled();
186
186
  expect( observeSpy ).toHaveBeenCalledWith( mockElement );
@@ -207,7 +207,7 @@ describe( 'useElementSize', () => {
207
207
  const { result } = renderHook( () => useElementSize() );
208
208
  const [ refCallback ] = result.current;
209
209
 
210
- refCallback( mockSpanElement as unknown as HTMLDivElement );
210
+ act( () => refCallback( mockSpanElement as unknown as HTMLDivElement ) );
211
211
 
212
212
  await waitFor( () => {
213
213
  expect( result.current[ 1 ] ).toBe( 75 );
@@ -236,7 +236,7 @@ describe( 'useElementSize', () => {
236
236
  const { result } = renderHook( () => useElementSize() );
237
237
  const [ refCallback ] = result.current;
238
238
 
239
- refCallback( mockElement as unknown as HTMLDivElement );
239
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
240
240
 
241
241
  await waitFor( () => {
242
242
  expect( result.current[ 1 ] ).toBe( 100 );
@@ -244,7 +244,7 @@ describe( 'useElementSize', () => {
244
244
  } );
245
245
 
246
246
  // Simulate resize
247
- resizeCallback();
247
+ act( () => resizeCallback() );
248
248
 
249
249
  await waitFor( () => {
250
250
  expect( result.current[ 1 ] ).toBe( 200 );
@@ -269,7 +269,7 @@ describe( 'useElementSize', () => {
269
269
  const { result, unmount } = renderHook( () => useElementSize() );
270
270
  const [ refCallback ] = result.current;
271
271
 
272
- refCallback( mockElement as unknown as HTMLDivElement );
272
+ act( () => refCallback( mockElement as unknown as HTMLDivElement ) );
273
273
  unmount();
274
274
 
275
275
  // The observer should be cleaned up when the hook unmounts
@@ -23,7 +23,7 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
23
23
  // One resolver for all three, so they share a single getComputedStyle call.
24
24
  const resolve = createCssVariableResolver( scopeElement );
25
25
  const resolveColor = ( value?: string ): string | undefined =>
26
- value ? resolve( value ) ?? value : value;
26
+ value ? ( resolve( value ) ?? value ) : value;
27
27
 
28
28
  const seriesColors: string[] = JSON.parse( seriesColorKey );
29
29
 
package/src/index.ts CHANGED
@@ -10,7 +10,9 @@ export {
10
10
  HeatmapChart,
11
11
  HeatmapChartUnresponsive,
12
12
  buildCalendarHeatmapData,
13
+ buildMonthCalendarHeatmapData,
13
14
  useCalendarHeatmapData,
15
+ useMonthCalendarHeatmapData,
14
16
  } from './charts/heatmap-chart';
15
17
  export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
16
18
  export { LineChart, LineChartUnresponsive } from './charts/line-chart';
@@ -95,7 +97,7 @@ export type { LegendProps, BaseLegendProps, ChartLegendOptions } from './compone
95
97
 
96
98
  // Previously available via '@automattic/charts/bar-chart', '@automattic/charts/line-chart', etc.
97
99
  export type { AreaChartProps } from './charts/area-chart';
98
- export type { BarChartProps } from './charts/bar-chart';
100
+ export type { BarChartProps, BandHighlightSelection } from './charts/bar-chart';
99
101
  export type {
100
102
  BarListChartProps,
101
103
  RenderLabelProps,
@@ -112,10 +114,14 @@ export type {
112
114
  export type {
113
115
  HeatmapChartProps,
114
116
  HeatmapColumn,
117
+ HeatmapColumnGroup,
115
118
  HeatmapCell,
116
119
  HeatmapTooltipData,
117
120
  CalendarHeatmapOptions,
118
121
  CalendarHeatmapResult,
122
+ MonthCalendarHeatmapOptions,
123
+ MonthCalendarHeatmapRange,
124
+ MonthCalendarHeatmapResult,
119
125
  } from './charts/heatmap-chart';
120
126
  export type { LeaderboardChartProps } from './charts/leaderboard-chart';
121
127
  export type {
@@ -61,6 +61,7 @@ const defaultTheme: CompleteChartTheme = {
61
61
  heatmapChart: {
62
62
  compactCellGap: 2,
63
63
  compactCellSize: 11,
64
+ groupGap: 24,
64
65
  },
65
66
  };
66
67
 
@@ -42,9 +42,7 @@
42
42
  // The literal is load-bearing: a `fill` reading a fallback-less var() on an
43
43
  // undeclared property drops the declaration and paints black instead.
44
44
  --a8c-charts-color-label-background: transparent;
45
- // The SVG axis and tick labels. Narrower than --a8c-charts-color-label and
46
- // derived from it, so setting the broad role moves every label at once while
47
- // setting this one moves only the axis.
45
+ // See TOKENS.md's "Broad roles and narrow ones" for label inheritance.
48
46
  --a8c-charts-color-label-axis: var(--a8c-charts-color-label);
49
47
  --a8c-charts-color-annotation: var(--wpds-color-foreground-content-neutral);
50
48
  --a8c-charts-color-trend-up: var(--wpds-color-foreground-content-success-weak);
@@ -86,3 +84,13 @@
86
84
  --a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
87
85
  --a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
88
86
  }
87
+
88
+ @media (forced-colors: active) {
89
+
90
+ :where(.a8c-charts-scope) {
91
+ --a8c-charts-color-label-axis: CanvasText;
92
+ --a8c-charts-color-grid: GrayText;
93
+ --a8c-charts-color-axis-x: GrayText;
94
+ --a8c-charts-color-tick-x: GrayText;
95
+ }
96
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { compileString } from 'sass-embedded';
3
4
 
4
5
  const stylesheet = readFileSync( join( __dirname, '..', 'chart-scope.scss' ), 'utf8' );
5
6
  const tokensDoc = readFileSync( join( __dirname, '..', '..', '..', 'TOKENS.md' ), 'utf8' );
@@ -19,7 +20,7 @@ type Entry = {
19
20
  * @return Every declared `--a8c-charts-*` property, keyed by name.
20
21
  */
21
22
  function parseStylesheet(): Map< string, Entry > {
22
- const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.lastIndexOf( '}' ) );
23
+ const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.indexOf( '}' ) );
23
24
  const entries = new Map< string, Entry >();
24
25
 
25
26
  for ( const declaration of body
@@ -100,7 +101,7 @@ function parseTokensDoc(): Map< string, Entry > {
100
101
  const token = /(--wp[\w-]+)/.exec( mapping );
101
102
 
102
103
  entries.set( role, {
103
- reads: unmapped ? null : derives?.[ 1 ] ?? token?.[ 1 ] ?? null,
104
+ reads: unmapped ? null : ( derives?.[ 1 ] ?? token?.[ 1 ] ?? null ),
104
105
  fallback: fallback === '—' ? null : normalize( fallback ),
105
106
  } );
106
107
  }
@@ -112,6 +113,39 @@ const declared = parseStylesheet();
112
113
  const documented = parseTokensDoc();
113
114
 
114
115
  describe( 'chart scope catalog', () => {
116
+ it( 'uses system colors for axis text and visible lines only under forced colors', () => {
117
+ const style = document.createElement( 'style' );
118
+ style.textContent = compileString( stylesheet ).css;
119
+ document.head.appendChild( style );
120
+
121
+ try {
122
+ const mediaRules = Array.from( style.sheet!.cssRules ).filter(
123
+ ( rule ): rule is CSSMediaRule => rule.type === CSSRule.MEDIA_RULE
124
+ );
125
+ expect( mediaRules ).toHaveLength( 1 );
126
+ expect( mediaRules[ 0 ].conditionText ).toBe( '(forced-colors: active)' );
127
+ expect( mediaRules[ 0 ].cssRules ).toHaveLength( 1 );
128
+
129
+ const rule = mediaRules[ 0 ].cssRules[ 0 ] as CSSStyleRule;
130
+ expect( rule.selectorText ).toBe( ':where(.a8c-charts-scope)' );
131
+ expect(
132
+ Object.fromEntries(
133
+ Array.from( rule.style ).map( property => [
134
+ property,
135
+ rule.style.getPropertyValue( property ).trim().toLowerCase(),
136
+ ] )
137
+ )
138
+ ).toEqual( {
139
+ '--a8c-charts-color-label-axis': 'canvastext',
140
+ '--a8c-charts-color-grid': 'graytext',
141
+ '--a8c-charts-color-axis-x': 'graytext',
142
+ '--a8c-charts-color-tick-x': 'graytext',
143
+ } );
144
+ } finally {
145
+ style.remove();
146
+ }
147
+ } );
148
+
115
149
  it( 'documents every declared role, and declares every documented one', () => {
116
150
  expect( [ ...documented.keys() ].sort() ).toEqual( [ ...declared.keys() ].sort() );
117
151
  } );