From 07c59d6a01caed3386749d4690032f70bc1e349d Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:21:58 +1200 Subject: [PATCH 01/17] feat(charts): add heatmapChart theme section Define heatmapChart settings on ChartTheme/CompleteChartTheme plus a default entry. Only values with no other home live here: the scale color (primaryColor, kept for parity with the other HTML charts) and the compact contribution-graph sizing. Cell gap, radius, in-cell value size, the empty-cell color and the selection ring resolve from WPDS tokens in CSS instead, so they track the active design system theme. --- .../src/providers/chart-context/themes.ts | 9 +++++++++ projects/js-packages/charts/src/types.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/projects/js-packages/charts/src/providers/chart-context/themes.ts b/projects/js-packages/charts/src/providers/chart-context/themes.ts index 0bfbd5e760ab..b319abe71ded 100644 --- a/projects/js-packages/charts/src/providers/chart-context/themes.ts +++ b/projects/js-packages/charts/src/providers/chart-context/themes.ts @@ -81,6 +81,15 @@ const defaultTheme: CompleteChartTheme = { margin: { top: 2, right: 2, bottom: 2, left: 2 }, strokeWidth: 1.5, }, + // Cell gap/radius/value-size and the selection ring come from WPDS tokens in CSS. + // `primaryColor` is intentionally unset so it falls back to the palette's `colors[0]`, + // matching how leaderboardChart resolves its primary (the prop and a theme override still + // win). The compact 11px square / 2px gap is the contribution-graph rhythm, which doesn't + // map to a WPDS dimension. Override via GlobalChartsProvider. + heatmapChart: { + compactCellGap: 2, + compactCellSize: 11, + }, }; export { defaultTheme }; diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts index 25e1e38a3c4b..14fff2122523 100644 --- a/projects/js-packages/charts/src/types.ts +++ b/projects/js-packages/charts/src/types.ts @@ -413,6 +413,23 @@ export type ChartTheme = { /** Stroke width for the sparkline line */ strokeWidth?: number; }; + /** + * HeatmapChart specific settings. Cell gap, radius, value font size and the selection + * ring come straight from WPDS tokens in CSS, so the only values here are the scale color + * (for parity with the other HTML charts) and the compact sizing. + */ + heatmapChart?: { + /** + * Color the cell scale interpolates toward at the highest value. Resolved like the + * other HTML charts (prop > this > palette `colors[0]`) and fed to CSS `color-mix` + * as `--heatmap-primary`. 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 +457,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 = { From e1650259f2e372bd906a930dea7e535c6a23491d Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:27:21 +1200 Subject: [PATCH 02/17] feat(charts): add HTML HeatmapChart component Render the heatmap as a CSS Grid of cells shaded with CSS color-mix against the resolved primary color (prop > theme > palette colors[0]), making the scale CSS-first and adaptive to the chart background. Empty cells default to the WPDS track color; compact mode renders fixed squares. Full ARIA grid with 2D arrow-key navigation, and tooltips on hover and keyboard focus. Includes the calendar/contribution-data transform and the color-scale legend, and wires the package exports. The keyboard-tooltip effect reads containerBounds from a ref rather than depending on it: useTooltipInPortal returns a fresh bounds object each render, so depending on it would loop (showTooltip -> render -> new bounds -> effect -> showTooltip). --- .../heatmap-chart/heatmap-chart.module.scss | 106 +++++ .../charts/heatmap-chart/heatmap-chart.tsx | 395 ++++++++++++++++++ .../charts/src/charts/heatmap-chart/index.ts | 4 + .../private/build-calendar-data.ts | 81 ++++ .../heatmap-chart/private/heatmap-legend.tsx | 55 +++ .../src/charts/heatmap-chart/private/index.ts | 5 + .../private/use-heatmap-colors.ts | 47 +++ .../charts/src/charts/heatmap-chart/types.ts | 42 ++ .../js-packages/charts/src/charts/index.ts | 1 + projects/js-packages/charts/src/index.ts | 12 + 10 files changed, 748 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/index.ts create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/types.ts diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss new file mode 100644 index 000000000000..4c42198baeb7 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -0,0 +1,106 @@ +.heatmap-chart { + width: 100%; + font-size: var(--wpds-typography-font-size-sm, 12px); +} + +.heatmap-chart__empty { + padding: var(--wpds-dimension-padding-lg, 16px); + color: var(--wpds-color-fg-content-neutral-weak, #707070); +} + +// A single CSS grid holds the cell matrix and its axis labels: track 1 of +// each axis is the label gutter (`auto`), the rest are the data cells. The +// track template is set inline (CSS `repeat()` won't take a `var()` count); +// sizes, gap and colors stay here. Non-compact cells flex to fill; compact +// cells are fixed squares and the grid shrinks to content. ARIA rows use +// `display: contents` so their cells join this grid. +.heatmap-chart__grid { + // `--heatmap-cell-gap` is only set inline for compact mode's tighter + // rhythm; otherwise the gap is the WPDS extra-small gap token. + display: grid; + gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px)); + width: 100%; + height: 100%; + min-height: 0; + + &:focus-visible { + outline: + var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid + var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9)); + outline-offset: 2px; + border-radius: var(--wpds-border-radius-sm, 2px); + } +} + +.heatmap-chart__grid--compact { + width: max-content; + height: max-content; +} + +.heatmap-chart__row { + display: contents; +} + +.heatmap-chart__col-label, +.heatmap-chart__row-label { + color: var(--wpds-color-fg-content-neutral-weak, #707070); + font-size: var(--wpds-typography-font-size-xs, 11px); + white-space: nowrap; + overflow: visible; +} + +.heatmap-chart__col-label { + align-self: end; + text-align: left; +} + +.heatmap-chart__row-label { + align-self: center; + justify-self: end; + text-align: right; + padding-inline-end: var(--wpds-dimension-padding-xs, 4px); +} + +.heatmap-chart__cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + border-radius: var(--wpds-border-radius-sm, 2px); + // Default is the empty (no-data) color; cells with a value override it below. + background: var(--wpds-color-bg-track-neutral-weak, #f0f0f0); +} + +// CSS-first shading for cells with a value: --intensity (0–1) maps to the +// color-mix percentage, floored at 15% so the lowest value stays visibly +// tinted (distinct from empty). Mixing toward `transparent` lets the tint +// adapt to the chart background. +.heatmap-chart__cell--filled { + background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent); +} + +.heatmap-chart__cell--selected { + outline: + var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid + var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9)); + outline-offset: calc(-1 * var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px))); +} + +.heatmap-chart__cell-value { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); + font-size: var(--wpds-typography-font-size-md, 13px); +} + +// Strong (high-value) cells get a dark fill, so flip the in-cell value +// to light for contrast. +.heatmap-chart__cell--strong .heatmap-chart__cell-value { + color: #fff; +} + +.heatmap-chart__legend-swatch { + width: var(--wpds-dimension-size-3xs, 12px); + height: var(--wpds-dimension-size-3xs, 12px); + border-radius: var(--wpds-border-radius-sm, 2px); + background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent); +} diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx new file mode 100644 index 000000000000..92f39c8d0a98 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -0,0 +1,395 @@ +import { formatNumber } from '@automattic/number-formatters'; +import { useTooltip, useTooltipInPortal } from '@visx/tooltip'; +import { __ } from '@wordpress/i18n'; +import clsx from 'clsx'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + GlobalChartsProvider, + useChartId, + useGlobalChartsContext, + GlobalChartsContext, +} from '../../providers'; +import { attachSubComponents } from '../../utils'; +import { Center } from '../private/center'; +import { useChartChildren } from '../private/chart-composition'; +import { ChartLayout } from '../private/chart-layout'; +import { SingleChartContext } from '../private/single-chart-context'; +import { withResponsive } from '../private/with-responsive'; +import styles from './heatmap-chart.module.scss'; +import { getValueExtent, getNormalizedValue, HeatmapLegend, isPresent } from './private'; +import type { HeatmapChartProps, HeatmapTooltipData } from './types'; +import type { ResponsiveConfig } from '../private/with-responsive'; +import type { CSSProperties, FC } from 'react'; + +export type HeatmapContextValue = { + extent: [ number, number ]; + /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */ + primaryColorHex: string; +}; + +export const HeatmapContext = createContext< HeatmapContextValue | null >( null ); + +const HeatmapChartInternal: FC< HeatmapChartProps > = ( { + data, + chartId: providedChartId, + width = 0, + height = 0, + className, + compact = false, + showValues, + rowLabels = [], + primaryColor, + gap = 'md', + withTooltips = false, + renderTooltip, + children, +} ) => { + const chartId = useChartId( providedChartId ); + const { getElementStyles, theme } = useGlobalChartsContext(); + const { heatmapChart: heatmapChartSettings } = theme; + const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' ); + + const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >(); + const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } = + useTooltip< HeatmapTooltipData >(); + const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( { + detectBounds: true, + scroll: true, + } ); + // `containerBounds` is a fresh object every render, so the keyboard-tooltip effect reads + // it from a ref instead of depending on it — depending on it would loop (showTooltip → + // render → new bounds → effect → showTooltip → …). + const containerBoundsRef = useRef( containerBounds ); + containerBoundsRef.current = containerBounds; + + // Same theme integration as the other HTML charts: prop > theme > palette colors[0]. + // The resolved color feeds CSS `color-mix` via the `--heatmap-primary` custom property. + const { color: primaryColorHex } = getElementStyles( { + index: 0, + overrideColor: primaryColor || heatmapChartSettings.primaryColor, + } ); + + const extent = useMemo( () => getValueExtent( data ), [ data ] ); + const heatmapContext = useMemo< HeatmapContextValue >( + () => ( { extent, primaryColorHex } ), + [ extent, primaryColorHex ] + ); + + const columns = data.length; + const rows = Math.max( 0, ...data.map( column => column.data.length ) ); + + const { compactCellGap, compactCellSize } = heatmapChartSettings; + // Cell gap and radius come from WPDS tokens in CSS. The only inline gap override is the + // compact mode's tighter contribution-graph rhythm (not a WPDS dimension). + const drawValues = showValues ?? ! compact; + + const buildTooltipData = useCallback( + ( columnIndex: number, rowIndex: number ): HeatmapTooltipData => { + const cell = data[ columnIndex ]?.data[ rowIndex ]; + return { + value: cell?.value ?? null, + rowLabel: rowLabels[ rowIndex ], + columnLabel: data[ columnIndex ]?.label, + cellLabel: cell?.label, + row: rowIndex, + column: columnIndex, + }; + }, + [ data, rowLabels ] + ); + + const onChartBlur = useCallback( () => { + setSelectedIndex( undefined ); + hideTooltip(); + }, [ hideTooltip ] ); + + const onChartKeyDown = useCallback( + ( event: React.KeyboardEvent< HTMLDivElement > ) => { + if ( + ! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape', 'Tab' ].includes( + event.key + ) + ) { + return; + } + + if ( event.key === 'Tab' || event.key === 'Escape' ) { + setSelectedIndex( undefined ); + hideTooltip(); + return; + } + + event.preventDefault(); + + if ( selectedIndex === undefined ) { + setSelectedIndex( 0 ); + return; + } + + let col = Math.floor( selectedIndex / rows ); + let row = selectedIndex % rows; + + if ( event.key === 'ArrowRight' ) { + col = Math.min( col + 1, columns - 1 ); + } else if ( event.key === 'ArrowLeft' ) { + col = Math.max( col - 1, 0 ); + } else if ( event.key === 'ArrowDown' ) { + row = Math.min( row + 1, rows - 1 ); + } else if ( event.key === 'ArrowUp' ) { + row = Math.max( row - 1, 0 ); + } + + setSelectedIndex( col * rows + row ); + }, + [ rows, columns, selectedIndex, hideTooltip ] + ); + + const handleCellMouseMove = useCallback( + ( event: React.MouseEvent< HTMLDivElement > ) => { + if ( ! withTooltips ) { + return; + } + const target = event.currentTarget; + const columnIndex = Number( target.dataset.column ); + const rowIndex = Number( target.dataset.row ); + // TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor. + showTooltip( { + tooltipLeft: event.clientX - containerBounds.left, + tooltipTop: event.clientY - containerBounds.top, + tooltipData: buildTooltipData( columnIndex, rowIndex ), + } ); + }, + [ withTooltips, showTooltip, buildTooltipData, containerBounds ] + ); + + const handleCellMouseLeave = useCallback( () => { + // Keyboard selection owns the tooltip; don't let a mouse-out clear it. + if ( withTooltips && selectedIndex === undefined ) { + hideTooltip(); + } + }, [ withTooltips, selectedIndex, hideTooltip ] ); + + // Keyboard navigation drives the tooltip too: anchor it at the selected cell's center + // (mirrors how bar/line charts surface the tooltip on keyboard focus). Cleared on + // blur/Escape rather than here, so a mouse hover (selectedIndex === undefined) is left + // alone. + useEffect( () => { + if ( ! withTooltips || selectedIndex === undefined ) { + return; + } + const col = Math.floor( selectedIndex / rows ); + const row = selectedIndex % rows; + const cell = + typeof document !== 'undefined' + ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` ) + : null; + const rect = cell?.getBoundingClientRect(); + const bounds = containerBoundsRef.current; + showTooltip( { + tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0, + tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0, + tooltipData: buildTooltipData( col, row ), + } ); + }, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] ); + + const defaultRenderTooltip = useCallback( + ( info: HeatmapTooltipData ) => ( +
+ + { info.cellLabel || `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim() } + +
+ { info.value === null ? __( 'No data', 'jetpack-charts' ) : formatNumber( info.value ) } +
+
+ ), + [] + ); + + if ( ! columns || ! rows ) { + return ( +
+ + { __( 'No data available', 'jetpack-charts' ) } + +
+ ); + } + + const trackSize = compact ? 'var(--heatmap-cell-size)' : 'minmax(0, 1fr)'; + const gridStyle: Record< string, string | number > = { + '--heatmap-primary': primaryColorHex, + gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`, + gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`, + }; + if ( compact ) { + gridStyle[ '--heatmap-cell-gap' ] = `${ compactCellGap }px`; + gridStyle[ '--heatmap-cell-size' ] = `${ compactCellSize }px`; + } + + const activeDescendant = + selectedIndex !== undefined + ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }` + : undefined; + + return ( + + + +
+ { /* Header band: corner gutter + column labels. Decorative — each cell's + aria-label already carries the column/row text for screen readers. */ } +
+ { withTooltips && tooltipOpen && tooltipData && ( + +
+ { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) } +
+
+ ) } +
+
+
+ ); +}; + +const HeatmapChartWithProvider: FC< HeatmapChartProps > = props => { + const existingContext = useContext( GlobalChartsContext ); + if ( existingContext ) { + return ; + } + return ( + + + + ); +}; + +HeatmapChartWithProvider.displayName = 'HeatmapChart'; + +interface HeatmapChartSubComponents { + Legend: typeof HeatmapLegend; +} + +const HeatmapChart = attachSubComponents( HeatmapChartWithProvider, { + Legend: HeatmapLegend, +} ) as FC< HeatmapChartProps > & HeatmapChartSubComponents; + +const HeatmapChartResponsive = attachSubComponents( + withResponsive< HeatmapChartProps >( HeatmapChartWithProvider ), + { Legend: HeatmapLegend } +) as FC< HeatmapChartProps & ResponsiveConfig > & HeatmapChartSubComponents; + +export { HeatmapChartResponsive as default, HeatmapChart as HeatmapChartUnresponsive }; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/index.ts b/projects/js-packages/charts/src/charts/heatmap-chart/index.ts new file mode 100644 index 000000000000..dd25facaf1c7 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/index.ts @@ -0,0 +1,4 @@ +export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart'; +export { buildCalendarHeatmapData } from './private'; +export type { CalendarHeatmapResult } from './private'; +export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types'; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts new file mode 100644 index 000000000000..e6045d75e385 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts @@ -0,0 +1,81 @@ +import { addDays, differenceInCalendarWeeks, format, parseISO, startOfWeek } from 'date-fns'; +import type { DataPointDate } from '../../../types'; +import type { HeatmapCell, HeatmapColumn } from '../types'; + +export type CalendarHeatmapResult = { + data: HeatmapColumn[]; + rowLabels: string[]; +}; + +/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */ +const LABELLED_ROWS = [ 0, 2, 4 ]; + +const toDate = ( point: DataPointDate ): Date | null => { + if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) { + return point.date; + } + if ( point.dateString ) { + const parsed = parseISO( point.dateString ); + if ( ! isNaN( parsed.getTime() ) ) { + return parsed; + } + } + return null; +}; + +export const buildCalendarHeatmapData = ( + series: DataPointDate[], + options: { weekStartsOn?: 0 | 1 } = {} +): CalendarHeatmapResult => { + const weekStartsOn = options.weekStartsOn ?? 1; + + const entries = series + .map( point => ( { date: toDate( point ), value: point.value } ) ) + .filter( ( entry ): entry is { date: Date; value: number | null } => entry.date !== null ); + + if ( ! entries.length ) { + return { data: [], rowLabels: [] }; + } + + const valueByDay = new Map< string, number | null >(); + let minDate = entries[ 0 ].date; + let maxDate = entries[ 0 ].date; + for ( const { date, value } of entries ) { + valueByDay.set( format( date, 'yyyy-MM-dd' ), value ); + if ( date < minDate ) { + minDate = date; + } + if ( date > maxDate ) { + maxDate = date; + } + } + + const gridStart = startOfWeek( minDate, { weekStartsOn } ); + const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1; + + const rowLabels = Array.from( { length: 7 }, ( _, row ) => + LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : '' + ); + + const data: HeatmapColumn[] = []; + let previousMonth = -1; + for ( let week = 0; week < weekCount; week++ ) { + const columnStart = addDays( gridStart, week * 7 ); + const month = columnStart.getMonth(); + const label = month !== previousMonth ? format( columnStart, 'MMM' ) : ''; + previousMonth = month; + + const cells: HeatmapCell[] = []; + for ( let row = 0; row < 7; row++ ) { + const day = addDays( gridStart, week * 7 + row ); + const key = format( day, 'yyyy-MM-dd' ); + cells.push( { + label: format( day, 'EEE, MMM d, yyyy' ), + value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null, + } ); + } + data.push( { label, data: cells } ); + } + + return { data, rowLabels }; +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx new file mode 100644 index 000000000000..2b347bf708b1 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx @@ -0,0 +1,55 @@ +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { useContext } from 'react'; +import { useGlobalChartsTheme } from '../../../providers'; +import { HeatmapContext } from '../heatmap-chart'; +import styles from '../heatmap-chart.module.scss'; +import type { CSSProperties, FC } from 'react'; + +export interface HeatmapLegendProps { + /** Number of swatches in the scale. Default 5. */ + steps?: number; + lessLabel?: string; + moreLabel?: string; +} + +export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => { + const context = useContext( HeatmapContext ); + const { legend } = useGlobalChartsTheme(); + if ( ! context ) { + return null; + } + const { primaryColorHex } = context; + const labelStyle = legend.labelStyles; + + return ( + + + { lessLabel ?? __( 'Less', 'jetpack-charts' ) } + + + { Array.from( { length: steps }, ( _, index ) => { + // Swatches share the cell fill rule: --intensity drives the same color-mix + // percentage in CSS, so the legend matches the rendered cells exactly. + const intensity = steps <= 1 ? 1 : index / ( steps - 1 ); + return ( + + + { moreLabel ?? __( 'More', 'jetpack-charts' ) } + + + ); +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts new file mode 100644 index 000000000000..eb956f77588c --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts @@ -0,0 +1,5 @@ +export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors'; +export { buildCalendarHeatmapData } from './build-calendar-data'; +export { HeatmapLegend } from './heatmap-legend'; +export type { CalendarHeatmapResult } from './build-calendar-data'; +export type { HeatmapLegendProps } from './heatmap-legend'; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts new file mode 100644 index 000000000000..0e60567303e5 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts @@ -0,0 +1,47 @@ +import type { HeatmapColumn } from '../types'; + +export const isPresent = ( value: number | null | undefined ): value is number => + value !== null && value !== undefined && ! isNaN( value ); + +/** + * Get the min and max values from heatmap data, ignoring null/NaN. + * @param data - The heatmap columns + * @return Tuple of [min, max] values + */ +export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => { + let min = Infinity; + let max = -Infinity; + for ( const column of data ) { + for ( const cell of column.data ) { + if ( ! isPresent( cell.value ) ) { + continue; + } + if ( cell.value < min ) { + min = cell.value; + } + if ( cell.value > max ) { + max = cell.value; + } + } + } + if ( min === Infinity ) { + return [ 0, 0 ]; + } + return [ min, max ]; +}; + +/** + * Normalize a value to 0–1 within the extent. Drives each cell's `--intensity` custom + * property; CSS turns that into the cell's `color-mix` percentage (the value→shade scale + * lives in CSS, not here). A flat extent (min === max) maps everything to the full color. + * @param value - The value to normalize + * @param extent - Tuple of [min, max] values for the normalization range + * @return Normalized value between 0 and 1 + */ +export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => { + const [ min, max ] = extent; + if ( min === max ) { + return 1; + } + return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) ); +}; 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..c9afaf170166 --- /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. Resolved like the + * other HTML charts (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/index.ts b/projects/js-packages/charts/src/index.ts index 2f097f77165c..2491ce5f1e9a 100644 --- a/projects/js-packages/charts/src/index.ts +++ b/projects/js-packages/charts/src/index.ts @@ -4,6 +4,11 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart'; export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart'; export { ConversionFunnelChart } from './charts/conversion-funnel-chart'; export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart'; +export { + HeatmapChart, + HeatmapChartUnresponsive, + buildCalendarHeatmapData, +} from './charts/heatmap-chart'; export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart'; export { LineChart, LineChartUnresponsive } from './charts/line-chart'; export { PieChart, PieChartUnresponsive } from './charts/pie-chart'; @@ -96,6 +101,13 @@ export type { MainMetricRenderProps, TooltipRenderProps, } from './charts/conversion-funnel-chart'; +export type { + HeatmapChartProps, + HeatmapColumn, + HeatmapCell, + HeatmapTooltipData, + CalendarHeatmapResult, +} from './charts/heatmap-chart'; export type { LeaderboardChartProps } from './charts/leaderboard-chart'; export type { LineChartProps, From a702ba90e1de61a9e5cc2a83a347befef1eeffc9 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:27:43 +1200 Subject: [PATCH 03/17] test(charts): cover HeatmapChart behaviour and helpers Render/empty-state, ARIA grid + 2D keyboard navigation, hover and keyboard tooltips, compact sizing, primaryColor resolution (prop, theme, palette fallback), and the value-extent/normalize and calendar-transform helpers (including invalid-date and duplicate-day edge cases). --- .../test/build-calendar-data.test.ts | 88 ++++++ .../heatmap-chart/test/heatmap-chart.test.tsx | 277 ++++++++++++++++++ .../test/use-heatmap-colors.test.ts | 34 +++ 3 files changed, 399 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts 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..1aa47b28251b --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx @@ -0,0 +1,277 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GlobalChartsProvider } from '../../../providers'; +import HeatmapChart 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(); + } ); + + /* eslint-disable testing-library/no-node-access */ + test( 'gives each cell an accessible name for screen readers (no native title tooltip)', () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + // aria-label (not ) provides the accessible name so no native browser tooltip shows. + expect( grid.querySelectorAll( 'title' ) ).toHaveLength( 0 ); + const labels = Array.from( grid.querySelectorAll( '[role="gridcell"]' ) ).map( c => + c.getAttribute( 'aria-label' ) + ); + expect( labels.some( l => l?.includes( 'W1' ) && l?.includes( 'Mon' ) ) ).toBe( true ); + } ); + + 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(); + } ); + /* eslint-enable testing-library/no-node-access */ + + 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( + <GlobalChartsProvider> + <HeatmapChart width={ 500 } height={ 300 } data={ data }> + <HeatmapChart.Legend /> + </HeatmapChart> + </GlobalChartsProvider> + ); + 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' ); + } ); + + /* eslint-disable testing-library/no-node-access */ + test( 'rows contain gridcell children in the ARIA hierarchy', () => { + renderChart(); + const rows = screen.getAllByRole( 'row' ); + expect( rows.length ).toBeGreaterThan( 0 ); + rows.forEach( row => { + const cells = Array.from( row.querySelectorAll( '[role="gridcell"]' ) ); + expect( cells.length ).toBeGreaterThan( 0 ); + } ); + } ); + /* eslint-enable testing-library/no-node-access */ + + 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( + <GlobalChartsProvider theme={ { heatmapChart: { compactCellGap: 3 } } }> + <HeatmapChart width={ 500 } height={ 300 } data={ data } compact /> + </GlobalChartsProvider> + ); + 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( + <GlobalChartsProvider theme={ { heatmapChart: { compactCellSize: 20 } } }> + <HeatmapChart width={ 500 } height={ 300 } data={ data } compact /> + </GlobalChartsProvider> + ); + 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( + <GlobalChartsProvider theme={ { heatmapChart: { primaryColor: '#0a0b0c' } } }> + <HeatmapChart width={ 500 } height={ 300 } data={ data } /> + </GlobalChartsProvider> + ); + 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( + <GlobalChartsProvider theme={ { colors: [ '#0a0b0c' ] } }> + <HeatmapChart width={ 500 } height={ 300 } data={ data } /> + </GlobalChartsProvider> + ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' ); + } ); +} ); 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 ); + } ); +} ); From f99dcf811e6daf71de335420a57a8d7999bbc62d Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:28:00 +1200 Subject: [PATCH 04/17] docs(charts): add HeatmapChart stories, docs and sample data Stories (default, compact, calendar with weekStartsOn control, fixed dimensions, aspect ratio, composition legend, error state), the docs/API MDX pages, shared sample datasets (activity matrix + calendar series), the charts-library index entry, and the changelog. --- .../changelog/add-charts-218-heatmap-chart | 4 + .../heatmap-chart/stories/index.api.mdx | 129 +++++++++ .../heatmap-chart/stories/index.docs.mdx | 244 ++++++++++++++++++ .../heatmap-chart/stories/index.stories.tsx | 89 +++++++ .../charts/src/stories/index.docs.mdx | 6 +- .../charts/src/stories/sample-data/index.ts | 39 ++- 6 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 projects/js-packages/charts/changelog/add-charts-218-heatmap-chart create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx create mode 100644 projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx diff --git a/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart b/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart new file mode 100644 index 000000000000..a3064d3103be --- /dev/null +++ b/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: add HeatmapChart for matrix and calendar/contribution-style data, with a compact mode and a composition color-scale legend. diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx new file mode 100644 index 000000000000..f8268b01d216 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx @@ -0,0 +1,129 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +<Meta title="JS Packages/Charts Library/Charts/Heatmap Chart/API Reference" /> + +# Heatmap Chart API Reference + +## HeatmapChart + +Main component for rendering a two-dimensional heatmap. Cells are laid out with CSS Grid +and shaded with CSS `color-mix`, so the scale adapts to the chart background. + +**Props:** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `data` | `HeatmapColumn[]` | - | **Required.** Array of columns; each column contains an optional label and an array of cells. | +| `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. | +| `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. | +| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell. | +| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolved like the other HTML charts: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. | +| `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. | +| `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. | +| `width` | `number` | - | Fixed width in pixels. When omitted, the chart fills its parent's width. | +| `height` | `number` | - | Fixed height in pixels. When omitted, the chart fills its parent's height. | +| `aspectRatio` | `number` | - | Height as a ratio of width in responsive mode (e.g. `0.4`). Used when `width`/`height` are omitted. | +| `chartId` | `string` | - | Custom chart identifier used in accessibility attributes. | +| `className` | `string` | - | Additional CSS class for the outermost element. | +| `gap` | `GapSize` | `'md'` | Gap between chart layout regions (chart area, legend, children). Uses WordPress design system tokens. | +| `children` | `ReactNode` | - | Composition children, e.g. `<HeatmapChart.Legend />`. | + +## HeatmapChart.Legend + +Sub-component that renders a sequential gradient scale from the lightest to the fullest color, annotated with the min and max values present in `data`. + +```jsx +<HeatmapChart data={ columns } rowLabels={ rowLabels }> + <HeatmapChart.Legend /> +</HeatmapChart> +``` + +**Props:** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `steps` | `number` | `5` | Number of color swatches in the scale. | +| `lessLabel` | `string` | `'Less'` | Label shown to the left of the swatches. | +| `moreLabel` | `string` | `'More'` | Label shown to the right of the swatches. | + +## HeatmapColumn Type + +A single column in the heatmap matrix (rendered left→right); its cells render top→bottom. + +```typescript +type HeatmapColumn = { + /** x-axis label for this column. Empty or omitted renders blank. */ + label?: string; + /** Ordered array of cells for this column. */ + data: HeatmapCell[]; +}; +``` + +## HeatmapCell Type + +A single cell in the heatmap. + +```typescript +type HeatmapCell = { + /** Per-cell label used in the tooltip / accessible name. */ + label?: string; + /** Numeric value. Use null to mark an empty / missing cell. */ + value: number | null; +}; +``` + +## HeatmapTooltipData Type + +Data passed to `renderTooltip` when a cell is hovered or selected via keyboard. + +```typescript +type HeatmapTooltipData = { + /** Cell value, or null for an empty cell. */ + value: number | null; + /** Row label from `rowLabels` at this cell's row index. */ + rowLabel?: string; + /** Column label from the column's `label` property. */ + columnLabel?: string; + /** Per-cell label from `HeatmapCell.label`. */ + cellLabel?: string; + /** Zero-based row index of the cell. */ + row: number; + /** Zero-based column index of the cell. */ + column: number; +}; +``` + +## buildCalendarHeatmapData + +Utility function that converts a flat time-series into the `HeatmapColumn[]` / `rowLabels` structure used by `HeatmapChart`, producing a calendar/contribution-style layout. + +**Signature:** + +```typescript +function buildCalendarHeatmapData( + series: DataPointDate[], + options?: { weekStartsOn?: 0 | 1 } +): CalendarHeatmapResult +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `series` | `DataPointDate[]` | - | **Required.** Array of data points with a `date` (or `dateString`) and a numeric `value`. | +| `options.weekStartsOn` | `0 \| 1` | `1` | Day the week starts on: `0` = Sunday, `1` = Monday. | + +**Returns:** `CalendarHeatmapResult` + +## CalendarHeatmapResult Type + +Return value of `buildCalendarHeatmapData`. + +```typescript +type CalendarHeatmapResult = { + /** Columns to pass directly to HeatmapChart's `data` prop. */ + data: HeatmapColumn[]; + /** Row labels to pass directly to HeatmapChart's `rowLabels` prop. */ + rowLabels: string[]; +}; +``` diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx new file mode 100644 index 000000000000..3d98cdc94462 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -0,0 +1,244 @@ +import { Meta, Canvas, Source } from '@storybook/addon-docs/blocks'; +import * as HeatmapStories from './index.stories'; + +<Meta title="JS Packages/Charts Library/Charts/Heatmap Chart" of={ HeatmapStories } /> + +# Heatmap Chart + +Visualize values across a two-dimensional matrix of cells on a sequential color scale. Cells transition from a light tint to the full primary color based on relative value, with `null` cells rendered in a neutral empty-cell color. + +<Canvas of={ HeatmapStories.Default } /> + +## Overview + +The Heatmap Chart renders a CSS Grid of colored cells where color intensity maps to cell value. Shading is done with CSS `color-mix` against the resolved primary color, so the scale adapts to the chart background. It is suitable for activity matrices, time-based frequency data, and any dataset with two categorical axes. + +<Source + language="jsx" + code={ `import { HeatmapChart } from '@automattic/charts'; +import '@automattic/charts/style.css'; + +<HeatmapChart + data={ columns } + rowLabels={ [ 'Mon', '', 'Wed', '', 'Fri', '', '' ] } + withTooltips={ true } +/>` } +/> + +## API Reference + +For detailed information about component props, types, and utility functions, see the [Heatmap Chart API Reference](./?path=/docs/js-packages-charts-library-charts-heatmap-chart-api-reference--docs). + +## Basic Usage + +### Default Matrix + +The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, each containing an optional column `label` and a `data` array of `HeatmapCell` objects: + +<Source + language="jsx" + code={ `const columns = [ + { + label: 'Q1', + data: [ + { label: 'Mon', value: 3 }, + { label: 'Tue', value: null }, + { label: 'Wed', value: 5 }, + ], + }, + { + label: '', + data: [ + { label: 'Mon', value: 1 }, + { label: 'Tue', value: 4 }, + { label: 'Wed', value: 2 }, + ], + }, +]; + +<HeatmapChart + data={ columns } + rowLabels={ [ 'Mon', '', 'Wed' ] } + withTooltips={ true } +/>` } +/> + +### Required Props + +- **`data`**: `HeatmapColumn[]` — array of columns, each with optional `label` and a `data` array of cells + +### Optional Props + +- **`rowLabels`**: y-axis labels by row index. Empty entries render blank. +- **`withTooltips`** (default: `false`): show a tooltip on cell hover +- **`compact`** (default: `false`): tighten spacing and suppress in-cell values for compact widgets +- **`showValues`** (default: `!compact`): render the numeric value inside each cell +- **`primaryColor`**: color the scale interpolates toward at the highest value (prop > `heatmapChart.primaryColor` > palette `colors[0]`) +- **`width`** / **`height`**: explicit dimensions in pixels. When omitted the chart fills its parent. +- **`gap`** (default: `'md'`): gap between chart layout regions (chart area, legend, children) +- **`chartId`**: custom identifier for accessibility labelling +- **`className`**: additional CSS class for the outer element +- **`children`**: composition children (e.g. `<HeatmapChart.Legend />`) + +## Compact Mode + +Use `compact` for narrow widgets or dashboards where vertical space is limited. The chart renders fixed-size square cells (the theme's `heatmapChart.compactCellSize`, default 11px), tightens cell gaps, hides in-cell values, and thins axis labels. + +<Canvas of={ HeatmapStories.Compact } /> + +<Source + language="jsx" + code={ `<HeatmapChart + data={ columns } + rowLabels={ rowLabels } + compact={ true } +/>` } +/> + +## Calendar Layout + +Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into the column/row structure expected by the chart. This produces the GitHub-style contribution graph layout. + +<Canvas of={ HeatmapStories.Calendar } /> + +<Source + language="jsx" + code={ `import { HeatmapChart, buildCalendarHeatmapData } from '@automattic/charts'; + +const series = [ + { date: new Date( '2024-01-01' ), value: 3 }, + { date: new Date( '2024-01-02' ), value: 0 }, + // ... +]; + +function CalendarHeatmap() { + const { data, rowLabels } = buildCalendarHeatmapData( series ); + return ( + <HeatmapChart + data={ data } + rowLabels={ rowLabels } + withTooltips={ true } + /> + ); +}` } +/> + +`buildCalendarHeatmapData` accepts an optional `options` object: + +- **`weekStartsOn`** (`0 | 1`, default `1`): `0` = Sunday, `1` = Monday + +## Fixed Dimensions + +Pass `width` and `height` to bypass the responsive wrapper and render at an exact pixel size: + +<Canvas of={ HeatmapStories.FixedDimensions } /> + +<Source + language="jsx" + code={ `<HeatmapChart + data={ columns } + rowLabels={ rowLabels } + width={ 720 } + height={ 220 } +/>` } +/> + +## Composition API — Legend + +Add a legend by placing `<HeatmapChart.Legend />` as a child. The legend renders a gradient scale from the lightest to the fullest shade, labelled with the value range. + +<Canvas of={ HeatmapStories.WithCompositionLegend } /> + +<Source + language="jsx" + code={ `<HeatmapChart data={ columns } rowLabels={ rowLabels } chartId="my-heatmap"> + <HeatmapChart.Legend /> +</HeatmapChart>` } +/> + +## Error / Empty State + +When `data` is empty the chart renders a "No data available" placeholder: + +<Canvas of={ HeatmapStories.ErrorStates } /> + +## Tooltips + +Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard navigation (the arrow-selected cell shows its tooltip, like bar and line charts). The default tooltip shows the cell label and the value, formatted with `@automattic/number-formatters`. Supply `renderTooltip` to customize: + +<Source + language="jsx" + code={ `<HeatmapChart + data={ columns } + rowLabels={ rowLabels } + withTooltips={ true } + renderTooltip={ ( { cellLabel, value, rowLabel, columnLabel } ) => ( + <div> + <strong>{ cellLabel ?? \`\${ columnLabel } \${ rowLabel }\` }</strong> + <div>{ value === null ? 'No data' : value }</div> + </div> + ) } +/>` } +/> + +## Theming + +The heatmap resolves its full-intensity color the same way the other HTML charts do: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values. + +<Source + language="tsx" + code={ `import { GlobalChartsProvider, HeatmapChart, type ChartTheme } from '@automattic/charts'; + +// Per-instance: +<HeatmapChart data={ columns } rowLabels={ rowLabels } primaryColor="#c0392b" /> + +// Or via the theme: +const customTheme: ChartTheme = { + heatmapChart: { primaryColor: '#c0392b' }, +}; + +<GlobalChartsProvider theme={ customTheme }> + <HeatmapChart data={ columns } rowLabels={ rowLabels } /> +</GlobalChartsProvider>` } +/> + +Everything else — the cell gap, corner radius, in-cell value size, the empty-cell color and the selection ring — comes straight from WordPress design system tokens (`--wpds-*`) in CSS, so it tracks the active design system theme automatically. There are no per-instance props for these. The `heatmapChart` theme section exposes just the scale color (`primaryColor`, above) and the compact sizing (`compactCellSize`, `compactCellGap`): + +<Source + language="tsx" + code={ `<GlobalChartsProvider theme={ { heatmapChart: { compactCellSize: 14, compactCellGap: 3 } } }> + <HeatmapChart data={ columns } rowLabels={ rowLabels } compact /> +</GlobalChartsProvider>` } +/> + +## Responsive Behavior + +By default the chart fills its parent container's dimensions. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width: + +<Source + language="jsx" + code={ `// Fill parent container — parent needs explicit height +<div style={{ width: '100%', height: '200px' }}> + <HeatmapChart data={ columns } rowLabels={ rowLabels } /> +</div> + +// Derive height from width via aspectRatio (height = width × 0.4) +<HeatmapChart data={ columns } rowLabels={ rowLabels } aspectRatio={ 0.4 } /> + +// Fixed dimensions +<HeatmapChart data={ columns } rowLabels={ rowLabels } width={ 720 } height={ 200 } />` } +/> + +<Canvas of={ HeatmapStories.AspectRatio } /> + +For more details on responsive behavior, see the [Responsive Design section](./?path=/docs/js-packages-charts-library-introduction--docs#responsive-design) in the introduction. + +## Accessibility + +The heatmap chart is designed for full keyboard and screen reader access: + +- The grid container is a `<div role="grid">` 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. +- 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..1ce3eb1d37da --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx @@ -0,0 +1,89 @@ +import { + chartDecorator, + sharedChartArgTypes, + ChartStoryArgs, +} from '../../../stories/chart-decorator'; +import { heatmapActivityMatrix, heatmapCalendarSeries } 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 Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = { + render: ( { weekStartsOn, ...args } ) => { + const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, { + weekStartsOn, + } ); + return <HeatmapChart { ...args } data={ data } rowLabels={ rowLabels } />; + }, + 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 => ( + <HeatmapChart { ...args } chartId="composition-heatmap"> + <HeatmapChart.Legend /> + </HeatmapChart> + ), + 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/stories/index.docs.mdx b/projects/js-packages/charts/src/stories/index.docs.mdx index ccac4d3111a8..212e3f133157 100644 --- a/projects/js-packages/charts/src/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/stories/index.docs.mdx @@ -8,7 +8,7 @@ A comprehensive charting library for displaying interactive data visualizations ## Features -- ✨ **Rich Chart Types** - Bar charts, bar list charts, conversion funnel charts, donut charts, geo charts, leaderboard visualizations, line charts, pie charts, and sparklines +- ✨ **Rich Chart Types** - Bar charts, bar list charts, conversion funnel charts, donut charts, geo charts, heatmap charts, leaderboard visualizations, line charts, pie charts, and sparklines - 🎨 **Themeable** - Built-in default theme with custom theme support - 📱 **Responsive** - Automatically adapts to container size and viewport changes - ♿ **Accessible** - Full keyboard navigation, screen reader support, and ARIA compliance @@ -90,6 +90,10 @@ A variation of pie charts with a hollow center for additional content display. Visualizes geographical data on an interactive world map, making it easy to understand the distribution of values across countries. +### [Heatmap Chart](./?path=/docs/js-packages-charts-library-charts-heatmap-chart--docs) + +Visualize values across a two-dimensional matrix of cells on a sequential color scale. Supports a compact mode for short widgets and a calendar/contribution-style layout via `buildCalendarHeatmapData`. + ### [Leaderboard Chart](./?path=/docs/js-packages-charts-library-charts-leaderboard-chart--docs) Specialized for ranking data with progress bars and comparison values. diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts index b051cc667c19..4ba1c8e61f83 100644 --- a/projects/js-packages/charts/src/stories/sample-data/index.ts +++ b/projects/js-packages/charts/src/stories/sample-data/index.ts @@ -4,8 +4,9 @@ */ import type { FunnelStep } from '../../charts/conversion-funnel-chart'; +import type { HeatmapColumn } from '../../charts/heatmap-chart'; import type { LeaderboardEntry } from '../../charts/leaderboard-chart'; -import type { DataPointPercentage, GeoData, SeriesData } from '../../types'; +import type { DataPointDate, DataPointPercentage, GeoData, SeriesData } from '../../types'; /** * Olympic medals data for top countries (1896-2020) @@ -1014,3 +1015,39 @@ export const viewsByEuropeanCountry: GeoData = [ [ 'Turkey', 200 ], [ 'Russia', 100 ], ]; + +/** + * Activity matrix for the heatmap chart (12 columns × 7 rows) + * + * Weekday-by-week grid with quarter labels and scattered empty cells. + * - Category: matrix + * - Data points: 84 + * - Suitable for: HeatmapChart + */ +export const heatmapActivityMatrix: HeatmapColumn[] = Array.from( + { length: 12 }, + ( _col, col ) => ( { + 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, + } ) ), + } ) +); + +/** + * 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 ), + } ) +); From 61d674d5bcdb521e559f4e08867e947ad9ff42d0 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:43:33 +1200 Subject: [PATCH 05/17] style(charts): trim redundant comments and doc cross-references Drop comments that restate the code or the package's given conventions (WPDS theming, cross-references to the other HTML charts), keeping only the non-obvious why: the containerBounds-ref loop guard, the repeat()/var() constraint, the 15% color-mix floor, and the aria-activedescendant focus model. Docs keep WPDS theming notes but drop the other-HTML-chart references. --- .../heatmap-chart/heatmap-chart.module.scss | 21 +++++----------- .../charts/heatmap-chart/heatmap-chart.tsx | 25 ++++++------------- .../heatmap-chart/private/heatmap-legend.tsx | 2 -- .../private/use-heatmap-colors.ts | 4 +-- .../heatmap-chart/stories/index.api.mdx | 2 +- .../heatmap-chart/stories/index.docs.mdx | 2 +- .../charts/src/charts/heatmap-chart/types.ts | 4 +-- .../src/providers/chart-context/themes.ts | 7 ++---- projects/js-packages/charts/src/types.ts | 10 +++----- 9 files changed, 25 insertions(+), 52 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss index 4c42198baeb7..53a811676afc 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -8,15 +8,9 @@ color: var(--wpds-color-fg-content-neutral-weak, #707070); } -// A single CSS grid holds the cell matrix and its axis labels: track 1 of -// each axis is the label gutter (`auto`), the rest are the data cells. The -// track template is set inline (CSS `repeat()` won't take a `var()` count); -// sizes, gap and colors stay here. Non-compact cells flex to fill; compact -// cells are fixed squares and the grid shrinks to content. ARIA rows use -// `display: contents` so their cells join this grid. +// Track template is set inline because CSS `repeat()` won't take a `var()` +// count. ARIA rows are `display: contents` so their cells join this grid. .heatmap-chart__grid { - // `--heatmap-cell-gap` is only set inline for compact mode's tighter - // rhythm; otherwise the gap is the WPDS extra-small gap token. display: grid; gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px)); width: 100%; @@ -68,14 +62,12 @@ min-width: 0; min-height: 0; border-radius: var(--wpds-border-radius-sm, 2px); - // Default is the empty (no-data) color; cells with a value override it below. + // Empty (no-data) default; cells with a value override it below. background: var(--wpds-color-bg-track-neutral-weak, #f0f0f0); } -// CSS-first shading for cells with a value: --intensity (0–1) maps to the -// color-mix percentage, floored at 15% so the lowest value stays visibly -// tinted (distinct from empty). Mixing toward `transparent` lets the tint -// adapt to the chart background. +// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from +// an empty cell. .heatmap-chart__cell--filled { background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent); } @@ -92,8 +84,7 @@ font-size: var(--wpds-typography-font-size-md, 13px); } -// Strong (high-value) cells get a dark fill, so flip the in-cell value -// to light for contrast. +// Light text for contrast on the darker high-value fill. .heatmap-chart__cell--strong .heatmap-chart__cell-value { color: #fff; } diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index 92f39c8d0a98..65078d87a782 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -64,14 +64,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { detectBounds: true, scroll: true, } ); - // `containerBounds` is a fresh object every render, so the keyboard-tooltip effect reads - // it from a ref instead of depending on it — depending on it would loop (showTooltip → - // render → new bounds → effect → showTooltip → …). + // Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which + // is a new object each render and would loop the effect via showTooltip. const containerBoundsRef = useRef( containerBounds ); containerBoundsRef.current = containerBounds; - // Same theme integration as the other HTML charts: prop > theme > palette colors[0]. - // The resolved color feeds CSS `color-mix` via the `--heatmap-primary` custom property. const { color: primaryColorHex } = getElementStyles( { index: 0, overrideColor: primaryColor || heatmapChartSettings.primaryColor, @@ -87,8 +84,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { const rows = Math.max( 0, ...data.map( column => column.data.length ) ); const { compactCellGap, compactCellSize } = heatmapChartSettings; - // Cell gap and radius come from WPDS tokens in CSS. The only inline gap override is the - // compact mode's tighter contribution-graph rhythm (not a WPDS dimension). const drawValues = showValues ?? ! compact; const buildTooltipData = useCallback( @@ -177,10 +172,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { } }, [ withTooltips, selectedIndex, hideTooltip ] ); - // Keyboard navigation drives the tooltip too: anchor it at the selected cell's center - // (mirrors how bar/line charts surface the tooltip on keyboard focus). Cleared on - // blur/Escape rather than here, so a mouse hover (selectedIndex === undefined) is left - // alone. + // Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape, + // not here, so a mouse hover (no selection) isn't affected. useEffect( () => { if ( ! withTooltips || selectedIndex === undefined ) { return; @@ -249,7 +242,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { <SingleChartContext.Provider value={ { chartId } }> <ChartLayout legendPosition="bottom" - // HeatmapLegend renders via trailingContent (useChartChildren doesn't classify it as a slot legend). + // Legend renders via trailingContent, not the legend slot. legendChildren={ [] } trailingContent={ nonLegendChildren } gap={ gap } @@ -273,8 +266,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { } ) } style={ gridStyle as CSSProperties } > - { /* Header band: corner gutter + column labels. Decorative — each cell's - aria-label already carries the column/row text for screen readers. */ } + { /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ } <span aria-hidden="true" /> { data.map( ( column, columnIndex ) => ( <span @@ -320,9 +312,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` } data-testid="heatmap-cell" role="gridcell" - // Focus stays on the grid container, which points here via - // aria-activedescendant; cells are programmatically focusable but - // out of the tab sequence. + // Focus stays on the grid (aria-activedescendant); cells are + // focusable but out of the tab order. tabIndex={ -1 } aria-colindex={ columnIndex + 1 } aria-label={ accessibleLabel } diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx index 2b347bf708b1..f634804794e6 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx @@ -29,8 +29,6 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, </Text> <Stack direction="row" gap="xs"> { Array.from( { length: steps }, ( _, index ) => { - // Swatches share the cell fill rule: --intensity drives the same color-mix - // percentage in CSS, so the legend matches the rendered cells exactly. const intensity = steps <= 1 ? 1 : index / ( steps - 1 ); return ( <span diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts index 0e60567303e5..1d30b14d3368 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts @@ -31,9 +31,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => { }; /** - * Normalize a value to 0–1 within the extent. Drives each cell's `--intensity` custom - * property; CSS turns that into the cell's `color-mix` percentage (the value→shade scale - * lives in CSS, not here). A flat extent (min === max) maps everything to the full color. + * Normalize a value to 0–1 within the extent. A flat extent (min === max) maps to 1. * @param value - The value to normalize * @param extent - Tuple of [min, max] values for the normalization range * @return Normalized value between 0 and 1 diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx index f8268b01d216..57160ee1f276 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx @@ -17,7 +17,7 @@ and shaded with CSS `color-mix`, so the scale adapts to the chart background. | `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. | | `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. | | `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell. | -| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolved like the other HTML charts: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. | +| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolution order: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. | | `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. | | `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. | | `width` | `number` | - | Fixed width in pixels. When omitted, the chart fills its parent's width. | diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx index 3d98cdc94462..ebb694c02943 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -183,7 +183,7 @@ Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard na ## Theming -The heatmap resolves its full-intensity color the same way the other HTML charts do: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values. +The heatmap resolves its full-intensity color in this order: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values. <Source language="tsx" diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/types.ts b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts index c9afaf170166..93d600dc5a56 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/types.ts +++ b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts @@ -33,8 +33,8 @@ export interface HeatmapChartProps /** Render the numeric value inside each cell. Default `! compact`. */ showValues?: boolean; /** - * Color the cell scale interpolates toward at the highest value. Resolved like the - * other HTML charts (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`). + * Color the cell scale interpolates toward at the highest value + * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`). */ primaryColor?: string; renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode; diff --git a/projects/js-packages/charts/src/providers/chart-context/themes.ts b/projects/js-packages/charts/src/providers/chart-context/themes.ts index b319abe71ded..1200b3b2d24e 100644 --- a/projects/js-packages/charts/src/providers/chart-context/themes.ts +++ b/projects/js-packages/charts/src/providers/chart-context/themes.ts @@ -81,11 +81,8 @@ const defaultTheme: CompleteChartTheme = { margin: { top: 2, right: 2, bottom: 2, left: 2 }, strokeWidth: 1.5, }, - // Cell gap/radius/value-size and the selection ring come from WPDS tokens in CSS. - // `primaryColor` is intentionally unset so it falls back to the palette's `colors[0]`, - // matching how leaderboardChart resolves its primary (the prop and a theme override still - // win). The compact 11px square / 2px gap is the contribution-graph rhythm, which doesn't - // map to a WPDS dimension. Override via GlobalChartsProvider. + // `primaryColor` is left unset so it falls back to the palette's `colors[0]`. The compact + // 11px square / 2px gap is the contribution-graph rhythm, which has no WPDS dimension. heatmapChart: { compactCellGap: 2, compactCellSize: 11, diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts index 14fff2122523..0fc9d03e487b 100644 --- a/projects/js-packages/charts/src/types.ts +++ b/projects/js-packages/charts/src/types.ts @@ -414,15 +414,13 @@ export type ChartTheme = { strokeWidth?: number; }; /** - * HeatmapChart specific settings. Cell gap, radius, value font size and the selection - * ring come straight from WPDS tokens in CSS, so the only values here are the scale color - * (for parity with the other HTML charts) and the compact sizing. + * 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. Resolved like the - * other HTML charts (prop > this > palette `colors[0]`) and fed to CSS `color-mix` - * as `--heatmap-primary`. Omit to use the palette color. + * 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 */ From 7aff0f0d244650a7fabcb442a7cea7dd721f7afd Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:46:44 +1200 Subject: [PATCH 06/17] test(charts): drop vestigial SVG title assertion The HTML heatmap has no SVG <title> element, so the no-native-title check can't fail; the aria-label assertion in the same test is the real check. --- .../src/charts/heatmap-chart/test/heatmap-chart.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index 1aa47b28251b..79825b210673 100644 --- 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 @@ -59,11 +59,9 @@ describe( 'HeatmapChart', () => { } ); /* eslint-disable testing-library/no-node-access */ - test( 'gives each cell an accessible name for screen readers (no native title tooltip)', () => { + test( 'gives each cell an accessible name for screen readers', () => { renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); - // aria-label (not <title>) provides the accessible name so no native browser tooltip shows. - expect( grid.querySelectorAll( 'title' ) ).toHaveLength( 0 ); const labels = Array.from( grid.querySelectorAll( '[role="gridcell"]' ) ).map( c => c.getAttribute( 'aria-label' ) ); From e4407f0ccc5d82206f1aefefae169cf7541da1fb Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:49:17 +1200 Subject: [PATCH 07/17] test(charts): query by role instead of suppressing no-node-access Use getByRole('gridcell', { name }) for the accessible-name check and within(row).getAllByRole('gridcell') for the ARIA-hierarchy check, so the testing-library/no-node-access rule no longer needs disabling. --- .../heatmap-chart/test/heatmap-chart.test.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 index 79825b210673..8b3053324cc0 100644 --- 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 @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GlobalChartsProvider } from '../../../providers'; import HeatmapChart from '../heatmap-chart'; @@ -58,14 +58,10 @@ describe( 'HeatmapChart', () => { expect( screen.queryByText( '3' ) ).not.toBeInTheDocument(); } ); - /* eslint-disable testing-library/no-node-access */ test( 'gives each cell an accessible name for screen readers', () => { renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); - const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); - const labels = Array.from( grid.querySelectorAll( '[role="gridcell"]' ) ).map( c => - c.getAttribute( 'aria-label' ) - ); - expect( labels.some( l => l?.includes( 'W1' ) && l?.includes( 'Mon' ) ) ).toBe( true ); + // 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 () => { @@ -74,7 +70,6 @@ describe( 'HeatmapChart', () => { await userEvent.setup().hover( cell ); await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument(); } ); - /* eslint-enable testing-library/no-node-access */ test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => { renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); @@ -205,17 +200,14 @@ describe( 'HeatmapChart', () => { expect( grid ).not.toHaveAttribute( 'aria-activedescendant' ); } ); - /* eslint-disable testing-library/no-node-access */ test( 'rows contain gridcell children in the ARIA hierarchy', () => { renderChart(); const rows = screen.getAllByRole( 'row' ); expect( rows.length ).toBeGreaterThan( 0 ); rows.forEach( row => { - const cells = Array.from( row.querySelectorAll( '[role="gridcell"]' ) ); - expect( cells.length ).toBeGreaterThan( 0 ); + expect( within( row ).getAllByRole( 'gridcell' ).length ).toBeGreaterThan( 0 ); } ); } ); - /* eslint-enable testing-library/no-node-access */ test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => { renderChart(); From dc0f351c9718298d596bd426542fbfb450dea87c Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:58:30 +1200 Subject: [PATCH 08/17] feat(charts): compact in-cell formatting for large heatmap values Large values (e.g. 748,500) overflowed the cell when rendered in full. Use formatNumberCompact for the in-cell value (748.5K) so it fits; the tooltip and aria-label keep the full, precise value. Adds a LargeValues story and sample dataset. --- .../charts/heatmap-chart/heatmap-chart.tsx | 5 ++-- .../heatmap-chart/stories/index.api.mdx | 2 +- .../heatmap-chart/stories/index.docs.mdx | 8 ++++++- .../heatmap-chart/stories/index.stories.tsx | 15 +++++++++++- .../heatmap-chart/test/heatmap-chart.test.tsx | 13 ++++++++++ .../charts/src/stories/sample-data/index.ts | 24 +++++++++++++++++++ 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index 65078d87a782..fd6b957c54ce 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -1,4 +1,4 @@ -import { formatNumber } from '@automattic/number-formatters'; +import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; import { useTooltip, useTooltipInPortal } from '@visx/tooltip'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; @@ -333,7 +333,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { > { drawValues && present && ( <span className={ styles[ 'heatmap-chart__cell-value' ] }> - { formatNumber( value ) } + { /* Compact so large values fit the cell; tooltip + aria-label keep full precision. */ } + { formatNumberCompact( value ) } </span> ) } </div> diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx index 57160ee1f276..1d87b37d43c6 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx @@ -16,7 +16,7 @@ and shaded with CSS `color-mix`, so the scale adapts to the chart background. | `data` | `HeatmapColumn[]` | - | **Required.** Array of columns; each column contains an optional label and an array of cells. | | `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. | | `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. | -| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell. | +| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell (compact notation for large numbers; the tooltip and aria-label keep full precision). | | `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolution order: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. | | `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. | | `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. | diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx index ebb694c02943..4f8349e508bb 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -72,7 +72,7 @@ The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, ea - **`rowLabels`**: y-axis labels by row index. Empty entries render blank. - **`withTooltips`** (default: `false`): show a tooltip on cell hover - **`compact`** (default: `false`): tighten spacing and suppress in-cell values for compact widgets -- **`showValues`** (default: `!compact`): render the numeric value inside each cell +- **`showValues`** (default: `!compact`): render the numeric value inside each cell (compact notation for large numbers; the tooltip keeps full precision) - **`primaryColor`**: color the scale interpolates toward at the highest value (prop > `heatmapChart.primaryColor` > palette `colors[0]`) - **`width`** / **`height`**: explicit dimensions in pixels. When omitted the chart fills its parent. - **`gap`** (default: `'md'`): gap between chart layout regions (chart area, legend, children) @@ -95,6 +95,12 @@ Use `compact` for narrow widgets or dashboards where vertical space is limited. />` } /> +## Large Values + +In-cell values use compact notation (e.g. `748.5K`, `1.2M`) so large numbers fit the cell. The tooltip and each cell's accessible label keep the full, precise value. + +<Canvas of={ HeatmapStories.LargeValues } /> + ## Calendar Layout Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into the column/row structure expected by the chart. This produces the GitHub-style contribution graph layout. 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 index 1ce3eb1d37da..e1f4f9414698 100644 --- 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 @@ -3,7 +3,11 @@ import { sharedChartArgTypes, ChartStoryArgs, } from '../../../stories/chart-decorator'; -import { heatmapActivityMatrix, heatmapCalendarSeries } from '../../../stories/sample-data'; +import { + heatmapActivityMatrix, + heatmapCalendarSeries, + heatmapLargeValueMatrix, +} from '../../../stories/sample-data'; import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config'; import { HeatmapChart } from '../index'; import { buildCalendarHeatmapData } from '../private'; @@ -40,6 +44,15 @@ export const Compact: Story = { args: { ...Default.args, compact: true, containerHeight: '160px' }, }; +export const LargeValues: Story = { + args: { + ...Default.args, + data: heatmapLargeValueMatrix, + containerWidth: '900px', + containerHeight: '320px', + }, +}; + export const Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = { render: ( { weekStartsOn, ...args } ) => { const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, { 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 index 8b3053324cc0..0ab22a71127a 100644 --- 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 @@ -58,6 +58,19 @@ describe( 'HeatmapChart', () => { expect( screen.queryByText( '3' ) ).not.toBeInTheDocument(); } ); + test( 'formats large in-cell values compactly', () => { + render( + <GlobalChartsProvider> + <HeatmapChart + width={ 500 } + height={ 300 } + data={ [ { label: 'W1', data: [ { value: 748500 } ] } ] } + /> + </GlobalChartsProvider> + ); + 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). diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts index 4ba1c8e61f83..ce6223dbcdce 100644 --- a/projects/js-packages/charts/src/stories/sample-data/index.ts +++ b/projects/js-packages/charts/src/stories/sample-data/index.ts @@ -1035,6 +1035,30 @@ export const heatmapActivityMatrix: HeatmapColumn[] = Array.from( } ) ); +/** + * 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) * From d5be04bbf85025b1c4b7b79b841df772185ef9da Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:10:48 +1200 Subject: [PATCH 09/17] fix(charts): pick in-cell text color by fill luminance for WCAG contrast The in-cell value used white text whenever the data value was above the midpoint, but with a light palette (e.g. colors[0] #98C8DF) even the fullest cell is light, so white numbers failed contrast. Decide the text color from the cell's actual blended-fill luminance instead: light text only where it out-contrasts dark text (W3C 0.179 threshold). Adds reusable relativeLuminance and prefersLightText to color-utils (composed with the existing lightenHexColor for the over-white blend) rather than bespoke color math in the chart. --- .../heatmap-chart/heatmap-chart.module.scss | 3 +- .../charts/heatmap-chart/heatmap-chart.tsx | 23 +++++++++++- .../heatmap-chart/stories/index.stories.tsx | 2 -- .../charts/src/utils/color-utils.ts | 36 +++++++++++++++++++ .../charts/src/utils/test/color-utils.test.ts | 33 +++++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss index 53a811676afc..82a593844f24 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -84,7 +84,8 @@ font-size: var(--wpds-typography-font-size-md, 13px); } -// Light text for contrast on the darker high-value fill. +// Light text on fills dark enough to out-contrast dark text (decided in JS from +// the blended fill luminance). .heatmap-chart__cell--strong .heatmap-chart__cell-value { color: #fff; } diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index fd6b957c54ce..b5045be4a657 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -18,6 +18,12 @@ import { GlobalChartsContext, } from '../../providers'; import { attachSubComponents } from '../../utils'; +import { + isValidHexColor, + lightenHexColor, + normalizeColorToHex, + prefersLightText, +} from '../../utils/color-utils'; import { Center } from '../private/center'; import { useChartChildren } from '../private/chart-composition'; import { ChartLayout } from '../private/chart-layout'; @@ -37,6 +43,10 @@ export type HeatmapContextValue = { export const HeatmapContext = createContext< HeatmapContextValue | null >( null ); +// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled): +// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity. +const CELL_MIX_FLOOR = 0.15; + const HeatmapChartInternal: FC< HeatmapChartProps > = ( { data, chartId: providedChartId, @@ -74,6 +84,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { overrideColor: primaryColor || heatmapChartSettings.primaryColor, } ); + // Pick the in-cell text color from the cell's actual blended fill luminance (not the data + // value), so light text is only used where it out-contrasts dark text. Falls back to dark + // text when the primary isn't a resolvable hex (e.g. a bare CSS token). + const primaryHex = normalizeColorToHex( primaryColorHex ); + const cellHasLightText = ( intensity: number ): boolean => + isValidHexColor( primaryHex ) && + prefersLightText( + lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) ) + ); + const extent = useMemo( () => getValueExtent( data ), [ data ] ); const heatmapContext = useMemo< HeatmapContextValue >( () => ( { extent, primaryColorHex } ), @@ -321,7 +341,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { data-row={ rowIndex } className={ clsx( styles[ 'heatmap-chart__cell' ], { [ styles[ 'heatmap-chart__cell--filled' ] ]: present, - [ styles[ 'heatmap-chart__cell--strong' ] ]: present && normalized > 0.5, + [ styles[ 'heatmap-chart__cell--strong' ] ]: + present && cellHasLightText( normalized ), [ styles[ 'heatmap-chart__cell--selected' ] ]: selectedIndex === flatIndex, } ) } 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 index e1f4f9414698..efe26d5e9538 100644 --- 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 @@ -48,8 +48,6 @@ export const LargeValues: Story = { args: { ...Default.args, data: heatmapLargeValueMatrix, - containerWidth: '900px', - containerHeight: '320px', }, }; 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 ); + } ); +} ); From 8dc8968f4bb84a97f94066fd94cd215e016fe531 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:15:48 +1200 Subject: [PATCH 10/17] style(charts): use WPDS token for light in-cell text Replace the hardcoded #fff with --wpds-color-fg-interactive-neutral-strong, the WPDS light foreground for content on a strong/colored surface (content tokens have no strong/inverse variant). Pairs with the neutral dark default. --- .../charts/src/charts/heatmap-chart/heatmap-chart.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss index 82a593844f24..5cc87dc355de 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -87,7 +87,7 @@ // Light text on fills dark enough to out-contrast dark text (decided in JS from // the blended fill luminance). .heatmap-chart__cell--strong .heatmap-chart__cell-value { - color: #fff; + color: var(--wpds-color-fg-interactive-neutral-strong, #f0f0f0); } .heatmap-chart__legend-swatch { From 5862d7b07edc1c26283f47196c4cc5aa2f8be419 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:48:08 +1200 Subject: [PATCH 11/17] fix(charts): reflow heatmap fluidly via CSS sizing The heatmap pinned the responsive wrapper's debounced, measured pixel width and height onto its container, so on resize the grid snapped to stale dimensions 300ms behind the cursor instead of reflowing with its parent. CSS Grid can do the reflow for free, but the inline pixel sizes were overriding it. Size the chart purely in CSS instead, matching the conversion-funnel chart: width/height flow at 100%, the grid uses flex to fill remaining space, and a min-height floor keeps it usable when the parent has no explicit height. Aspect-ratio mode now expresses the ratio as a CSS aspect-ratio on the responsive wrapper so its height tracks width fluidly rather than snapping to a debounced measurement; this also makes aspect-ratio sizing fluid for the other charts that share the wrapper. Both axes now reflow continuously in the same layout pass, with no JavaScript in the sizing path. --- .../src/charts/heatmap-chart/heatmap-chart.module.scss | 9 +++++++-- .../charts/src/charts/heatmap-chart/heatmap-chart.tsx | 4 ---- .../src/charts/heatmap-chart/stories/index.docs.mdx | 2 +- .../charts/private/with-responsive/with-responsive.tsx | 5 +++++ 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss index 5cc87dc355de..b9dd58c73756 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -1,5 +1,7 @@ .heatmap-chart { width: 100%; + height: 100%; + min-height: 0; font-size: var(--wpds-typography-font-size-sm, 12px); } @@ -13,9 +15,10 @@ .heatmap-chart__grid { display: grid; gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px)); + flex: 1 1 0; width: 100%; - height: 100%; - min-height: 0; + // Floor so the grid stays usable when the parent has no explicit height. + min-height: 200px; &:focus-visible { outline: @@ -27,8 +30,10 @@ } .heatmap-chart__grid--compact { + flex: 0 0 auto; width: max-content; height: max-content; + min-height: 0; } .heatmap-chart__row { diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index b5045be4a657..caf2ea55ab66 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -50,8 +50,6 @@ const CELL_MIX_FLOOR = 0.15; const HeatmapChartInternal: FC< HeatmapChartProps > = ( { data, chartId: providedChartId, - width = 0, - height = 0, className, compact = false, showValues, @@ -231,7 +229,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { return ( <Center className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) } - style={ { width: width || undefined, height: height || undefined } } data-testid="heatmap-chart" > <span className={ styles[ 'heatmap-chart__empty' ] }> @@ -267,7 +264,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { trailingContent={ nonLegendChildren } gap={ gap } className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) } - style={ { width: width || undefined, height: height || undefined } } data-testid="heatmap-chart" data-chart-id={ `heatmap-chart-${ chartId }` } > diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx index 4f8349e508bb..d7923e6a1c8a 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -219,7 +219,7 @@ Everything else — the cell gap, corner radius, in-cell value size, the empty-c ## Responsive Behavior -By default the chart fills its parent container's dimensions. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width: +By default the chart fills its parent container's dimensions, reflowing fluidly as the container resizes. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width. When neither is provided, the chart falls back to a minimum height so it stays usable rather than collapsing: <Source language="jsx" 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..84e67c84b301 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,10 @@ 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. + const aspectRatioStyle = + hasAspectRatio && aspectRatio ? { aspectRatio: `${ 1 / aspectRatio }` } : null; return ( <div @@ -102,6 +106,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o style={ { width: width ?? '100%', height: height ?? defaultHeight, + ...aspectRatioStyle, } } > <WrappedComponent From afe45b26329ab21a81fb7ecf1bad2e2f5a2ef7ca Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:55:26 +1200 Subject: [PATCH 12/17] docs(charts): note heatmap in-cell text contrast handling The Accessibility section listed value labels and color cues but omitted that in-cell text picks light or dark from the blended fill luminance, which is what keeps numbers legible across the scale and under any primary color. Document it alongside the other a11y guarantees. --- .../charts/src/charts/heatmap-chart/stories/index.docs.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx index d7923e6a1c8a..78d1b8294a3e 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -247,4 +247,5 @@ The heatmap chart is designed for full keyboard and screen reader access: - 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. From 407ce32d6da06e8c9c1dfefcf3e02f7165147a7b Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:34:28 +1200 Subject: [PATCH 13/17] fix(charts): cap aspect-ratio wrapper at maxWidth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS aspect-ratio was applied to a wrapper that stays width: 100%, while the wrapped chart is sized from the maxWidth-capped measured width. In a container wider than maxWidth (default 1200) with an aspectRatio set, the wrapper reserved height for the full container width while the chart only filled the capped width — e.g. a 2000px parent with aspectRatio 0.4 gave a 2000x800 wrapper around 1200-wide content, leaving a block of empty space below the chart. Cap the wrapper's width at maxWidth in the aspect-ratio branch so the CSS-derived height matches the capped content. Scoped to that branch, so non-aspect-ratio charts keep their existing full-width wrapper. Reported by a Codex review of the HeatmapChart PR. --- .../private/with-responsive/with-responsive.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 84e67c84b301..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 @@ -94,9 +94,16 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o 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. + // 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 }` } : null; + hasAspectRatio && aspectRatio + ? { + aspectRatio: `${ 1 / aspectRatio }`, + maxWidth: width === undefined ? maxWidth : undefined, + } + : null; return ( <div From 8a58e69adb1b9859d720aacd787a0592b3a852c0 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:34:46 +1200 Subject: [PATCH 14/17] fix(charts): honor fixed dimensions in unresponsive heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HeatmapChartInternal stopped reading width/height when sizing moved to CSS, so consumers of the exported HeatmapChartUnresponsive that pass width or height got them silently ignored — the chart fell back to 100% plus the grid min-height and could not render the documented fixed size without a wrapper of their own. Apply width/height inline again, and have the responsive export drop the wrapper's measured pixels (passing width/height undefined) so its grid still fills the container via CSS and reflows fluidly. Net: the unresponsive export pins explicit dimensions; the responsive export stays unpinned. Cover both with tests. Reported by a Codex review of the HeatmapChart PR. --- .../charts/heatmap-chart/heatmap-chart.tsx | 17 +++++++++++++- .../heatmap-chart/test/heatmap-chart.test.tsx | 23 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index caf2ea55ab66..bbfb588b2c85 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -50,6 +50,8 @@ const CELL_MIX_FLOOR = 0.15; const HeatmapChartInternal: FC< HeatmapChartProps > = ( { data, chartId: providedChartId, + width = 0, + height = 0, className, compact = false, showValues, @@ -229,6 +231,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { return ( <Center className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) } + style={ { width: width || undefined, height: height || undefined } } data-testid="heatmap-chart" > <span className={ styles[ 'heatmap-chart__empty' ] }> @@ -264,6 +267,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { trailingContent={ nonLegendChildren } gap={ gap } className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) } + // Explicit dimensions (the unresponsive export) pin the size; otherwise + // width/height are unset and the grid fills its container via CSS. The + // responsive export drops the measured pixels so reflow stays fluid. + style={ { width: width || undefined, height: height || undefined } } data-testid="heatmap-chart" data-chart-id={ `heatmap-chart-${ chartId }` } > @@ -396,8 +403,16 @@ const HeatmapChart = attachSubComponents( HeatmapChartWithProvider, { Legend: HeatmapLegend, } ) as FC< HeatmapChartProps > & HeatmapChartSubComponents; +// The responsive wrapper already sizes the container; drop its measured pixel +// width/height so the grid fills that container via CSS and reflows fluidly, +// instead of pinning to a debounced measurement. +const HeatmapChartResponsiveInner: FC< HeatmapChartProps > = props => ( + <HeatmapChartWithProvider { ...props } width={ undefined } height={ undefined } /> +); +HeatmapChartResponsiveInner.displayName = 'HeatmapChart'; + const HeatmapChartResponsive = attachSubComponents( - withResponsive< HeatmapChartProps >( HeatmapChartWithProvider ), + withResponsive< HeatmapChartProps >( HeatmapChartResponsiveInner ), { Legend: HeatmapLegend } ) as FC< HeatmapChartProps & ResponsiveConfig > & HeatmapChartSubComponents; 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 index 0ab22a71127a..aea7a16f5ce2 100644 --- 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 @@ -1,7 +1,7 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GlobalChartsProvider } from '../../../providers'; -import HeatmapChart from '../heatmap-chart'; +import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart'; import type { HeatmapColumn } from '../types'; const mockRefCallback = jest.fn(); @@ -277,4 +277,25 @@ describe( 'HeatmapChart', () => { 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( + <GlobalChartsProvider> + <HeatmapChartUnresponsive width={ 480 } height={ 240 } data={ data } /> + </GlobalChartsProvider> + ); + 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( + <GlobalChartsProvider> + <HeatmapChart width={ 500 } height={ 300 } data={ data } /> + </GlobalChartsProvider> + ); + const chart = screen.getByTestId( 'heatmap-chart' ); + expect( chart ).not.toHaveStyle( { width: '500px' } ); + expect( chart ).not.toHaveStyle( { height: '300px' } ); + } ); } ); From d898ef871aa15d35b7b002aaef0381f2af18907f Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:54:18 +1200 Subject: [PATCH 15/17] refactor(charts): stabilize heatmap handleCellMouseMove callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleCellMouseMove closed over containerBounds, a fresh object from useTooltipInPortal on every render, so the callback was rebuilt each render and reattached to every cell. Read the bounds from the existing containerBoundsRef — the same pattern the keyboard-tooltip effect uses — so the callback memoizes and the two tooltip paths stay consistent. Raised as a nit in the @claude review. --- .../charts/src/charts/heatmap-chart/heatmap-chart.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx index bbfb588b2c85..651ddf1a8a36 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -175,14 +175,17 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( { const target = event.currentTarget; const columnIndex = Number( target.dataset.column ); const rowIndex = Number( target.dataset.row ); + // Read bounds from the ref (like the keyboard-tooltip effect) so this + // callback stays stable across renders. + const bounds = containerBoundsRef.current; // TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor. showTooltip( { - tooltipLeft: event.clientX - containerBounds.left, - tooltipTop: event.clientY - containerBounds.top, + tooltipLeft: event.clientX - bounds.left, + tooltipTop: event.clientY - bounds.top, tooltipData: buildTooltipData( columnIndex, rowIndex ), } ); }, - [ withTooltips, showTooltip, buildTooltipData, containerBounds ] + [ withTooltips, showTooltip, buildTooltipData ] ); const handleCellMouseLeave = useCallback( () => { From c4c3e43708e329887dc6a17471402c0d7cfe4881 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:54:25 +1200 Subject: [PATCH 16/17] test(charts): cover with-responsive aspect-ratio wrapper styles The shared HOC now expresses aspectRatio as a CSS aspect-ratio and caps the wrapper at maxWidth, but nothing asserted it. Add tests for the CSS aspect-ratio value and the maxWidth cap, plus its omission when an explicit width is passed, so this shared behavior is locked in. Raised as a nit in the @claude review. --- .../with-responsive/test/with-responsive.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } maxWidth={ 800 } /> ); + 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( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } width={ 300 } /> ); + const wrapper = screen.getByTestId( 'responsive-wrapper' ); + expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } ); + expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } ); + } ); } ); describe( 'configuration', () => { From eeb8f353d0a6c2d4d3f227b9e6b29a6831b69235 Mon Sep 17 00:00:00 2001 From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:55:22 +1200 Subject: [PATCH 17/17] docs(charts): fix code-block indentation in heatmap docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `<Source>` example blocks authored their JSX at the template's base column, so Storybook's dedent stripped the props' indentation and the examples rendered with every prop flush against the component tag. Indent the JSX body one level (imports stay flush), matching the convention the other chart docs already use, so the dedent preserves the prop indentation. Kept to tabs throughout — no tab/space mix. --- .../heatmap-chart/stories/index.docs.mdx | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx index 78d1b8294a3e..0321c4932213 100644 --- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -18,11 +18,11 @@ The Heatmap Chart renders a CSS Grid of colored cells where color intensity maps code={ `import { HeatmapChart } from '@automattic/charts'; import '@automattic/charts/style.css'; -<HeatmapChart - data={ columns } - rowLabels={ [ 'Mon', '', 'Wed', '', 'Fri', '', '' ] } - withTooltips={ true } -/>` } + <HeatmapChart + data={ columns } + rowLabels={ [ 'Mon', '', 'Wed', '', 'Fri', '', '' ] } + withTooltips={ true } + />` } /> ## API Reference @@ -56,11 +56,11 @@ The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, ea }, ]; -<HeatmapChart - data={ columns } - rowLabels={ [ 'Mon', '', 'Wed' ] } - withTooltips={ true } -/>` } + <HeatmapChart + data={ columns } + rowLabels={ [ 'Mon', '', 'Wed' ] } + withTooltips={ true } + />` } /> ### Required Props @@ -89,10 +89,10 @@ Use `compact` for narrow widgets or dashboards where vertical space is limited. <Source language="jsx" code={ `<HeatmapChart - data={ columns } - rowLabels={ rowLabels } - compact={ true } -/>` } + data={ columns } + rowLabels={ rowLabels } + compact={ true } + />` } /> ## Large Values @@ -111,22 +111,22 @@ Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into t language="jsx" code={ `import { HeatmapChart, buildCalendarHeatmapData } from '@automattic/charts'; -const series = [ - { date: new Date( '2024-01-01' ), value: 3 }, - { date: new Date( '2024-01-02' ), value: 0 }, - // ... -]; - -function CalendarHeatmap() { - const { data, rowLabels } = buildCalendarHeatmapData( series ); - return ( - <HeatmapChart - data={ data } - rowLabels={ rowLabels } - withTooltips={ true } - /> - ); -}` } + const series = [ + { date: new Date( '2024-01-01' ), value: 3 }, + { date: new Date( '2024-01-02' ), value: 0 }, + // ... + ]; + + function CalendarHeatmap() { + const { data, rowLabels } = buildCalendarHeatmapData( series ); + return ( + <HeatmapChart + data={ data } + rowLabels={ rowLabels } + withTooltips={ true } + /> + ); + }` } /> `buildCalendarHeatmapData` accepts an optional `options` object: @@ -142,11 +142,11 @@ Pass `width` and `height` to bypass the responsive wrapper and render at an exac <Source language="jsx" code={ `<HeatmapChart - data={ columns } - rowLabels={ rowLabels } - width={ 720 } - height={ 220 } -/>` } + data={ columns } + rowLabels={ rowLabels } + width={ 720 } + height={ 220 } + />` } /> ## Composition API — Legend @@ -158,8 +158,8 @@ Add a legend by placing `<HeatmapChart.Legend />` as a child. The legend renders <Source language="jsx" code={ `<HeatmapChart data={ columns } rowLabels={ rowLabels } chartId="my-heatmap"> - <HeatmapChart.Legend /> -</HeatmapChart>` } + <HeatmapChart.Legend /> + </HeatmapChart>` } /> ## Error / Empty State @@ -175,16 +175,16 @@ Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard na <Source language="jsx" code={ `<HeatmapChart - data={ columns } - rowLabels={ rowLabels } - withTooltips={ true } - renderTooltip={ ( { cellLabel, value, rowLabel, columnLabel } ) => ( - <div> - <strong>{ cellLabel ?? \`\${ columnLabel } \${ rowLabel }\` }</strong> - <div>{ value === null ? 'No data' : value }</div> - </div> - ) } -/>` } + data={ columns } + rowLabels={ rowLabels } + withTooltips={ true } + renderTooltip={ ( { cellLabel, value, rowLabel, columnLabel } ) => ( + <div> + <strong>{ cellLabel ?? \`\${ columnLabel } \${ rowLabel }\` }</strong> + <div>{ value === null ? 'No data' : value }</div> + </div> + ) } + />` } /> ## Theming @@ -195,17 +195,17 @@ The heatmap resolves its full-intensity color in this order: the `primaryColor` language="tsx" code={ `import { GlobalChartsProvider, HeatmapChart, type ChartTheme } from '@automattic/charts'; -// Per-instance: -<HeatmapChart data={ columns } rowLabels={ rowLabels } primaryColor="#c0392b" /> + // Per-instance: + <HeatmapChart data={ columns } rowLabels={ rowLabels } primaryColor="#c0392b" /> -// Or via the theme: -const customTheme: ChartTheme = { - heatmapChart: { primaryColor: '#c0392b' }, -}; + // Or via the theme: + const customTheme: ChartTheme = { + heatmapChart: { primaryColor: '#c0392b' }, + }; -<GlobalChartsProvider theme={ customTheme }> - <HeatmapChart data={ columns } rowLabels={ rowLabels } /> -</GlobalChartsProvider>` } + <GlobalChartsProvider theme={ customTheme }> + <HeatmapChart data={ columns } rowLabels={ rowLabels } /> + </GlobalChartsProvider>` } /> Everything else — the cell gap, corner radius, in-cell value size, the empty-cell color and the selection ring — comes straight from WordPress design system tokens (`--wpds-*`) in CSS, so it tracks the active design system theme automatically. There are no per-instance props for these. The `heatmapChart` theme section exposes just the scale color (`primaryColor`, above) and the compact sizing (`compactCellSize`, `compactCellGap`): @@ -213,8 +213,8 @@ Everything else — the cell gap, corner radius, in-cell value size, the empty-c <Source language="tsx" code={ `<GlobalChartsProvider theme={ { heatmapChart: { compactCellSize: 14, compactCellGap: 3 } } }> - <HeatmapChart data={ columns } rowLabels={ rowLabels } compact /> -</GlobalChartsProvider>` } + <HeatmapChart data={ columns } rowLabels={ rowLabels } compact /> + </GlobalChartsProvider>` } /> ## Responsive Behavior @@ -228,11 +228,11 @@ By default the chart fills its parent container's dimensions, reflowing fluidly <HeatmapChart data={ columns } rowLabels={ rowLabels } /> </div> -// Derive height from width via aspectRatio (height = width × 0.4) -<HeatmapChart data={ columns } rowLabels={ rowLabels } aspectRatio={ 0.4 } /> + // Derive height from width via aspectRatio (height = width × 0.4) + <HeatmapChart data={ columns } rowLabels={ rowLabels } aspectRatio={ 0.4 } /> -// Fixed dimensions -<HeatmapChart data={ columns } rowLabels={ rowLabels } width={ 720 } height={ 200 } />` } + // Fixed dimensions + <HeatmapChart data={ columns } rowLabels={ rowLabels } width={ 720 } height={ 200 } />` } /> <Canvas of={ HeatmapStories.AspectRatio } />