@automattic/charts 4.0.0 → 4.1.1

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 (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +7 -5
  4. package/dist/index.cjs +614 -259
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +30 -0
  7. package/dist/index.d.cts +88 -74
  8. package/dist/index.d.ts +88 -74
  9. package/dist/index.js +615 -261
  10. package/dist/index.js.map +1 -1
  11. package/package.json +5 -4
  12. package/src/charts/area-chart/area-chart.tsx +2 -1
  13. package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
  14. package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  17. package/src/charts/heatmap-chart/index.ts +10 -3
  18. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  19. package/src/charts/heatmap-chart/private/index.ts +0 -2
  20. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  21. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
  22. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  24. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  25. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  26. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  27. package/src/charts/heatmap-chart/types.ts +35 -0
  28. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  29. package/src/charts/leaderboard-chart/types.ts +2 -2
  30. package/src/charts/line-chart/line-chart.tsx +41 -7
  31. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  32. package/src/charts/line-chart/test/line-chart.test.tsx +178 -38
  33. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  34. package/src/charts/line-chart/types.ts +13 -2
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  36. package/src/components/tooltip/accessible-tooltip.tsx +40 -12
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  38. package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  40. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
  41. package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
  42. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  44. package/src/hooks/use-chart-data-transform.ts +10 -3
  45. package/src/hooks/use-chart-margin.tsx +54 -9
  46. package/src/index.ts +4 -0
  47. package/src/styles/chart-scope.scss +11 -3
  48. package/src/styles/test/chart-scope.test.ts +35 -1
  49. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  50. package/src/types.ts +2 -2
  51. package/src/utils/date-formatting.ts +1 -18
  52. package/src/utils/date-parsing.ts +103 -52
  53. package/src/utils/get-edge-tick-widths.ts +33 -0
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  56. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  57. package/src/utils/test/date-parsing.test.ts +16 -1
  58. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  59. package/src/utils/warn-once.ts +23 -0
  60. package/src/visx/types.ts +27 -3
  61. package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
@@ -2,12 +2,38 @@ 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 { isValidHexColor } from '../../utils';
5
6
  import { BoundedTooltip } from './private/bounded-tooltip';
6
- import type { RenderTooltipGlyphProps, XyChartTooltipProps } from '../../visx/types';
7
+ import type {
8
+ CrosshairStyle,
9
+ RenderTooltipGlyphProps,
10
+ XyChartTooltipProps,
11
+ } from '../../visx/types';
7
12
  import type { TooltipContextType } from '@visx/xychart';
8
13
  import type { CSSProperties, ReactNode } from 'react';
9
14
 
10
15
  const CROSSHAIR_STROKE_WIDTH = 1.5;
16
+ const CROSSHAIR_PAINT_PROPERTIES = new Set( [
17
+ 'stroke',
18
+ 'strokeWidth',
19
+ 'strokeOpacity',
20
+ 'strokeDasharray',
21
+ 'strokeLinecap',
22
+ 'opacity',
23
+ ] );
24
+
25
+ const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
26
+ const pickPaint = ( value: object ) =>
27
+ Object.fromEntries(
28
+ Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
29
+ );
30
+
31
+ return {
32
+ ...pickPaint( props ),
33
+ ...( props.className === undefined ? {} : { className: props.className } ),
34
+ ...( props.style === undefined ? {} : { style: pickPaint( props.style ) } ),
35
+ };
36
+ };
11
37
  const DEFAULT_GLYPH_RADIUS = 4;
12
38
  const FALLBACK_COLOR = '#222';
13
39
  // Above the chart's own overlays (the zoom reset button sits at 2). The chart
@@ -83,7 +109,9 @@ const XyChartTooltipContent = < Datum extends object >( {
83
109
  verticalCrosshairStyle,
84
110
  horizontalCrosshairStyle,
85
111
  detectBounds = true,
112
+ tooltipPlacement = 'auto',
86
113
  zIndex = DEFAULT_TOOLTIP_Z_INDEX,
114
+ style,
87
115
  ...rest
88
116
  }: XyChartTooltipContentProps< Datum > ) => {
89
117
  const tooltipProps = Object.fromEntries(
@@ -125,9 +153,12 @@ const XyChartTooltipContent = < Datum extends object >( {
125
153
  const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
126
154
  let { tooltipLeft, tooltipTop } = tooltipContext;
127
155
 
128
- if ( nearestDatum && ( snapTooltipToDatumX || snapTooltipToDatumY ) ) {
156
+ if (
157
+ nearestDatum &&
158
+ ( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
159
+ ) {
129
160
  const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
130
- if ( snapTooltipToDatumX && isValidNumber( left ) ) {
161
+ if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
131
162
  tooltipLeft = left;
132
163
  }
133
164
  if ( snapTooltipToDatumY && isValidNumber( top ) ) {
@@ -183,15 +214,17 @@ const XyChartTooltipContent = < Datum extends object >( {
183
214
  const marginTop = margin?.top ?? 0;
184
215
  const marginLeft = margin?.left ?? 0;
185
216
 
186
- const TooltipComponent = detectBounds ? BoundedTooltip : Tooltip;
217
+ const TooltipComponent =
218
+ detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
187
219
  const boxStyle: CSSProperties = {
188
220
  ...defaultStyles,
189
221
  zIndex,
190
- background: theme?.backgroundColor ?? 'white',
222
+ backgroundColor: theme?.backgroundColor ?? 'white',
191
223
  boxShadow: `0 1px 2px ${
192
- theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
224
+ isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
193
225
  }`,
194
226
  ...theme?.htmlLabel,
227
+ ...style,
195
228
  };
196
229
 
197
230
  return (
@@ -201,26 +234,26 @@ const XyChartTooltipContent = < Datum extends object >( {
201
234
  <line
202
235
  className="visx-crosshair visx-crosshair-vertical"
203
236
  data-testid="xy-chart-tooltip-crosshair-vertical"
237
+ stroke={ crosshairStroke }
238
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
239
+ { ...crosshairPaintProps( verticalCrosshairStyle ) }
204
240
  x1={ tooltipLeft }
205
241
  x2={ tooltipLeft }
206
242
  y1={ marginTop }
207
243
  y2={ marginTop + innerHeight }
208
- stroke={ crosshairStroke }
209
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
210
- { ...verticalCrosshairStyle }
211
244
  />
212
245
  ) }
213
246
  { showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
214
247
  <line
215
248
  className="visx-crosshair visx-crosshair-horizontal"
216
249
  data-testid="xy-chart-tooltip-crosshair-horizontal"
250
+ stroke={ crosshairStroke }
251
+ strokeWidth={ CROSSHAIR_STROKE_WIDTH }
252
+ { ...crosshairPaintProps( horizontalCrosshairStyle ) }
217
253
  x1={ marginLeft }
218
254
  x2={ marginLeft + innerWidth }
219
255
  y1={ tooltipTop }
220
256
  y2={ tooltipTop }
221
- stroke={ crosshairStroke }
222
- strokeWidth={ CROSSHAIR_STROKE_WIDTH }
223
- { ...horizontalCrosshairStyle }
224
257
  />
225
258
  ) }
226
259
  { glyphs }
@@ -229,10 +262,15 @@ const XyChartTooltipContent = < Datum extends object >( {
229
262
  createPortal(
230
263
  <TooltipComponent
231
264
  left={ tooltipLeft }
232
- top={ tooltipTop }
265
+ top={
266
+ tooltipPlacement === 'below-axis'
267
+ ? marginTop + innerHeight + ( margin?.bottom ?? 0 )
268
+ : tooltipTop
269
+ }
233
270
  style={ boxStyle }
234
271
  applyPositionStyle
235
272
  { ...tooltipProps }
273
+ { ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
236
274
  >
237
275
  { tooltipContent }
238
276
  </TooltipComponent>,
@@ -259,7 +297,8 @@ const XyChartTooltipContent = < Datum extends object >( {
259
297
  * isolation keeps the box's `zIndex` from competing with page chrome. The box
260
298
  * flips and clamps to stay inside the nearest ancestor that clips its overflow
261
299
  * (or the viewport), so it may extend past the chart wrapper but is never cut
262
- * off unless that ancestor is smaller than the box itself.
300
+ * off unless that ancestor is smaller than the box itself. Below-axis placement
301
+ * pins it beneath the x-axis and applies horizontal bounds only.
263
302
  *
264
303
  * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
265
304
  * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers';
3
+ import { useChartDataTransform } from '../use-chart-data-transform';
4
+ import type { SeriesData } from '../../types';
5
+
6
+ // One reference, reused across both renders, so only the zone can invalidate the memo.
7
+ const data: SeriesData[] = [
8
+ { label: 'Series A', data: [ { dateString: '2026-08-02', value: 1 } ] },
9
+ ];
10
+
11
+ const Probe = () => {
12
+ const [ series ] = useChartDataTransform( data );
13
+ const point = series.data[ 0 ] as { date?: Date };
14
+
15
+ return <span data-testid="instant">{ point.date?.toISOString() }</span>;
16
+ };
17
+
18
+ describe( 'useChartDataTransform', () => {
19
+ it( 're-dates its points when only the provider time zone changes', () => {
20
+ const { rerender } = render(
21
+ <GlobalChartsProvider timeZone="Asia/Tokyo">
22
+ <Probe />
23
+ </GlobalChartsProvider>
24
+ );
25
+
26
+ expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T15:00:00.000Z' );
27
+
28
+ rerender(
29
+ <GlobalChartsProvider timeZone="Pacific/Auckland">
30
+ <Probe />
31
+ </GlobalChartsProvider>
32
+ );
33
+
34
+ expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T12:00:00.000Z' );
35
+ } );
36
+ } );
@@ -9,6 +9,13 @@ jest.mock( '../../utils/get-longest-tick-width', () => ( {
9
9
  getLongestTickWidth: ( ...args: unknown[] ) => mockGetLongestTickWidth( ...args ),
10
10
  } ) );
11
11
 
12
+ // jsdom has no getComputedTextLength, so the real measurement always returns null.
13
+ const mockGetEdgeTickWidths = jest.fn();
14
+ jest.mock( '../../utils/get-edge-tick-widths', () => ( {
15
+ ...jest.requireActual( '../../utils/get-edge-tick-widths' ),
16
+ getEdgeTickWidths: ( ...args: unknown[] ) => mockGetEdgeTickWidths( ...args ),
17
+ } ) );
18
+
12
19
  describe( 'useChartMargin', () => {
13
20
  const baseTheme = {
14
21
  axisStyles: {
@@ -43,6 +50,8 @@ describe( 'useChartMargin', () => {
43
50
  beforeEach( () => {
44
51
  mockGetLongestTickWidth.mockReset();
45
52
  mockGetLongestTickWidth.mockReturnValue( 40 );
53
+ mockGetEdgeTickWidths.mockReset();
54
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 0 } );
46
55
  } );
47
56
 
48
57
  it( 'calculates left margin for left y axis', () => {
@@ -62,7 +71,7 @@ describe( 'useChartMargin', () => {
62
71
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
63
72
  expect.any( Array ),
64
73
  options.axis.y.tickFormat,
65
- theme.axisStyles.y.left.axisLabel
74
+ { fontSize: '12px' }
66
75
  );
67
76
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
68
77
  expect( result.current.left ).toBe( 51 );
@@ -85,7 +94,7 @@ describe( 'useChartMargin', () => {
85
94
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
86
95
  expect.any( Array ),
87
96
  options.axis.y.tickFormat,
88
- theme.axisStyles.y.right.axisLabel
97
+ { fontSize: '12px' }
89
98
  );
90
99
  // 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
91
100
  expect( result.current.right ).toBe( 51 );
@@ -108,7 +117,7 @@ describe( 'useChartMargin', () => {
108
117
  expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
109
118
  [ 0, 1000 ],
110
119
  options.axis.y.tickFormat,
111
- theme.axisStyles.y.left.axisLabel
120
+ { fontSize: '12px' }
112
121
  );
113
122
  } );
114
123
 
@@ -185,6 +194,167 @@ describe( 'useChartMargin', () => {
185
194
  expect( result.current.bottom ).toBe( 25 );
186
195
  } );
187
196
 
197
+ describe( 'x-axis edge tick labels', () => {
198
+ const tickFormat = ( value: number ) => new Date( value ).toDateString();
199
+ const tickValues = [ 1, 2, 3 ];
200
+ const datedXOptions = ( xOverrides = {} ) => ( {
201
+ ...optionsBase,
202
+ axis: { ...optionsBase.axis, x: { tickValues, tickFormat, ...xOverrides } },
203
+ } );
204
+
205
+ it( 'reserves half of the last label on the right', () => {
206
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 60 } );
207
+
208
+ const { result } = renderHook( () =>
209
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
210
+ );
211
+
212
+ expect( result.current.right ).toBe( 30 );
213
+ } );
214
+
215
+ it( 'keeps the default right margin when the last label fits inside it', () => {
216
+ mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 30 } );
217
+
218
+ const { result } = renderHook( () =>
219
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
220
+ );
221
+
222
+ expect( result.current.right ).toBe( 20 );
223
+ } );
224
+
225
+ it( 'widens the left margin past the y-axis reservation when the first label needs it', () => {
226
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 0 } );
227
+
228
+ const { result } = renderHook( () =>
229
+ useChartMargin( 300, datedXOptions(), data, baseTheme )
230
+ );
231
+
232
+ // 60 for the label's overhanging half, over the 51 the y-axis ticks need.
233
+ expect( result.current.left ).toBe( 60 );
234
+ } );
235
+
236
+ it( 'measures the axis tick values with the x tick label style', () => {
237
+ const theme = {
238
+ ...baseTheme,
239
+ axisStyles: {
240
+ ...baseTheme.axisStyles,
241
+ x: {
242
+ bottom: { tickLabel: { fontSize: 11 }, tickLength: 8 } as unknown as never,
243
+ top: {} as unknown as never,
244
+ },
245
+ },
246
+ } as XYChartTheme;
247
+
248
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
249
+
250
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
251
+ fontSize: '11px',
252
+ } );
253
+ } );
254
+
255
+ it( 'falls back to the raw tick label style when its font size is a relative unit', () => {
256
+ const theme = {
257
+ ...baseTheme,
258
+ axisStyles: {
259
+ ...baseTheme.axisStyles,
260
+ x: {
261
+ bottom: { tickLabel: { fontSize: '0.875rem' }, tickLength: 8 } as unknown as never,
262
+ top: {} as unknown as never,
263
+ },
264
+ },
265
+ } as XYChartTheme;
266
+
267
+ renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
268
+
269
+ expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
270
+ fontSize: '0.875rem',
271
+ } );
272
+ } );
273
+
274
+ it( 'reserves the edge labels on a top x axis too', () => {
275
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
276
+
277
+ const { result } = renderHook( () =>
278
+ useChartMargin( 300, datedXOptions( { orientation: 'top' } ), data, baseTheme )
279
+ );
280
+
281
+ expect( result.current.right ).toBe( 30 );
282
+ expect( result.current.left ).toBe( 60 );
283
+ } );
284
+
285
+ it( 'reserves nothing for a hidden x axis', () => {
286
+ mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
287
+
288
+ const { result } = renderHook( () =>
289
+ useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme )
290
+ );
291
+
292
+ expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled();
293
+ expect( result.current.right ).toBe( 20 );
294
+ expect( result.current.left ).toBe( 51 );
295
+ } );
296
+ } );
297
+
298
+ describe( 'real measurement', () => {
299
+ // Everything else here mocks the measurer out; this block runs it for real,
300
+ // so that a width that never reaches the margin would fail something.
301
+ const actual = jest.requireActual( '../../utils/get-edge-tick-widths' );
302
+ type Measurable = { getComputedTextLength?: () => number };
303
+
304
+ afterEach( () => {
305
+ delete ( window.SVGElement.prototype as Measurable ).getComputedTextLength;
306
+ } );
307
+
308
+ it( 'turns a measured edge label into a reserved margin', () => {
309
+ // jsdom ships no getComputedTextLength, so @visx/text cannot measure at all.
310
+ ( window.SVGElement.prototype as Measurable ).getComputedTextLength = function (
311
+ this: SVGElement
312
+ ) {
313
+ return ( this.textContent ?? '' ).length * 8;
314
+ };
315
+ mockGetEdgeTickWidths.mockImplementation( actual.getEdgeTickWidths );
316
+
317
+ const options = {
318
+ ...optionsBase,
319
+ axis: {
320
+ ...optionsBase.axis,
321
+ x: {
322
+ tickValues: [ 1, 2 ],
323
+ tickFormat: ( _value: number, index: number ) =>
324
+ index === 0 ? 'AA' : 'MEASURED-LAST',
325
+ },
326
+ },
327
+ };
328
+
329
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
330
+
331
+ // 'MEASURED-LAST' is 13 characters, so 104px wide, and half of it is reserved.
332
+ expect( result.current.right ).toBe( 52 );
333
+ } );
334
+ } );
335
+
336
+ describe( 'y axis gutter', () => {
337
+ it( 'measures a caller-pinned domain rather than the data range', () => {
338
+ const options = { ...optionsBase, yScale: { domain: [ 0, 1 ] as [ number, number ] } };
339
+
340
+ renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
341
+
342
+ const ticks = mockGetLongestTickWidth.mock.calls[ 0 ][ 0 ] as number[];
343
+ expect( Math.max( ...ticks ) ).toBeLessThanOrEqual( 1 );
344
+ } );
345
+
346
+ it( 'reserves no gutter for a hidden y axis', () => {
347
+ const options = {
348
+ ...optionsBase,
349
+ axis: { ...optionsBase.axis, y: { ...optionsBase.axis.y, display: false } },
350
+ };
351
+
352
+ const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
353
+
354
+ expect( result.current.left ).toBe( 20 );
355
+ } );
356
+ } );
357
+
188
358
  describe( 'horizontal y ticks', () => {
189
359
  const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
190
360
  ...optionsBase,
@@ -1,4 +1,7 @@
1
1
  import { useMemo } from 'react';
2
+ // Imported from the module rather than the `providers` barrel: that barrel pulls
3
+ // `use-chart-registration`, which imports this file's own barrel back.
4
+ import { useChartFormatting } from '../providers/chart-context/hooks/use-chart-formatting';
2
5
  import { parseAsLocalDate } from '../utils';
3
6
  import type { SeriesData } from '../types';
4
7
 
@@ -7,7 +10,7 @@ import type { SeriesData } from '../types';
7
10
  *
8
11
  * This hook extracts the common data transformation logic used in both line-chart
9
12
  * and bar-chart components. It:
10
- * 1. Parses date strings into Date objects using parseAsLocalDate
13
+ * 1. Parses date strings into Date objects, dated in the provider's time zone
11
14
  * 2. Sorts data points by date when date properties are present
12
15
  * 3. Returns the original data unchanged when no date properties are found
13
16
  *
@@ -15,6 +18,10 @@ import type { SeriesData } from '../types';
15
18
  * @return {SeriesData[]} The transformed and sorted data
16
19
  */
17
20
  export const useChartDataTransform = ( data: SeriesData[] ) => {
21
+ // A naive `dateString` names no zone, so without this it would be read in the
22
+ // viewer's and then relabeled in the host's, and the two never cancel.
23
+ const { timeZone } = useChartFormatting();
24
+
18
25
  return useMemo( () => {
19
26
  // Check if the first data point has date or dateString properties
20
27
  const firstPoint = data?.[ 0 ]?.data?.[ 0 ];
@@ -35,7 +42,7 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
35
42
  if ( 'date' in point && point.date ) {
36
43
  date = point.date;
37
44
  } else if ( 'dateString' in point && point.dateString ) {
38
- date = parseAsLocalDate( point.dateString );
45
+ date = parseAsLocalDate( point.dateString, timeZone );
39
46
  }
40
47
 
41
48
  return {
@@ -48,5 +55,5 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
48
55
  return a.date.getTime() - b.date.getTime();
49
56
  } ),
50
57
  } ) );
51
- }, [ data ] );
58
+ }, [ data, timeZone ] );
52
59
  };
@@ -1,6 +1,6 @@
1
1
  import { createScale, getTicks } from '@visx/scale';
2
2
  import { useMemo } from 'react';
3
- import { getLongestTickWidth, resolveFontSize } from '../utils';
3
+ import { getEdgeTickWidths, getLongestTickWidth, resolveFontSize } from '../utils';
4
4
  import type { BaseChartProps, DataPointDate, SeriesData } from '../types';
5
5
  import type { XYChartTheme } from '@visx/xychart';
6
6
 
@@ -50,6 +50,33 @@ const DEFAULT_TICK_LENGTH = 8;
50
50
  */
51
51
  const DEFAULT_Y_TICK_WIDTH = 40;
52
52
 
53
+ type LabelStyle = { fontSize?: number | string; letterSpacing?: number | string };
54
+
55
+ /**
56
+ * Copy a label style with its lengths spelled out in px.
57
+ *
58
+ * `getStringWidth` applies the style through CSSOM. Blink resolves a bare number
59
+ * on an SVG `<text>`, but that is its own leniency rather than the CSS rule, and
60
+ * `buildChartTheme` hands us bare numbers.
61
+ *
62
+ * @param style - Raw label style from the theme.
63
+ * @return The same style with px-qualified lengths.
64
+ */
65
+ const toMeasurableStyle = < T extends LabelStyle >( style?: T ) => {
66
+ if ( ! style ) {
67
+ return style;
68
+ }
69
+
70
+ const fontSize = resolveFontSize( style.fontSize );
71
+ const { letterSpacing } = style;
72
+
73
+ return {
74
+ ...style,
75
+ ...( fontSize === undefined ? {} : { fontSize: `${ fontSize }px` } ),
76
+ ...( typeof letterSpacing === 'number' ? { letterSpacing: `${ letterSpacing }px` } : {} ),
77
+ };
78
+ };
79
+
53
80
  const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => {
54
81
  const xAxisStyles =
55
82
  orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
@@ -61,7 +88,7 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom
61
88
 
62
89
  const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH;
63
90
 
64
- return { fontSize, tickLength };
91
+ return { fontSize, tickLength, tickLabelStyle: toMeasurableStyle( xAxisStyles?.tickLabel ) };
65
92
  };
66
93
 
67
94
  export const useChartMargin = (
@@ -88,7 +115,9 @@ export const useChartMargin = (
88
115
  const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
89
116
  const yScale = createScale( {
90
117
  ...options.yScale,
91
- domain: [ minY, maxY ],
118
+ // A pinned domain is what the axis actually renders, so measure those
119
+ // ticks; the data's range would size the gutter for narrower labels.
120
+ domain: options.yScale?.domain ?? [ minY, maxY ],
92
121
  range: [ height, 0 ],
93
122
  } );
94
123
 
@@ -111,7 +140,7 @@ export const useChartMargin = (
111
140
  const yTickWidth = getLongestTickWidth(
112
141
  yTicks,
113
142
  options.axis?.y?.tickFormat,
114
- yAxisStyles.axisLabel
143
+ toMeasurableStyle( yAxisStyles.axisLabel )
115
144
  );
116
145
  // visx's default axis theme pushes y-axis tick labels a further 0.25em
117
146
  // away from the axis (dx of -0.25em on the left, 0.25em on the right), so
@@ -125,17 +154,20 @@ export const useChartMargin = (
125
154
  ( yAxisStyles?.tickLength ?? 0 ) +
126
155
  Math.ceil( yTickLabelFontSize * 0.25 );
127
156
 
128
- if ( yAxisOrientation === 'right' ) {
129
- defaultMargin.right = yMarginValue;
130
- } else {
131
- defaultMargin.left = yMarginValue;
157
+ // A hidden y axis reserves nothing; its gutter belongs to the plot area.
158
+ if ( options.axis?.y?.display !== false ) {
159
+ if ( yAxisOrientation === 'right' ) {
160
+ defaultMargin.right = yMarginValue;
161
+ } else {
162
+ defaultMargin.left = yMarginValue;
163
+ }
132
164
  }
133
165
 
134
166
  // Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
135
167
  // This mirrors Y-axis behavior where margin is based on label size and tick length,
136
168
  // but keeps the padding minimal so consumers can control container spacing themselves.
137
169
  const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
138
- const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation );
170
+ const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
139
171
  const computedXMargin = fontSize + tickLength;
140
172
 
141
173
  if ( xOrientation === 'top' ) {
@@ -145,6 +177,19 @@ export const useChartMargin = (
145
177
  defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
146
178
  }
147
179
 
180
+ // An X-axis label is centered on its tick, so the ones at either end of the
181
+ // scale hang half their width outside the plot area and clip at the SVG edge.
182
+ if ( options.axis?.x?.display !== false ) {
183
+ const { first, last } = getEdgeTickWidths(
184
+ options.axis?.x?.tickValues ?? [],
185
+ options.axis?.x?.tickFormat,
186
+ tickLabelStyle
187
+ );
188
+
189
+ defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) );
190
+ defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) );
191
+ }
192
+
148
193
  return defaultMargin;
149
194
  }, [ options, theme, yTicks ] );
150
195
  };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  HeatmapChart,
11
11
  HeatmapChartUnresponsive,
12
12
  buildCalendarHeatmapData,
13
+ useCalendarHeatmapData,
13
14
  } from './charts/heatmap-chart';
14
15
  export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
15
16
  export { LineChart, LineChartUnresponsive } from './charts/line-chart';
@@ -113,6 +114,7 @@ export type {
113
114
  HeatmapColumn,
114
115
  HeatmapCell,
115
116
  HeatmapTooltipData,
117
+ CalendarHeatmapOptions,
116
118
  CalendarHeatmapResult,
117
119
  } from './charts/heatmap-chart';
118
120
  export type { LeaderboardChartProps } from './charts/leaderboard-chart';
@@ -138,3 +140,5 @@ export {
138
140
  export * from './utils/color-utils';
139
141
  export type { MetricValueType } from './utils';
140
142
  export type { BucketInfo } from './types';
143
+
144
+ export type { CrosshairStyle } from './visx/types';
@@ -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
@@ -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
  } );