` with a localised `aria-label`; each visual row is a `role="row"` and each cell a `role="gridcell"`.
+- Each cell has an `aria-label` containing the cell name and value (or "No data"). An `aria-label` is used rather than a native `title` so no duplicate browser tooltip appears. Axis labels are decorative (`aria-hidden`) because the cell label already carries that text.
+- **Keyboard navigation**: Tab focuses the chart; arrow keys navigate between cells; the focused cell receives a visible highlight ring, and (when `withTooltips` is set) its tooltip. Escape clears the selection.
+- Color alone is never the sole indicator of value — every cell exposes its value via its accessible label, and `showValues` can render numeric labels inside cells.
+- **In-cell text contrast**: when values are shown, each cell chooses light or dark text from the luminance of its blended fill rather than the raw value, so the number stays legible across the whole scale and with any primary color.
+- The component does not use animations by default, and no `prefers-reduced-motion` opt-out is required.
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
new file mode 100644
index 000000000000..efe26d5e9538
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
@@ -0,0 +1,100 @@
+import {
+ chartDecorator,
+ sharedChartArgTypes,
+ ChartStoryArgs,
+} from '../../../stories/chart-decorator';
+import {
+ heatmapActivityMatrix,
+ heatmapCalendarSeries,
+ heatmapLargeValueMatrix,
+} from '../../../stories/sample-data';
+import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config';
+import { HeatmapChart } from '../index';
+import { buildCalendarHeatmapData } from '../private';
+import type { Meta, StoryObj } from '@storybook/react';
+
+type StoryArgs = ChartStoryArgs< React.ComponentProps< typeof HeatmapChart > >;
+
+const meta: Meta< StoryArgs > = {
+ title: 'JS Packages/Charts Library/Charts/Heatmap Chart',
+ component: HeatmapChart,
+ parameters: { layout: 'centered' },
+ decorators: [ chartDecorator ],
+ argTypes: {
+ ...sharedChartArgTypes,
+ ...themeArgTypes,
+ compact: { control: 'boolean', table: { category: 'Visual Style' } },
+ showValues: { control: 'boolean', table: { category: 'Visual Style' } },
+ },
+} satisfies Meta< StoryArgs >;
+
+export default meta;
+type Story = StoryObj< StoryArgs >;
+
+export const Default: Story = {
+ args: {
+ ...sharedThemeArgs,
+ data: heatmapActivityMatrix,
+ rowLabels: [ 'Mon', '', 'Wed', '', 'Fri', '', '' ],
+ withTooltips: true,
+ },
+};
+
+export const Compact: Story = {
+ args: { ...Default.args, compact: true, containerHeight: '160px' },
+};
+
+export const LargeValues: Story = {
+ args: {
+ ...Default.args,
+ data: heatmapLargeValueMatrix,
+ },
+};
+
+export const Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = {
+ render: ( { weekStartsOn, ...args } ) => {
+ const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, {
+ weekStartsOn,
+ } );
+ return
;
+ },
+ args: { ...sharedThemeArgs, withTooltips: true, weekStartsOn: 1 },
+ argTypes: {
+ weekStartsOn: {
+ control: { type: 'inline-radio', labels: { 0: 'Sunday', 1: 'Monday' } },
+ options: [ 1, 0 ],
+ table: { category: 'Calendar' },
+ },
+ },
+};
+
+export const WithCompositionLegend: Story = {
+ render: args => (
+
+
+
+ ),
+ args: { ...Default.args },
+};
+
+export const FixedDimensions: Story = {
+ args: {
+ ...Default.args,
+ width: 720,
+ height: 220,
+ },
+};
+
+export const AspectRatio: Story = {
+ args: {
+ ...Default.args,
+ aspectRatio: 0.4,
+ },
+};
+
+export const ErrorStates: Story = {
+ args: {
+ ...Default.args,
+ data: [],
+ },
+};
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts b/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts
new file mode 100644
index 000000000000..cb2788f64bb8
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts
@@ -0,0 +1,88 @@
+import { buildCalendarHeatmapData } from '../private/build-calendar-data';
+import type { DataPointDate } from '../../../types';
+
+const series: DataPointDate[] = [
+ { dateString: '2024-01-01', value: 3 }, // Mon
+ { dateString: '2024-01-03', value: 5 }, // Wed
+ { dateString: '2024-01-15', value: 2 }, // Mon (3rd week)
+];
+
+describe( 'buildCalendarHeatmapData', () => {
+ test( 'returns empty result for empty input', () => {
+ expect( buildCalendarHeatmapData( [] ) ).toEqual( { data: [], rowLabels: [] } );
+ } );
+
+ test( 'groups days into week columns of 7 rows', () => {
+ const { data } = buildCalendarHeatmapData( series );
+ expect( data ).toHaveLength( 3 ); // weeks containing Jan 1, Jan 8, Jan 15
+ data.forEach( column => expect( column.data ).toHaveLength( 7 ) );
+ } );
+
+ test( 'Monday week start places Jan 1 (Mon) in row 0', () => {
+ const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 1 } );
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 3 );
+ expect( data[ 0 ].data[ 2 ].value ).toBe( 5 ); // Wed
+ expect( rowLabels[ 0 ] ).toBe( 'Mon' );
+ expect( rowLabels[ 2 ] ).toBe( 'Wed' );
+ expect( rowLabels[ 4 ] ).toBe( 'Fri' );
+ expect( rowLabels[ 1 ] ).toBe( '' );
+ } );
+
+ test( 'fills missing days with null', () => {
+ const { data } = buildCalendarHeatmapData( series );
+ expect( data[ 0 ].data[ 1 ].value ).toBeNull(); // Tue Jan 2 has no datum
+ } );
+
+ test( 'labels only the first column of each month', () => {
+ const multiMonth: DataPointDate[] = [
+ { dateString: '2024-01-29', value: 1 },
+ { dateString: '2024-02-05', value: 1 },
+ ];
+ const { data } = buildCalendarHeatmapData( multiMonth );
+ expect( data[ 0 ].label ).toBe( 'Jan' );
+ const labels = data.map( c => c.label ).filter( Boolean );
+ expect( labels ).toContain( 'Feb' );
+ } );
+
+ test( 'filters out entries with unparseable or missing dates', () => {
+ const mixed: DataPointDate[] = [
+ { dateString: '2024-01-01', value: 3 },
+ { dateString: 'not-a-date', value: 9 },
+ { date: new Date( NaN ), value: 7 },
+ { value: 1 }, // neither date nor dateString
+ ];
+ const { data } = buildCalendarHeatmapData( mixed );
+ expect( data ).toHaveLength( 1 ); // only Jan 1 survives -> one week column
+ const values = data.flatMap( column => column.data.map( cell => cell.value ) );
+ expect( values ).toContain( 3 );
+ expect( values ).not.toContain( 9 );
+ expect( values ).not.toContain( 7 );
+ } );
+
+ test( 'returns empty result when every entry has an invalid date', () => {
+ const allInvalid: DataPointDate[] = [
+ { dateString: 'nope', value: 1 },
+ { date: new Date( NaN ), value: 2 },
+ ];
+ expect( buildCalendarHeatmapData( allInvalid ) ).toEqual( { data: [], rowLabels: [] } );
+ } );
+
+ test( 'duplicate days keep the last value (no aggregation)', () => {
+ const dupes: DataPointDate[] = [
+ { dateString: '2024-01-01', value: 3 },
+ { dateString: '2024-01-01', value: 8 },
+ ];
+ const { data } = buildCalendarHeatmapData( dupes, { weekStartsOn: 1 } );
+ expect( data[ 0 ].data[ 0 ].value ).toBe( 8 ); // last write wins, not summed to 11
+ } );
+
+ test( 'Sunday week start shifts rows and labels (Sun/Tue/Thu)', () => {
+ // gridStart snaps to Sun Dec 31 2023, so Mon Jan 1 lands in row 1.
+ const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 0 } );
+ expect( data[ 0 ].data[ 1 ].value ).toBe( 3 ); // Mon Jan 1
+ expect( data[ 0 ].data[ 3 ].value ).toBe( 5 ); // Wed Jan 3
+ expect( rowLabels[ 0 ] ).toBe( 'Sun' );
+ expect( rowLabels[ 2 ] ).toBe( 'Tue' );
+ expect( rowLabels[ 4 ] ).toBe( 'Thu' );
+ } );
+} );
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
new file mode 100644
index 000000000000..aea7a16f5ce2
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
@@ -0,0 +1,301 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { GlobalChartsProvider } from '../../../providers';
+import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart';
+import type { HeatmapColumn } from '../types';
+
+const mockRefCallback = jest.fn();
+jest.mock( '../../../hooks/use-element-size', () => ( {
+ useElementSize: () => [ mockRefCallback, 500, 300 ],
+} ) );
+
+const data: HeatmapColumn[] = [
+ { label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] },
+ { label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] },
+];
+
+const renderChart = ( props = {} ) =>
+ render(
+
+
+
+ );
+
+describe( 'HeatmapChart', () => {
+ test( 'renders a grid with an accessible label', () => {
+ renderChart();
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toBeInTheDocument();
+ } );
+
+ test( 'renders one cell per data point', () => {
+ renderChart();
+ // 2 columns x 3 rows = 6 cells
+ expect( screen.getAllByTestId( 'heatmap-cell' ) ).toHaveLength( 6 );
+ } );
+
+ test( 'shows an empty-state message for empty data', () => {
+ renderChart( { data: [] } );
+ expect( screen.getByText( /no data available/i ) ).toBeInTheDocument();
+ } );
+
+ test( 'renders column and row labels', () => {
+ renderChart( { rowLabels: [ 'Mon', '', 'Wed' ] } );
+ expect( screen.getAllByText( 'W1' ).length ).toBeGreaterThan( 0 );
+ expect( screen.getAllByText( 'Mon' ).length ).toBeGreaterThan( 0 );
+ expect( screen.getAllByText( 'Wed' ).length ).toBeGreaterThan( 0 );
+ } );
+
+ test( 'shows in-cell values by default and hides them in compact mode', () => {
+ const { rerender } = renderChart();
+ // value 3 appears in a cell
+ expect( screen.getAllByText( '3' ).length ).toBeGreaterThan( 0 );
+
+ rerender(
+
+
+
+ );
+ expect( screen.queryByText( '3' ) ).not.toBeInTheDocument();
+ } );
+
+ test( 'formats large in-cell values compactly', () => {
+ render(
+
+
+
+ );
+ expect( screen.getByText( /748\.5\s?K/i ) ).toBeInTheDocument();
+ } );
+
+ test( 'gives each cell an accessible name for screen readers', () => {
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ // The gridcell's accessible name is its aria-label (column + row + value).
+ expect( screen.getByRole( 'gridcell', { name: 'W1 Mon: 1' } ) ).toBeInTheDocument();
+ } );
+
+ test( 'shows a tooltip on cell hover when withTooltips is set', async () => {
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const cell = screen.getAllByTestId( 'heatmap-cell' )[ 0 ];
+ await userEvent.setup().hover( cell );
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
+ } );
+
+ test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => {
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ grid.focus();
+ await userEvent.setup().keyboard( '{ArrowDown}' );
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
+ } );
+
+ test( 'hides the keyboard tooltip on Escape', async () => {
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ const user = userEvent.setup();
+ grid.focus();
+ await user.keyboard( '{ArrowDown}' );
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
+ await user.keyboard( '{Escape}' );
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
+ } );
+
+ test( 'renders a composition legend with Less/More labels', () => {
+ render(
+
+
+
+
+
+ );
+ expect( screen.getByText( /less/i ) ).toBeInTheDocument();
+ expect( screen.getByText( /more/i ) ).toBeInTheDocument();
+ } );
+
+ test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ const user = userEvent.setup();
+
+ // First ArrowDown lands on cell (0,0)
+ grid.focus();
+ await user.keyboard( '{ArrowDown}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-0$/ )
+ );
+
+ // Second ArrowDown moves row 0→1 in col 0
+ await user.keyboard( '{ArrowDown}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-1$/ )
+ );
+
+ // ArrowRight moves to next column, same row (col 0→1, row 1)
+ await user.keyboard( '{ArrowRight}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-1-1$/ )
+ );
+ } );
+
+ test( 'ArrowUp and ArrowLeft move focus in the opposite direction', async () => {
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ const user = userEvent.setup();
+
+ // Navigate to col 1, row 2 (bottom-right of a 2-col × 3-row grid)
+ // First ArrowDown lands on (0,0); subsequent presses move from there.
+ grid.focus();
+ await user.keyboard( '{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-1-2$/ )
+ );
+
+ // ArrowUp moves row 2→1 within col 1
+ await user.keyboard( '{ArrowUp}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-1-1$/ )
+ );
+
+ // ArrowLeft moves col 1→0, same row
+ await user.keyboard( '{ArrowLeft}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-1$/ )
+ );
+ } );
+
+ test( 'ArrowLeft at column 0 and ArrowUp at row 0 clamp (no wrap)', async () => {
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ const user = userEvent.setup();
+
+ // Start at col 0, row 0 (first ArrowDown sets index to row 1; press ArrowUp back to row 0)
+ grid.focus();
+ await user.keyboard( '{ArrowDown}{ArrowUp}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-0$/ )
+ );
+
+ // ArrowUp at row 0 stays at row 0
+ await user.keyboard( '{ArrowUp}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-0$/ )
+ );
+
+ // ArrowLeft at col 0 stays at col 0
+ await user.keyboard( '{ArrowLeft}' );
+ expect( grid ).toHaveAttribute(
+ 'aria-activedescendant',
+ expect.stringMatching( /-cell-0-0$/ )
+ );
+ } );
+
+ test( 'Escape clears the selection (aria-activedescendant removed)', async () => {
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ const user = userEvent.setup();
+
+ grid.focus();
+ await user.keyboard( '{ArrowDown}' );
+ expect( grid ).toHaveAttribute( 'aria-activedescendant' );
+
+ await user.keyboard( '{Escape}' );
+ expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
+ } );
+
+ test( 'rows contain gridcell children in the ARIA hierarchy', () => {
+ renderChart();
+ const rows = screen.getAllByRole( 'row' );
+ expect( rows.length ).toBeGreaterThan( 0 );
+ rows.forEach( row => {
+ expect( within( row ).getAllByRole( 'gridcell' ).length ).toBeGreaterThan( 0 );
+ } );
+ } );
+
+ test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => {
+ renderChart();
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ // Non-compact sets no inline gap/radius — the SCSS falls back to the WPDS tokens.
+ expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' );
+ expect( grid.style.getPropertyValue( '--heatmap-cell-radius' ) ).toBe( '' );
+ } );
+
+ test( 'applies the compact gap inline from the theme compactCellGap', () => {
+ render(
+
+
+
+ );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '3px' );
+ } );
+
+ test( 'sizes compact cells to the theme compactCellSize', () => {
+ render(
+
+
+
+ );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ expect( grid.style.getPropertyValue( '--heatmap-cell-size' ) ).toBe( '20px' );
+ // Compact track template is built from the fixed cell size.
+ expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' );
+ } );
+
+ test( 'applies the primaryColor prop as the cell-scale color', () => {
+ renderChart( { primaryColor: '#abcdef' } );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#abcdef' );
+ } );
+
+ test( 'resolves primaryColor from the chart theme', () => {
+ render(
+
+
+
+ );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
+ } );
+
+ test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
+ render(
+
+
+
+ );
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
+ expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
+ } );
+
+ test( 'the unresponsive export pins explicit width and height', () => {
+ render(
+
+
+
+ );
+ const chart = screen.getByTestId( 'heatmap-chart' );
+ expect( chart ).toHaveStyle( { width: '480px', height: '240px' } );
+ } );
+
+ test( 'the responsive export leaves the chart unpinned so it fills its container', () => {
+ render(
+
+
+
+ );
+ const chart = screen.getByTestId( 'heatmap-chart' );
+ expect( chart ).not.toHaveStyle( { width: '500px' } );
+ expect( chart ).not.toHaveStyle( { height: '300px' } );
+ } );
+} );
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts b/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts
new file mode 100644
index 000000000000..f79f7509a601
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts
@@ -0,0 +1,34 @@
+import { getValueExtent, getNormalizedValue } from '../private/use-heatmap-colors';
+import type { HeatmapColumn } from '../types';
+
+const data: HeatmapColumn[] = [
+ { label: 'A', data: [ { value: 0 }, { value: null }, { value: 10 } ] },
+ { label: 'B', data: [ { value: 5 }, { value: 20 }, { value: null } ] },
+];
+
+describe( 'getValueExtent', () => {
+ test( 'returns [min, max] ignoring null/NaN', () => {
+ expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] );
+ } );
+
+ test( 'returns [0, 0] for all-empty data', () => {
+ expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
+ } );
+} );
+
+describe( 'getNormalizedValue', () => {
+ test( 'returns 0 at min and 1 at max', () => {
+ expect( getNormalizedValue( 0, [ 0, 20 ] ) ).toBe( 0 );
+ expect( getNormalizedValue( 20, [ 0, 20 ] ) ).toBe( 1 );
+ } );
+
+ test( 'returns a clamped value for points inside and outside the extent', () => {
+ expect( getNormalizedValue( 10, [ 0, 20 ] ) ).toBe( 0.5 );
+ expect( getNormalizedValue( 30, [ 0, 20 ] ) ).toBe( 1 );
+ expect( getNormalizedValue( -5, [ 0, 20 ] ) ).toBe( 0 );
+ } );
+
+ test( 'returns 1 when min === max', () => {
+ expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
+ } );
+} );
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/types.ts b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts
new file mode 100644
index 000000000000..93d600dc5a56
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts
@@ -0,0 +1,42 @@
+import type { BaseChartProps } from '../../types';
+import type { ReactNode } from 'react';
+
+/** A single heatmap cell. `value: null` marks an empty cell. */
+export type HeatmapCell = {
+ /** Per-cell label used in the tooltip / accessible name. */
+ label?: string;
+ value: number | null;
+};
+
+/** A heatmap column (rendered left→right); its cells render top→bottom. */
+export type HeatmapColumn = {
+ /** x-axis label for this column. Empty/omitted renders blank. */
+ label?: string;
+ data: HeatmapCell[];
+};
+
+export type HeatmapTooltipData = {
+ value: number | null;
+ rowLabel?: string;
+ columnLabel?: string;
+ cellLabel?: string;
+ row: number;
+ column: number;
+};
+
+export interface HeatmapChartProps
+ extends Omit< BaseChartProps< HeatmapColumn[] >, 'showLegend' | 'legend' | 'gridVisibility' > {
+ /** y-axis labels by row index. Empty entries render blank. */
+ rowLabels?: string[];
+ /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
+ compact?: boolean;
+ /** Render the numeric value inside each cell. Default `! compact`. */
+ showValues?: boolean;
+ /**
+ * Color the cell scale interpolates toward at the highest value
+ * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
+ */
+ primaryColor?: string;
+ renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
+ children?: ReactNode;
+}
diff --git a/projects/js-packages/charts/src/charts/index.ts b/projects/js-packages/charts/src/charts/index.ts
index 55082d2cfd22..d646c1801ed7 100644
--- a/projects/js-packages/charts/src/charts/index.ts
+++ b/projects/js-packages/charts/src/charts/index.ts
@@ -1,6 +1,7 @@
export * from './bar-chart';
export * from './bar-list-chart';
export * from './conversion-funnel-chart';
+export * from './heatmap-chart';
export * from './leaderboard-chart';
export * from './line-chart';
export * from './pie-chart';
diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
index fedf762944f7..d8f3dbec039d 100644
--- a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
+++ b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
@@ -72,6 +72,20 @@ describe( 'withResponsive', () => {
const wrapper = screen.getByTestId( 'responsive-wrapper' );
expect( wrapper ).toHaveStyle( { width: '100%', height: 'auto' } );
} );
+
+ test( 'wrapper expresses aspectRatio in CSS and caps at maxWidth', () => {
+ render(
);
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
+ // CSS aspect-ratio is width/height, so 1 / 0.5 = 2.
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } );
+ } );
+
+ test( 'wrapper omits the maxWidth cap when an explicit width is set', () => {
+ render(
);
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } );
+ expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } );
+ } );
} );
describe( 'configuration', () => {
diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
index e0ddc4890664..bf8527c53851 100644
--- a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
+++ b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
@@ -93,6 +93,17 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
const effectiveHeight = measuredHeight || height || 0;
const defaultHeight = hasAspectRatio ? 'auto' : '100%';
+ // Express the aspect ratio in CSS so the container height tracks its width
+ // fluidly, rather than snapping to a debounced measured height. Cap the width
+ // at maxWidth so the CSS-derived height matches the maxWidth-capped content
+ // (the wrapped chart is sized from the capped `measuredWidth`).
+ const aspectRatioStyle =
+ hasAspectRatio && aspectRatio
+ ? {
+ aspectRatio: `${ 1 / aspectRatio }`,
+ maxWidth: width === undefined ? maxWidth : undefined,
+ }
+ : null;
return (
, 'o
style={ {
width: width ?? '100%',
height: height ?? defaultHeight,
+ ...aspectRatioStyle,
} }
>
( {
+ label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '',
+ data: Array.from( { length: 7 }, ( _row, row ) => ( {
+ label: `Col ${ col + 1 }, Row ${ row + 1 }`,
+ value: ( col * 7 + row ) % 5 === 0 ? null : ( ( col + row ) % 5 ) + 1,
+ } ) ),
+ } )
+);
+
+/**
+ * Large-value matrix for the heatmap chart (12 columns × 7 rows)
+ *
+ * Same shape as the activity matrix but with values up to ~1,000,000, to exercise
+ * compact in-cell number formatting (e.g. `748.5K`).
+ * - Category: matrix
+ * - Data points: 84
+ * - Suitable for: HeatmapChart
+ */
+export const heatmapLargeValueMatrix: HeatmapColumn[] = Array.from(
+ { length: 12 },
+ ( _col, col ) => ( {
+ label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '',
+ data: Array.from( { length: 7 }, ( _row, row ) => {
+ const index = col * 7 + row;
+ return {
+ label: `Col ${ col + 1 }, Row ${ row + 1 }`,
+ value:
+ index % 9 === 0 ? null : Math.round( Math.abs( Math.sin( index ) ) * 990_000 ) + 1_000,
+ };
+ } ),
+ } )
+);
+
+/**
+ * Daily activity series for the calendar heatmap (120 days from 2024-01-01)
+ *
+ * Date/value pairs for building a GitHub-style contribution calendar via
+ * `buildCalendarHeatmapData`.
+ * - Category: time-series
+ * - Data points: 120
+ * - Suitable for: HeatmapChart (calendar layout)
+ */
+export const heatmapCalendarSeries: DataPointDate[] = Array.from(
+ { length: 120 },
+ ( _, index ) => ( {
+ date: new Date( 2024, 0, 1 + index ),
+ value: Math.round( Math.abs( Math.sin( index ) ) * 4 ),
+ } )
+);
diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts
index 25e1e38a3c4b..0fc9d03e487b 100644
--- a/projects/js-packages/charts/src/types.ts
+++ b/projects/js-packages/charts/src/types.ts
@@ -413,6 +413,21 @@ export type ChartTheme = {
/** Stroke width for the sparkline line */
strokeWidth?: number;
};
+ /**
+ * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
+ * WPDS tokens in CSS, so only the scale color and the compact sizing live here.
+ */
+ heatmapChart?: {
+ /**
+ * Color the cell scale interpolates toward at the highest value (prop > this >
+ * palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color.
+ */
+ primaryColor?: string;
+ /** Gap in px between cells in compact mode */
+ compactCellGap?: number;
+ /** Fixed square cell size in px for compact mode */
+ compactCellSize?: number;
+ };
};
/**
@@ -440,6 +455,8 @@ export type CompleteChartTheme = Required< ChartTheme > & {
sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
};
+ heatmapChart: Omit< Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >, 'primaryColor' > &
+ Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >;
};
export type AxisOptions = {
diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts
index 5dfdd92b2128..4e75e407ee03 100644
--- a/projects/js-packages/charts/src/utils/color-utils.ts
+++ b/projects/js-packages/charts/src/utils/color-utils.ts
@@ -232,3 +232,39 @@ export const lightenHexColor = ( hex: string, blend: number ): string => {
.toString( 16 )
.padStart( 2, '0' ) }${ newB.toString( 16 ).padStart( 2, '0' ) }`;
};
+
+/**
+ * WCAG relative luminance of a hex color (0 = black, 1 = white).
+ *
+ * @param hex - Hex color string (e.g., '#98C8DF')
+ * @return Relative luminance in the range [0, 1]
+ * @throws {Error} if hex string is malformed
+ */
+export const relativeLuminance = ( hex: string ): number => {
+ validateHexColor( hex );
+
+ const toLinear = ( value: number ): number => {
+ const channel = value / 255;
+ return channel <= 0.03928 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
+ };
+
+ const r = toLinear( parseInt( hex.slice( 1, 3 ), 16 ) );
+ const g = toLinear( parseInt( hex.slice( 3, 5 ), 16 ) );
+ const b = toLinear( parseInt( hex.slice( 5, 7 ), 16 ) );
+
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+/**
+ * Whether light text reads better than dark text on the given background, using the W3C
+ * luminance threshold (0.179) that maximizes contrast against black vs white.
+ *
+ * @param backgroundHex - Hex background color
+ * @return true if light text should be used; false (dark text) for malformed colors
+ */
+export const prefersLightText = ( backgroundHex: string ): boolean => {
+ if ( ! isValidHexColor( backgroundHex ) ) {
+ return false;
+ }
+ return relativeLuminance( backgroundHex ) <= 0.179;
+};
diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts
index 47e06211bc28..31d8511b4fe5 100644
--- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts
+++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts
@@ -8,6 +8,8 @@ import {
parseHslString,
parseRgbString,
normalizeColorToHex,
+ relativeLuminance,
+ prefersLightText,
} from '../color-utils';
// Helper to convert hex to HSL tuple using d3-color
@@ -881,3 +883,34 @@ describe( 'normalizeColorToHex', () => {
} );
} );
} );
+
+describe( 'relativeLuminance', () => {
+ it( 'returns 0 for black and 1 for white', () => {
+ expect( relativeLuminance( '#000000' ) ).toBeCloseTo( 0, 5 );
+ expect( relativeLuminance( '#ffffff' ) ).toBeCloseTo( 1, 5 );
+ } );
+
+ it( 'returns a higher luminance for a lighter color', () => {
+ expect( relativeLuminance( '#98c8df' ) ).toBeGreaterThan( relativeLuminance( '#006dab' ) );
+ } );
+
+ it( 'throws on a malformed hex', () => {
+ expect( () => relativeLuminance( 'not-a-color' ) ).toThrow();
+ } );
+} );
+
+describe( 'prefersLightText', () => {
+ it( 'prefers dark text on light backgrounds', () => {
+ expect( prefersLightText( '#ffffff' ) ).toBe( false );
+ expect( prefersLightText( '#98c8df' ) ).toBe( false );
+ } );
+
+ it( 'prefers light text on dark backgrounds', () => {
+ expect( prefersLightText( '#000000' ) ).toBe( true );
+ expect( prefersLightText( '#006dab' ) ).toBe( true );
+ } );
+
+ it( 'falls back to dark text for malformed colors', () => {
+ expect( prefersLightText( 'var(--token)' ) ).toBe( false );
+ } );
+} );