@automattic/charts 4.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
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": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.8",
66
+ "@automattic/number-formatters": "^1.2.9",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@date-fns/tz": "1.4.1",
69
69
  "@react-spring/web": "10.1.1",
@@ -114,9 +114,9 @@
114
114
  "@wordpress/components": "40.0.0",
115
115
  "@wordpress/element": "8.6.0",
116
116
  "@wordpress/private-apis": "1.54.0",
117
- "babel-jest": "30.4.1",
117
+ "babel-jest": "30.5.1",
118
118
  "identity-obj-proxy": "^3.0.0",
119
- "jest": "30.4.2",
119
+ "jest": "30.5.1",
120
120
  "jest-extended": "7.0.0",
121
121
  "magic-string": "0.30.21",
122
122
  "react": "18.3.1",
@@ -411,6 +411,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
411
411
 
412
412
  return (
413
413
  <div
414
+ ref={ chartRef }
414
415
  role="grid"
415
416
  aria-label={ __( 'Area chart', 'jetpack-charts' ) }
416
417
  tabIndex={ 0 }
@@ -419,7 +420,7 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
419
420
  onBlur={ onChartBlur }
420
421
  >
421
422
  { chartHeight > 0 && (
422
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
423
+ <div className={ plotStyles[ 'xy-plot' ] }>
423
424
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
424
425
  <XYChart
425
426
  theme={ theme }
@@ -52,6 +52,37 @@ describe( 'AreaChart', () => {
52
52
  );
53
53
  };
54
54
 
55
+ test.each( [ [ 'Escape', '{Escape}' ] ] )(
56
+ 'returns focus to the grid after %s',
57
+ async ( _name, keys ) => {
58
+ jest.useFakeTimers();
59
+ try {
60
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
61
+ renderWithProvider();
62
+ const chart = screen.getByRole( 'grid', { name: /area chart/i } );
63
+
64
+ await user.tab();
65
+ expect( chart ).toHaveFocus();
66
+ await user.keyboard( '{ArrowRight}' );
67
+ expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
68
+
69
+ await user.keyboard( keys );
70
+ await act( async () => {
71
+ jest.advanceTimersByTime( 5000 );
72
+ } );
73
+ expect( chart ).toHaveFocus();
74
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
75
+
76
+ await user.keyboard( '{ArrowRight}' );
77
+ const tooltip = screen.getByRole( 'tooltip' );
78
+ expect( tooltip ).toHaveFocus();
79
+ expect( tooltip ).toHaveTextContent( 'Series A:10' );
80
+ } finally {
81
+ jest.useRealTimers();
82
+ }
83
+ }
84
+ );
85
+
55
86
  describe( 'Data Validation', () => {
56
87
  test( 'shows error when data is empty', () => {
57
88
  renderWithProvider( { data: [] } );
@@ -1,5 +1,5 @@
1
- import { createDateFormatter, sanitizeFormatting } from '../../../utils/date-formatting';
2
- import { warnOnce } from '../../../utils/warn-once';
1
+ import { createDateFormatter, sanitizeFormatting } from '../../utils/date-formatting';
2
+ import { warnOnce } from '../../utils/warn-once';
3
3
  import {
4
4
  addCivilDays,
5
5
  civilDate,
@@ -9,39 +9,15 @@ import {
9
9
  pointDayKey,
10
10
  startOfCivilWeek,
11
11
  writtenDayKey,
12
- } from './civil-day';
13
- import type { DataPointDate } from '../../../types';
14
- import type { HeatmapCell, HeatmapColumn } from '../types';
15
- import type { CivilDate, DayKey } from './civil-day';
16
-
17
- export type CalendarHeatmapResult = {
18
- data: HeatmapColumn[];
19
- rowLabels: string[];
20
- };
21
-
22
- export type CalendarHeatmapOptions = {
23
- weekStartsOn?: 0 | 1;
24
- /**
25
- * Mark the days completing the first and last week outside the series' span as
26
- * hidden cells rather than blank ones, giving the calendar ragged edges. Days
27
- * inside the span stay blank even where the series has no entry.
28
- */
29
- hideOutOfRangeDays?: boolean;
30
- /**
31
- * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
32
- * Days inside the grid but outside the series become placeholder cells: painted,
33
- * but reporting nothing, since they were never measured. A start bound opens from
34
- * the beginning of its week, and bounds narrower than the series are ignored.
35
- */
36
- gridSpan?: { start?: string; end?: string };
37
- /** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
38
- locale?: string;
39
- /**
40
- * IANA zone the series' instants are bucketed into days in. Defaults to the
41
- * runtime's zone. A `dateString` carrying no offset is taken as written.
42
- */
43
- timeZone?: string;
44
- };
12
+ } from './private/civil-day';
13
+ import type { CivilDate, DayKey } from './private/civil-day';
14
+ import type {
15
+ CalendarHeatmapOptions,
16
+ CalendarHeatmapResult,
17
+ HeatmapCell,
18
+ HeatmapColumn,
19
+ } from './types';
20
+ import type { DataPointDate } from '../../types';
45
21
 
46
22
  /** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
47
23
  const LABELLED_ROWS = [ 0, 2, 4 ];
@@ -1,5 +1,11 @@
1
1
  export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
2
- export { buildCalendarHeatmapData } from './private';
2
+ export { buildCalendarHeatmapData } from './build-calendar-data';
3
3
  export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
4
- export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
5
- export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';
4
+ export type {
5
+ CalendarHeatmapOptions,
6
+ CalendarHeatmapResult,
7
+ HeatmapChartProps,
8
+ HeatmapColumn,
9
+ HeatmapCell,
10
+ HeatmapTooltipData,
11
+ } from './types';
@@ -1,7 +1,5 @@
1
1
  export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
2
- export { buildCalendarHeatmapData } from './build-calendar-data';
3
2
  export { HeatmapLegend } from './heatmap-legend';
4
3
  export { HeatmapContext } from './heatmap-context';
5
- export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './build-calendar-data';
6
4
  export type { HeatmapLegendProps } from './heatmap-legend';
7
5
  export type { HeatmapContextValue } from './heatmap-context';
@@ -1,4 +1,4 @@
1
- import { buildCalendarHeatmapData } from '../private/build-calendar-data';
1
+ import { buildCalendarHeatmapData } from '../build-calendar-data';
2
2
  import type { DataPointDate } from '../../../types';
3
3
 
4
4
  const series: DataPointDate[] = [
@@ -8,7 +8,7 @@ import { render } from '@testing-library/react';
8
8
  import { GlobalChartsProvider } from '../../../providers';
9
9
  import { useCalendarHeatmapData } from '../use-calendar-heatmap-data';
10
10
  import type { DataPointDate } from '../../../types';
11
- import type { CalendarHeatmapOptions, CalendarHeatmapResult } from '../private';
11
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from '../types';
12
12
 
13
13
  // 2026-08-03 08:00 in Asia/Tokyo, 2026-08-02 16:00 in America/Los_Angeles.
14
14
  const series: DataPointDate[] = [ { date: new Date( '2026-08-02T23:00:00Z' ), value: 7 } ];
@@ -4,7 +4,7 @@
4
4
 
5
5
  // Chatham runs ahead of UTC. A worker behind it renders a UTC-midnight proxy on
6
6
  // its own day, so it cannot tell the grid's UTC arithmetic from local arithmetic.
7
- import { buildCalendarHeatmapData } from '../private/build-calendar-data';
7
+ import { buildCalendarHeatmapData } from '../build-calendar-data';
8
8
  import type { DataPointDate } from '../../../types';
9
9
 
10
10
  const labelsCarryingValues = ( data: ReturnType< typeof buildCalendarHeatmapData >[ 'data' ] ) =>
@@ -82,3 +82,32 @@ export interface HeatmapChartProps
82
82
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
83
83
  children?: ReactNode;
84
84
  }
85
+
86
+ export type CalendarHeatmapResult = {
87
+ data: HeatmapColumn[];
88
+ rowLabels: string[];
89
+ };
90
+
91
+ export type CalendarHeatmapOptions = {
92
+ weekStartsOn?: 0 | 1;
93
+ /**
94
+ * Mark the days completing the first and last week outside the series' span as
95
+ * hidden cells rather than blank ones, giving the calendar ragged edges. Days
96
+ * inside the span stay blank even where the series has no entry.
97
+ */
98
+ hideOutOfRangeDays?: boolean;
99
+ /**
100
+ * Draw the grid over this span (`yyyy-MM-dd` bounds) instead of the series' own.
101
+ * Days inside the grid but outside the series become placeholder cells: painted,
102
+ * but reporting nothing, since they were never measured. A start bound opens from
103
+ * the beginning of its week, and bounds narrower than the series are ignored.
104
+ */
105
+ gridSpan?: { start?: string; end?: string };
106
+ /** BCP-47 tag the labels are written in. Defaults to the runtime's locale. */
107
+ locale?: string;
108
+ /**
109
+ * IANA zone the series' instants are bucketed into days in. Defaults to the
110
+ * runtime's zone. A `dateString` carrying no offset is taken as written.
111
+ */
112
+ timeZone?: string;
113
+ };
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useChartFormatting } from '../../providers';
3
- import { buildCalendarHeatmapData } from './private';
4
- import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
3
+ import { buildCalendarHeatmapData } from './build-calendar-data';
4
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './types';
5
5
  import type { DataPointDate } from '../../types';
6
6
 
7
7
  /**
@@ -562,6 +562,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
562
562
 
563
563
  return (
564
564
  <div
565
+ ref={ chartRef }
565
566
  role="grid"
566
567
  aria-label={ __( 'Line chart', 'jetpack-charts' ) }
567
568
  tabIndex={ 0 }
@@ -570,7 +571,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
570
571
  onBlur={ onChartBlur }
571
572
  >
572
573
  { chartHeight > 0 && (
573
- <div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
574
+ <div className={ plotStyles[ 'xy-plot' ] }>
574
575
  { zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
575
576
  <XYChart
576
577
  theme={ theme }
@@ -204,6 +204,37 @@ describe( 'LineChart', () => {
204
204
  );
205
205
  } );
206
206
 
207
+ test.each( [ [ 'Escape', '{Escape}' ] ] )(
208
+ 'returns focus to the grid after %s',
209
+ async ( _name, keys ) => {
210
+ jest.useFakeTimers();
211
+ try {
212
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
213
+ renderWithTheme();
214
+ const chart = screen.getByRole( 'grid', { name: /line chart/i } );
215
+
216
+ await user.tab();
217
+ expect( chart ).toHaveFocus();
218
+ await user.keyboard( '{ArrowRight}' );
219
+ expect( screen.getByRole( 'tooltip' ) ).toHaveFocus();
220
+
221
+ await user.keyboard( keys );
222
+ await act( async () => {
223
+ jest.advanceTimersByTime( 5000 );
224
+ } );
225
+ expect( chart ).toHaveFocus();
226
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
227
+
228
+ await user.keyboard( '{ArrowRight}' );
229
+ const tooltip = screen.getByRole( 'tooltip' );
230
+ expect( tooltip ).toHaveFocus();
231
+ expect( tooltip ).toHaveTextContent( 'Series A:10' );
232
+ } finally {
233
+ jest.useRealTimers();
234
+ }
235
+ }
236
+ );
237
+
207
238
  describe( 'Data Validation', () => {
208
239
  test( 'handles empty data array', () => {
209
240
  renderWithTheme( { data: [] } );
@@ -227,12 +227,18 @@ export const useKeyboardNavigation = ( {
227
227
  [ preventTooltipScroll, selectedIndex ]
228
228
  );
229
229
 
230
- // On each focus of chart, reset the selectedIndex to 0, if keyboard navigation is not already active
231
- const onChartFocus = useCallback( () => {
232
- if ( ! isNavigating && selectedIndex !== undefined ) {
233
- setSelectedIndex( 0 );
234
- }
235
- }, [ isNavigating, selectedIndex, setSelectedIndex ] );
230
+ // Returning focus from the tooltip must not restore the selection Escape just cleared.
231
+ const onChartFocus = useCallback(
232
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
233
+ if ( event.currentTarget.contains( event.relatedTarget ) ) {
234
+ return;
235
+ }
236
+ if ( ! isNavigating && selectedIndex !== undefined ) {
237
+ setSelectedIndex( 0 );
238
+ }
239
+ },
240
+ [ isNavigating, selectedIndex, setSelectedIndex ]
241
+ );
236
242
 
237
243
  // On each blur of chart, keyboard navigation should restart from first tooltip
238
244
  const onChartBlur = useCallback( () => {
@@ -243,9 +249,17 @@ export const useKeyboardNavigation = ( {
243
249
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
244
250
  if ( totalPoints === 0 ) return;
245
251
 
252
+ const focusChart = () => {
253
+ if ( preventTooltipScroll ) {
254
+ chartRef.current?.focus( { preventScroll: true } );
255
+ } else {
256
+ chartRef.current?.focus();
257
+ }
258
+ };
259
+
246
260
  // Keep focus on the chart if tab is pressed
247
261
  if ( event.key === 'Tab' ) {
248
- chartRef.current?.focus();
262
+ focusChart();
249
263
  setSelectedIndex( undefined );
250
264
  setIsNavigating( false );
251
265
  return;
@@ -254,7 +268,7 @@ export const useKeyboardNavigation = ( {
254
268
  const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
255
269
 
256
270
  if ( currentSelectedIndex + 1 >= totalPoints && [ 'ArrowRight' ].includes( event.key ) ) {
257
- chartRef.current?.focus();
271
+ focusChart();
258
272
  setSelectedIndex( undefined );
259
273
  setIsNavigating( false );
260
274
  return;
@@ -271,12 +285,20 @@ export const useKeyboardNavigation = ( {
271
285
  } else if ( event.key === 'Escape' ) {
272
286
  setSelectedIndex( undefined );
273
287
  setIsNavigating( false );
274
- chartRef.current?.focus();
288
+ focusChart();
275
289
  } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
276
290
  onActivate?.( selectedIndex );
277
291
  }
278
292
  },
279
- [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
293
+ [
294
+ totalPoints,
295
+ selectedIndex,
296
+ setSelectedIndex,
297
+ setIsNavigating,
298
+ chartRef,
299
+ onActivate,
300
+ preventTooltipScroll,
301
+ ]
280
302
  );
281
303
 
282
304
  return {
@@ -86,6 +86,38 @@ describe( 'AccessibleTooltip', () => {
86
86
  }
87
87
  } );
88
88
 
89
+ it.each( [
90
+ [ 'Tab', '{Tab}' ],
91
+ [ 'Escape', '{Escape}' ],
92
+ ] )( 'requests scroll suppression when returning focus after %s', async ( _name, keys ) => {
93
+ const user = userEvent.setup();
94
+ renderChart( undefined, 'below-axis' );
95
+ await openTooltip();
96
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
97
+ try {
98
+ await user.keyboard( keys );
99
+ expect( focus ).toHaveBeenCalledWith( { preventScroll: true } );
100
+ } finally {
101
+ focus.mockRestore();
102
+ }
103
+ } );
104
+
105
+ it.each( [
106
+ [ 'Tab', '{Tab}' ],
107
+ [ 'Escape', '{Escape}' ],
108
+ ] )( 'keeps default focus scrolling when returning focus after %s', async ( _name, keys ) => {
109
+ const user = userEvent.setup();
110
+ renderChart();
111
+ await openTooltip();
112
+ const focus = jest.spyOn( screen.getByRole( 'grid' ), 'focus' );
113
+ try {
114
+ await user.keyboard( keys );
115
+ expect( focus ).toHaveBeenCalledWith();
116
+ } finally {
117
+ focus.mockRestore();
118
+ }
119
+ } );
120
+
89
121
  it( 'falls back to the catalog default when the role is unset', async () => {
90
122
  renderChart();
91
123
 
@@ -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
 
@@ -2,6 +2,7 @@ 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
7
  import type {
7
8
  CrosshairStyle,
@@ -220,7 +221,7 @@ const XyChartTooltipContent = < Datum extends object >( {
220
221
  zIndex,
221
222
  backgroundColor: theme?.backgroundColor ?? 'white',
222
223
  boxShadow: `0 1px 2px ${
223
- theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
224
+ isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
224
225
  }`,
225
226
  ...theme?.htmlLabel,
226
227
  ...style,
@@ -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
  } );