Skip to content

Charts: add HeatmapChart (HTML/CSS implementation)#50065

Open
adamwoodnz wants to merge 17 commits into
trunkfrom
charts-218-heatmap-html
Open

Charts: add HeatmapChart (HTML/CSS implementation)#50065
adamwoodnz wants to merge 17 commits into
trunkfrom
charts-218-heatmap-html

Conversation

@adamwoodnz

@adamwoodnz adamwoodnz commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes CHARTS-218

Why

A heatmap is a discrete grid of solid-filled cells — the canonical CSS Grid case. The original implementation (PR #49855, unmerged) renders it as an <svg> of <rect>s via @visx/heatmap, which means manual coordinate math (xScale/yScale, binWidth/binHeight, gap fudging) and JS-driven color theming. This PR is an alternative, HTML/CSS-first implementation of the same public API, posted as a second option to compare against the SVG branch before either lands.

The win is CSS-first theming (the package's preferred direction): per-cell shading, the light→full scale, the empty color, the gap/radius/value-size and the selection ring all move into CSS, driven by WPDS tokens and color-mix. JS computes only a 0–1 intensity per cell. The geometry math disappears into native CSS Grid + gap, sizing is handled by the layout engine, and text/focus get native rendering.

Proposed changes

  • HTML/CSS HeatmapChart rendered as a single CSS Grid (label gutters + data cells; ARIA rows via display: contents).
  • CSS-first shading with color-mix(in srgb, var(--heatmap-primary) …, transparent); intensity floored at 15% so the lowest value stays visibly tinted. Empty cells default to the WPDS track color and a value overrides it.
  • Accessible in-cell contrast: each cell chooses light or dark text from its blended fill luminance (W3C threshold), so values stay legible across the whole scale and with any primary color.
  • Large values render in compact notation (748.5K, 1.2M) so they fit the cell; the tooltip and each cell's accessible label keep full precision.
  • Fluid CSS sizing: the grid reflows continuously with its container on both axes (no debounced resize step), matching the conversion-funnel chart. A min-height floor keeps it usable when the parent has no explicit height, and aspect-ratio mode derives height from width in CSS.
  • Theme integration matching the other HTML charts: scale color resolves primaryColor prop > heatmapChart.primaryColor (theme) > palette colors[0] via getElementStyles.
  • WPDS tokens, not props/theme fields, for cell gap, radius, in-cell value size, empty color and the selection ring — read directly in CSS. The heatmapChart theme section is just primaryColor + the compact sizing (compactCellSize, compactCellGap).
  • Compact mode (fixed 11px squares), calendar/contribution layout via buildCalendarHeatmapData, a composition color-scale legend, full ARIA grid + 2D keyboard navigation, and tooltips on hover and keyboard focus.
  • Stories, docs/API MDX, shared sample data, and a changelog entry.

The public API (HeatmapChartProps, <HeatmapChart.Legend />, buildCalendarHeatmapData) stays compatible with the SVG branch, so it's a drop-in alternative.

Screenshots

Default matrix

heatmap-default

Compact mode

heatmap-compact

Large values (compact notation)

heatmap-large-values

Calendar / contribution layout

heatmap-calendar

Composition legend

heatmap-legend

Testing instructions

  1. pnpm install and run Storybook for the charts package.
  2. Open JS Packages / Charts Library / Charts / Heatmap Chart and exercise: Default, Compact, Large Values, Calendar (toggle the weekStartsOn control), Fixed Dimensions, Aspect Ratio, Composition Legend, and Error State.
  3. Tab into the grid (focus ring appears), arrow-key around (active cell highlights; with withTooltips its tooltip follows), and press Escape to clear.
  4. Hover cells to confirm tooltips; confirm empty (no-data) cells render in the neutral track color and present cells shade from light → full. On the Large Values story, confirm in-cell numbers use compact notation and stay legible on the darker cells.
  5. Resize the Storybook viewport (or drag a story's container) and confirm the grid reflows smoothly on both axes — no snapping or lag.
  6. From the package: pnpm run typecheck and pnpm test — the heatmap suite (36 tests) and the full charts suite (961) pass.

Does this pull request change what data or activity we track or use?

No.

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.
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).
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).
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.
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the charts-218-heatmap-html branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack charts-218-heatmap-html
bin/jetpack-downloader test jetpack-mu-wpcom-plugin charts-218-heatmap-html

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Jun 30, 2026
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@copilot review this PR. Leave your feedback as review comments only — do NOT push commits, apply suggestions, or modify the branch in any way. I will make all code changes myself.

Copilot AI commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

@copilot review this PR. Leave your feedback as review comments only — do NOT push commits, apply suggestions, or modify the branch in any way. I will make all code changes myself.

I reviewed the PR changes and didn’t find any blocking functional, accessibility, or API-compatibility issues to call out in review comments.

@jp-launch-control

jp-launch-control Bot commented Jun 30, 2026

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 3 files.

File Coverage Δ% Δ Uncovered
projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx 12/12 (100.00%) 0.00% 0 💚
projects/js-packages/charts/src/stories/sample-data/index.ts 32/32 (100.00%) 0.00% 0 💚
projects/js-packages/charts/src/utils/color-utils.ts 90/91 (98.90%) 0.18% 0 💚

7 files are newly checked for coverage. Only the first 5 are listed here.

File Coverage
projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx 9/10 (90.00%) 💚
projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx 98/105 (93.33%) 💚
projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts 41/43 (95.35%) 💚
projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts 21/21 (100.00%) 💚
projects/js-packages/charts/src/charts/heatmap-chart/index.ts 0/0 (—%) 🤷

Full summary · PHP report · JS report

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.
@adamwoodnz adamwoodnz self-assigned this Jun 30, 2026
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.
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.
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@copilot review this PR. Leave your feedback as review comments only — do NOT push commits, apply suggestions, or modify the branch in any way. I will make all code changes myself.

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.
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.
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@copilot review this PR. Leave your feedback as review comments only — do NOT push commits, apply suggestions, or modify the branch in any way. I will make all code changes myself.

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.
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.
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.
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

🤖 Codex Review

Automated review generated locally with codex exec review (model: gpt-5.5), diffing this branch against trunk.

Summary

The heatmap addition largely typechecks and tests pass, but the responsive aspect-ratio change mis-sizes wrappers when maxWidth caps the rendered chart, and the public unresponsive heatmap export ignores fixed dimensions. These are functional layout regressions that should be fixed before considering the patch correct.

Findings

[P2] Keep aspect-ratio sizing aligned with maxWidthprojects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx:98-99

When a responsive chart uses aspectRatio inside a container wider than maxWidth, the wrapped component receives dimensions based on the capped measuredWidth, but this CSS aspect-ratio is applied to a wrapper that is still width: 100%. For example, with the default maxWidth of 1200 in a 2000px parent and aspectRatio={0.4}, the chart renders at 1200×480 while the wrapper reserves 2000×800, leaving large blank space and mis-sizing all responsive charts using this path.

[P2] Honor fixed dimensions in the unresponsive heatmapprojects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx:50-62

When consumers import the newly exported HeatmapChartUnresponsive and pass width or height, those props are silently ignored because HeatmapChartInternal never reads or applies them. Unlike the other unresponsive chart exports, fixed-size usage falls back to width: 100% / height: 100% plus the grid minimum height, so callers cannot render the documented fixed dimensions without wrapping the chart themselves.


Posted on behalf of @adamwoodnz — this is an automated Codex review and has not yet been triaged or addressed.

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.
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.
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@claude — requesting a final review.

This is the HTML/CSS-first HeatmapChart (alternative to the SVG/visx branch in #49855). Copilot's rounds are complete; a local Codex review was run and its two findings are addressed. Please focus on:

  • Responsive sizing — the chart sizes purely in CSS (fills its container, reflows fluidly on both axes, min-height floor when the parent has no explicit height), matching the conversion-funnel chart. The responsive export drops the wrapper's measured pixels; the unresponsive export pins explicit width/height.
  • Shared withResponsive change — aspect-ratio is now expressed as CSS aspect-ratio, capped at maxWidth. Scoped to the aspect-ratio branch, but please sanity-check the impact on the other charts that share the HOC.
  • Accessibility — ARIA grid + 2D keyboard nav, and in-cell text contrast chosen from the blended-fill luminance.

Gates are green locally: type-check, ESLint, Stylelint, and the full charts suite (963 tests). Thanks!

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @adamwoodnz's task in 3m 59s —— View job


Final review — HTML/CSS HeatmapChart

Reviewed the implementation against the three focus areas. This is a clean, well-tested PR — both Codex findings are correctly addressed and the a11y story is strong. Notes below, nothing blocking.

  • Read PR diff & new files
  • Responsive sizing (exports + min-height floor)
  • Shared withResponsive aspect-ratio change & other-chart impact
  • Accessibility (ARIA grid, 2D keyboard nav, in-cell contrast)
  • Color utils / luminance

✅ Responsive sizing — correct

The two-export split does what the description claims:

  • Responsive default (heatmap-chart.tsx:409-411) drops the measured pixels (width={undefined} height={undefined}) so the grid fills the wrapper via CSS (width/height: 100%, min-height: 200px floor) and reflows fluidly. The FixedDimensions story sizes through the wrapper (withResponsive applies width ?? '100%'), so passing width/height to the responsive export still works.
  • Unresponsive export pins width/height onto ChartLayout (heatmap-chart.tsx:273) and the empty-state Center (:234) — Codex finding #2 resolved.
  • The compact branch correctly flips to flex: 0 0 auto / width: max-content / min-height: 0 so the 11px-square grid doesn't get stretched by the 200px floor.

withResponsive change — math is right, low blast radius

  • aspect-ratio: ${1 / aspectRatio} (with-responsive.tsx:103) is correct: aspectRatio is height/width (0.4), CSS aspect-ratio is width/height (2.5) → height = W×0.4. ✔
  • The maxWidth cap on the wrapper (:104) actually fixes a pre-existing bug for all aspect-ratio charts: before this, in a parent wider than maxWidth the wrapper spanned the full parent width while the child (SVG) was capped at maxWidth, leaving blank space to the right. Capping the wrapper removes that. Good catch to fold this in.
  • One thing to eyeball before merge: for the other seven aspect-ratio charts (area/bar/bar-list/geo/line/sparkline/leaderboard), the wrapper height was previously content-derived (height: auto tracking the child SVG's pixel height) and is now CSS-derived (aspect-ratio + height: auto). At steady state they're equal (child height = capped-width × ratio = wrapper-width × ratio), and .container has no overflow, so there's no clipping risk. The only difference is a possible brief height mismatch during the 300ms resize debounce (wrapper tracks continuously, SVG snaps). Worth a quick visual pass on one SVG aspect-ratio story (e.g. Area → Responsive) to confirm no flicker — but I don't see a correctness problem.
  • Minor test gap: the new aspectRatioStyle (the aspect-ratio + max-width on the wrapper) isn't asserted in with-responsive.test.tsx — the existing tests only check width/height/auto. Since this HOC is shared by every responsive chart, a small assertion would lock in the new behavior. Fix this →

✅ Accessibility — solid

  • ARIA grid + aria-activedescendant roving pattern is right: focus stays on the grid (tabIndex={0}), cells are tabIndex={-1}, and the activeDescendant id math (Math.floor(selectedIndex/rows) / selectedIndex % rows, :257) matches each cell's id (:335). Arrow clamping (no wrap), Escape/Tab/blur clearing, and preventDefault on arrows (only, so Tab still moves focus) are all handled and covered by tests.
  • In-cell contrast (cellHasLightText, :91-95) modeling the color-mix floor as lightenHexColor(primary, 1 - (0.15 + 0.85·intensity)) and thresholding on W3C luminance (0.179) is a sound approximation. Worth noting it assumes a white backdropcolor-mix(..., transparent) composites over whatever's behind the cell, so on a dark theme the chosen text color could be inverted from optimal. Fine for the current light-theme default; just a known limitation if the package later ships dark surfaces.
  • Minor ARIA nit: the corner span + column-label spans are direct children of role="grid" (:293-302). A grid's required owned children are rows; these are aria-hidden, so AT ignores them and each cell carries its full name — but a strict axe run may still flag aria-required-children. If Storybook's a11y addon is clean here, ignore this.

Nits (non-blocking)

  • handleCellMouseMove (:170-186) depends on containerBounds, which is a fresh object each render, so the callback is re-created every render. Harmless, but reading bounds from the existing containerBoundsRef (as the keyboard effect already does) would let it memoize.

Nice work — the CSS-first approach reads cleanly and the test suite (grid structure, compact toggle, compact-notation, hover + keyboard tooltips, full 2D nav with clamping, legend) covers the surface well.
· branch charts-218-heatmap-html

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.
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.
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.
@adamwoodnz adamwoodnz marked this pull request as ready for review July 1, 2026 01:59
@adamwoodnz adamwoodnz added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels Jul 1, 2026

@kangzj kangzj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — solid, well-tested implementation. CSS-first is the right call for a discrete grid, the public API matches the SVG branch as claimed, and coverage (calendar builder, keyboard nav, theming resolution, contrast utils) is genuinely thorough. No blocking bugs.

Left 5 inline notes: (1) the shared withResponsive change affects all aspect-ratio charts, not just the heatmap — worth verifying no regression or splitting out; (2) a circular import between the chart and its legend; (3) a minor ARIA grid → row structural nit; (4) the in-cell contrast calc implicitly assumes a light background; (5) one vacuous test assertion.

Nice touches worth calling out: the max = 0 → undefined slice trap is avoided, getNormalizedValue handles the flat-extent case, buildCalendarHeatmapData filters unparseable dates + documents last-write-wins for duplicate days (both tested), and the keyboard-tooltip effect reads containerBounds from a ref to avoid a render loop.

hasAspectRatio && aspectRatio
? {
aspectRatio: `${ 1 / aspectRatio }`,
maxWidth: width === undefined ? maxWidth : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't heatmap-specific — it applies a CSS aspect-ratio and a maxWidth cap to the wrapper <div> for every chart that uses aspectRatio (line, bar, funnel, …). Previously the wrapper was width: 100% with no cap (only the inner chart width was capped via Math.min(parentWidth, maxWidth)); now the wrapper itself caps at maxWidth in wide containers.

That's arguably more correct (removes dead space to the right of the chart), but it's a visible layout change to shared infra bundled into a new-chart PR. The two added unit tests cover the emitted style, not visual layout — worth confirming no regression on existing aspect-ratio stories, or splitting this out from the heatmap change. Not a blocker.

import { Stack, Text } from '@wordpress/ui';
import { useContext } from 'react';
import { useGlobalChartsTheme } from '../../../providers';
import { HeatmapContext } from '../heatmap-chart';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circular import: heatmap-chart.tsx imports HeatmapLegend from ./private, and this file imports HeatmapContext back from ../heatmap-chart. It works today because the context is only referenced at render time (ES live bindings resolve by then), but it's fragile — a future eager use of HeatmapContext at module scope here would break. Cheap fix: move HeatmapContext into its own small module under private/ that both import. Not a blocker.

style={ gridStyle as CSSProperties }
>
{ /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ }
<span aria-hidden="true" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The corner gutter and column-label <span>s are direct children of role="grid" rather than inside a role="row". They're aria-hidden so functionally fine and AT-invisible, but a strict validator expects grid → row → gridcell only. Wrapping the header cells in a role="row" (or role="presentation") would make it airtight. Low priority.

const cellHasLightText = ( intensity: number ): boolean =>
isValidHexColor( primaryHex ) &&
prefersLightText(
lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cellHasLightText derives luminance from lightenHexColor(primary, …), which blends toward white, while the actual CSS fill is color-mix(…, transparent) — i.e. primary over whatever background sits behind it. These agree only on a light background; on a dark chart background the light/dark text choice can invert. The package background is light today so this is acceptable, but it's an implicit coupling — worth a one-line comment near CELL_MIX_FLOOR noting the light-background assumption.

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( '' );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vacuous assertion: nothing in the component ever sets --heatmap-cell-radius (the SCSS reads --wpds-border-radius-sm directly), so this can never fail regardless of behavior. Either wire radius through that var or drop the line — the --heatmap-cell-gap assertion above it is the one that carries weight.

@kangzj kangzj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — solid, well-tested HTML/CSS implementation with a clean public API matching the SVG branch. The inline notes are non-blocking; happy to see them handled as follow-ups. 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants