Skip to content

improvement(charts): a Heatmap composed from existing primitives - #1219

Merged
bert-e merged 12 commits into
development/1.0from
improvement/heatmap-component
Sep 23, 2026
Merged

bert-e merged 12 commits into
development/1.0from
improvement/heatmap-component

Conversation

@clement-scality

Copy link
Copy Markdown
Contributor

Component: charts — a new Heatmap

TL;DR — Adds a Heatmap component: one row per entity, one column per time slot, each cell coloured by its value.

Split out of #1190, which carried this plus a logarithmic Y axis. The axis is in #1218 now; this one is the heatmap and nothing else.

Context / Why

Nothing in core-ui reads one metric across many entities over time — the shape a service-status board or a per-node health strip wants. It started life in #1190 as a story-only recipe, a design proposal to be argued with before anything landed in src/. That review happened, the shape held, so it is a component.

It is assembled from Box, Tooltip, Text and ChartLegend rather than from a charting library: the grid is CSS, so it costs no Recharts instance and scales to a few hundred cells without one.

🧩 Approach

The component owns everything it draws — title, row labels, grid, x-axis, legend — so an app hands it data and a colour scale, not a composition:

<Heatmap
  title="Monitoring Services Status"
  legendTitle="Service Status"
  scale={{ colorSet: { OK: theme.statusHealthy, /* … */ } }}
  rows={rows}
  columns={timeSlots}
/>

Two scales, and they type the data with them:

scale Cells Legend
discrete (default) value names mounts its own ChartLegendWrapper from colorSet, so the legend both colours the cells and filters the grid on click. Omit colorSet to read a wrapper the caller put above instead — that is how several charts share one legend.
continuous numbers ramps one colour by opacity, beside a HeatmapGradientScale handed the same max the cells were ramped against, so the printed domain cannot drift from what the grid shows.

The grid is driven by columns, not by each row's cells. A row shorter than the axis has to leave the rest of its line empty, and CSS auto-placement does the opposite — it pulls the next row's label out of the gutter and skews everything below it. A null cell holds its slot.

What stays with the caller is domain, not composition: which colour OK / WARNING / CRITICAL / NONE are worth, and in what order the legend lists them. The component has no business knowing those statuses.

📷 Screenshots

ScreenshotEquivalent — 5 services, 4 columns, the last one with no data yet. The rest of the stories are worth clicking through rather than screenshotting.

Heatmap, services against time slots with a status legend

🔍 Review focus

  • 🟡 Moderate — charts/heatmap/Heatmap.tsx — the props surface, and two calls worth a second opinion. One implementation per scale rather than one branch inside it, because the discrete scale reads the legend context and a hook cannot be called conditionally; the dispatcher pays for it with a cast, since TypeScript narrows on a top-level discriminant and not on scale.type one level down. And showLegend defaults to true even when the wrapper is inherited, so two heatmaps under one shared wrapper draw two identical legends until the caller says otherwise — a predictable default preferred over an implicit rule.
  • ⚪ Minor — charts/heatmap/Heatmap.utils.ts — the opacity ramp: clamped to its domain, and rounded to two decimals so a dense grid does not mint a styled class per cell.
  • ⚪ Minor — accessibility: every cell is a tab stop, which is what makes its tooltip reachable from the keyboard (Tooltip opens on focus). On the Dense grid story that is ~400 tab stops. Say so if you would rather trade the keyboard path for a shorter tab order.

🧪 How to test

npm run storybook

Charts → Heatmap. It opens on a Playground whose data is a control, so it is meant to be poked at rather than read:

  1. Playground → Controls → rows. It is a JSON editor: add a row, rename one, change any cell to OK / WARNING / CRITICAL / NONE. The x-axis is derived from the longest row, so adding cells adds columns.
  2. Still on rows: delete the last cells of one row. Its line ends early and every other row stays put — that is the auto-placement fix.
  3. Click a legend entry. Unselected statuses fade rather than vanish, so the shape of the data survives the filter.
  4. Dense grid → push cellGap to 0 for a continuous timeline, then raise columns to 96 and use labelEvery to keep the axis legible. noDataColumns is the "collection has not caught up" tail.
  5. Numeric values is the continuous scale: minOpacity is the floor that keeps the low end visible, and the gradient beside the grid states the domain the cells were ramped against.
  6. Tab into the grid. A focused cell outlines itself and opens its tooltip — the same one hover gives.

Theme switching is worth a pass: the status colours, the cell outlines and the gradient all come from tokens.

npm test -- src/lib/components/charts/heatmap

🔗 References

Breaking Changes: none. Heatmap, HeatmapGradientScale and getHeatmapMaxValue are new exports on next.ts; nothing existing changes.

No issue or ticket for this one.

What changed

charts/heatmap/ is the component: Heatmap.tsx (the frame, the grid and one implementation per scale), HeatmapGradientScale.tsx (the continuous legend, placed by the component or by the caller, like ChartLegend), Heatmap.utils.ts (the ramp and the domain) and their tests. stories/Heatmap/ keeps the data and the theme colours, and nothing else.

Two shapes were tried and dropped along the way in #1190. One stacked a GlobalHealthBar per row — it worked, but it needs one chart instance per row and hides every x-axis but the last. The other was a NestedProportionBar, a different chart altogether, which is not part of this PR.

One row per entity, one column per time slot, each cell coloured by its
value and describing itself on hover or focus. It is assembled from Box,
Tooltip, Text and ChartLegend rather than from a charting library: the
grid is CSS, so it costs no Recharts instance and scales to a few
hundred cells without one.

The component owns everything it draws — title, row labels, grid, x-axis
and legend — so an app hands it data and a colour scale rather than a
composition.

Two scales, and they type the data with them. `discrete` — the default —
takes a colorSet and mounts its own ChartLegendWrapper, so the legend
both colours the cells and filters the grid on click; omitting the
colorSet reads a wrapper the caller put above instead, which is how
several charts come to share one legend. `continuous` ramps one colour by
opacity and stands beside a HeatmapGradientScale handed the same max the
cells were ramped against, so the numbers printed by the scale cannot
drift from what the grid shows.

The grid is driven by `columns`, not by each row's cells. A row shorter
than the axis has to leave the rest of its line empty, and CSS
auto-placement does the opposite — it pulls the next row's label out of
the gutter and skews everything below it. A `null` cell holds its slot.

Three smaller decisions worth knowing. Colours are resolved once per
distinct value rather than once per cell, because a dense grid asks
`getColor` the same four questions four hundred times and each miss
warns. Cell opacity is rounded to two decimals, which is past the eye's
resolution and stops a dense grid from minting a styled class per cell.
And the prop is `scale`, not `colorScale`: Storybook infers a colour
picker for any prop whose name matches /color/i.

The stories keep the data and a useStatusScale() that says what OK,
WARNING, CRITICAL and NONE are worth in the theme's colours — that much
is app domain, and the component has no business knowing those statuses.
@bert-e

bert-e commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Hello clement-scality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval ⭐
/bypass_build_status Bypass the build and test status ⭐
/bypass_commit_size Bypass the check on the size of the changeset TBA ⭐
/bypass_incompatible_branch Bypass the check on the source branch prefix ⭐
/bypass_jira_check Bypass the Jira issue check ⭐
/bypass_peer_approval Bypass the pull request peers' approval ⭐
/bypass_leader_approval Bypass the pull request leaders' approval ⭐
/bypass_source_branch_lineage Bypass the cross-branch contamination check ⭐
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request.
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e

bert-e commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@clement-scality
clement-scality marked this pull request as ready for review September 10, 2026 12:37
…status

The stories only ever showed OK / WARNING / CRITICAL / NONE in the theme's
status tokens, which reads as the set of values a Heatmap supports rather
than as one example of them. Three stories say otherwise.

CustomColorSet takes five backup outcomes and colours them half from the
chart palette and half from the theme's own tokens, because colorSet takes
any CSS colour and there is no reason to pick them all from one place.
NonStatusValues drops the theme entirely: four workload profiles in four
hand-written hex values, nothing about health in them. LabelledValues puts
bare response codes in the cells, then hands labelMap to the legend and
formatValue to the tooltip and aria-label, so the data keeps its codes and
the reader gets sentences; its sortOrder compares them as numbers.

Generalising the data helper is what made the three cheap. buildCategoryRows
fills a grid over any value set, weighting each value at half the frequency
of the one before it — an even wash hides which colour means what, which is
the only thing these stories are about. inDeclaredOrder replaces the
indexOf-with-cast comparator that useStatusScale had inline.
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
@Cuervino

Copy link
Copy Markdown
Contributor

Hello Clément,

I pulled the branch and ran the storybook locally, so this is feedback from using it, not from reading the code.

First, the thing I want us to be explicit about: in a heatmap, colour is the only channel carrying information. By definition it is not accessible on its own, and here the tooltip is what saves it. I am fine with that for this component, it is the very nature of a heatmap, but two consequences follow. The limitation has to be written down in the guidelines rather than discovered, and I want to keep the number of components that rely on colour alone as small as possible. So this one should not become a habit.

What I like: the row labels are right-aligned, which makes them much easier to scan against the grid. The selected state is a good call too, it uses the design system's selection token and it is painted as an outline, so nothing moves when you hover. Accessibility is clearly not an afterthought either, every cell is reachable and announces itself, and the tooltip opens on focus, not only on hover.

Now the points I would like you to look at.

  1. A new component needs its guideline page, and for this one it matters more than usual, because of the colour limitation above. Take the Button guideline as the model and keep it short to start with, the designers can grow it later. What I would want in a first pass: when to use a heatmap and when a table or a line chart is the better answer, the accessibility limitation stated plainly, which colour sets are allowed (status versus categorical), how the time axis and the tooltip express time, and the empty and no-data states.

  2. The time axis does not follow our date format. It shows "08-25" and "09-02", which is month first and numeric, so it reads as 11 September for us and as 9 November for anyone used to day first. Our format guideline gives "DD MMM" (20 Jul) for a chart time axis. The tooltip itself is fine as it is.

  3. The period in the title is optional at best. Our charts carry the unit, not the range, and even where you see a period in the BarChart stories, the time range is usually controlled globally, outside the chart. So "last 24 hours" and "last 14 days" written into a title will not hold once a real range selector drives the data.

  4. Midnight. Showing it as 00:00 is fine by me, but no story crosses midnight today, so the choice is invisible. Please add one that does, so the decision to change nothing is on the record.

  5. The hover should say how long a cell lasts. Today it gives a single instant, and I have to compare it with the next tick to work out whether I am looking at 15 minutes, an hour or a day. My preference is the full slot on the first line, "25 Aug 03:30 to 04:00". If that is too long, keep the start time and add the duration next to it, "25 Aug 03:30, 30 min".

  6. The cursor turns into a hand on every cell, but nothing is clickable. It promises an action that does not exist, in every use case.

  7. The stories shout. "OK", "WARNING", "CRITICAL", "NONE" in full uppercase reads as generated content, and stories are what people copy. Normal capital initials please.

  8. Colours. When the values are not statuses, please do not reuse the status palette: in the backup story the cyan that means "Healthy" elsewhere becomes "Full", and that story mixes job types (Full, Incremental, Snapshot) with job outcomes (Skipped, Failed) in one scale. A neutral categorical set is what is missing, and we already have the series colours in the theme, which is what the same story uses at the top. One story goes further and hardcodes four colours that exist in no theme (#3B9EDB, #E8A33D, #8E6FD8, #5A6270), so they will not follow any theme at all.

  9. Have you checked both light and dark? I ran it in A-Light and the component itself holds up. The hardcoded colours above are the part that will not.

  10. What is the use case for the continuous scale? I am not convinced we need it. Distinct colours read better than a ramp, and if we do need intensity I would rather discretise it into a few steps. What settles it for me is the low end: a nearly transparent cell and a "no data" cell look almost the same, so "almost nothing" and "nothing" become hard to tell apart. What is your take on that one?

  11. For percentages, the top of the scale should be pinned to 100 by default, overridable. Today it stops at the highest value in the data, so two charts side by side are not comparable. LineTimeSerieChart already does this with its percentage axis.

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

Requesting changes so this does not land before we settle the design points, which I left in full in the comment above: #1219 (comment)

Happy to discuss any of them, several are questions rather than asks.

@bert-e

bert-e commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

The following reviewers are expecting changes from the author, or must review again:

@Cuervino

Copy link
Copy Markdown
Contributor

Even if my previous content looked AI-generated, it's really my personal review, but maybe the style is not understandable enough because generated by Claude. Please tell me if it's the case, I need to adapt and to have the correct tone, and it should be explainable enough.

A cell now says how long it lasts. It only ever gave the instant its
column opened, so reading it meant measuring it against the next tick to
find out whether the square covered five minutes, an hour or a day. The
duration is read off the axis — a column lasts until the next one starts,
the last reuses the gap before it — so an irregular axis is followed
rather than assumed away, and no new prop is needed. The end repeats the
date whenever the slot changes day: without that, a nightly slot read
"31 Aug 23:00 to 00:00" and a daily one "31 Aug 00:00 to 00:00", the same
instant twice as far as the reader could tell. getDateDaysDiff cannot
answer that question, being an elapsed duration where this is a calendar
one, hence isSameCalendarDay.

labelWidth promised that labels truncate rather than widen the gutter,
and nothing in the CSS made them. They overflowed onto the grid instead.
The gutter clips now, with min-width because a grid item defaults to the
width of its content and would otherwise refuse to shrink into its track,
and carries the full label in a title so the truncation loses nothing.

Cells no longer show a pointer cursor. Nothing in the grid is clickable
and the hand promised an action that does not exist.

Colour sets stop crossing. Status tokens are for states of health;
everything else — job outcomes, workload profiles — takes the series
colours, because a value painted statusHealthy claims to be healthy and a
backup type is not. One story had four hex values written into it, which
belong to no theme and follow none. Worth knowing: the series colours are
module constants, identical in all four themes, so they are the design
system's categorical palette rather than a theme-reactive one.

The axis speaks the date guideline. It printed "09-02", which is
2 September or 9 February depending on the reader; day-month-abbreviated
gives "02 Sep". The component's own default tick is still time-of-day and
has no notion of its axis granularity, so a daily axis without an
explicit formatColumnTick would print midnight fourteen times.

Stories stop shouting: OK / WARNING / CRITICAL / NONE became Ok / Warning
/ Critical / No data, since stories are what people copy. Periods leave
the titles, a range being something a global selector owns rather than a
chart. A story crossing midnight puts on the record that the axis says
nothing when the day changes. And Numeric Values gained a toggle between
a ramp pinned to 100 and one topped by the data, with the sample load
lowered to around 60% so the difference is visible — two grids whose
ramps top out at their own maxima are not comparable.

The guideline page is a first version, and deliberately silent on the
continuous scale while its fate is still being discussed. It states the
limitation the component cannot design away: colour is the only channel
carrying information, the tooltip is what saves it, and that is a reason
to keep such components rare rather than a pattern to copy.
@bert-e

bert-e commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

The following reviewers are expecting changes from the author, or must review again:

The Labelled Values story still painted 200 with statusHealthy, 403 with
statusWarning and 500 with statusCritical, which is the crossing the rest
of the review already removed everywhere else.

A response code looks like a health status and is not one. A 403 is the
server working exactly as asked; a 500 in one endpoint's row says nothing
about whether the product is degraded. Painting them with the status
tokens makes every red cell in the product claim the same thing whether
it holds or not, and that claim is the whole value of those three colours
where they do apply.

Four series colours instead, none of them crimson, so the reading order
of the legend comes from sortOrder comparing the codes as numbers rather
than from a hue quietly ranking them.
Comment thread src/lib/components/charts/heatmap/HeatmapGradientScale.tsx Outdated

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

Hello Clément,

I ran the storybook on 7267bcf1, most of what I raised is in. Thanks, and good call on the response codes.

I am approving so this is not stuck on me, on the understanding that the continuous scale goes before merge. See my comment on next.ts.

One question (it is what made me rework the guideline): every cell is an aggregate, and the rule behind it changes the picture: worst value wins, average and last value give three different grids from the same data.
👉 Which rule should we recommend ? Do you think the component should help state it to the reader?

The rest is on the lines.

Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread src/lib/next.ts Outdated
Comment thread stories/Heatmap/heatmap.stories.tsx Outdated
Comment thread stories/Heatmap/heatmap.guideline.mdx Outdated
@bert-e

bert-e commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

…is tick

The continuous scale goes. No product needs it today, and a published
prop nobody exercises still has to be documented, tested and supported.
The day intensity is actually needed, the use case will say whether it
wants a ramp or a few steps, which is a better place to decide it from
than this one.

Out with it: HeatmapGradientScale, HeatmapContinuousScale,
ContinuousHeatmapProps, getHeatmapMaxValue, getRampOpacity,
DEFAULT_MIN_OPACITY, the Numeric Values story and the exports of all of
it. Two open questions close with it — pinning the top of a percentage
ramp to 100, and the gradient bar starting at minOpacity while its bottom
label said min, so it showed shades no cell would ever use.

What is left is simpler than what it replaced. HeatmapProps stops being a
union, so Heatmap has neither a branch nor the casts that branch needed,
and the value type narrows from string | number to string — a heatmap
value is a name, and HeatmapRow<CellStatus> still lets a caller say which
names. formatValue keeps its test: it was only ever covered through the
continuous scale, and it is a discrete prop too.

The x-axis now works out its own tick format. It was hardcoded to the
time of day and ignored the step of the axis, so a daily grid printed
"00:00" over every one of its columns unless the caller passed
formatColumnTick. The slot duration is already known from getColumnEnds,
so the default reads it: time of day below a day, the abbreviated date
from a day up. The date guideline then lives in the component rather than
in every caller, which is why the guideline page no longer states it.

That guideline page is Cuervino's rewrite, kept whole. It adds the two
things mine was missing. First, what a cell hides: it is an aggregate,
worst-value-wins and average and last-value produce three different grids
from the same data, and the reader cannot tell which rule made the
picture — so the page has to ask for it to be stated. Second, the name,
since a heatmap in UX research is clicks or gaze painted over a
screenshot, an unrelated object that shares the word.

Titles go to sentence case throughout, because examples are what people
copy and the rest of the charts in that file already were.
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx
Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
gridTemplateColumns={`${labelWidth} repeat(${Math.max(
columns.length,
1,
)}, minmax(0, 1fr))`}

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.

No min on cell, meaning that if the column number increase or if available space is small, the cell will not stop shrinking. Also the labels for time can overlap.
@Cuervino Should the cell have a min size ? And what happens if there is no enough space ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A new Horizontal Scroll story is available to try this, giving each cell a minimum width so a long axis scrolls sideways instead of shrinking.

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.

Yes, it's a very good question, @JeanMarcMilletScality.
No minimum size that makes the grid scroll, as a status history is read as one picture, so the window has to fit.

As is, this component might be tricky, especially if it doesn't calculate anything regarding how much space it has, but only depends on the backend.
And maybe you (Jean-Marc) need to provide a bit of responsiveness rules here since it's a topic that you start to be an expert at.
It might be possible to set the legend on the bottom and not on the right if there is not enough space, and also maybe there is a way to combine and to make a new aggregation if there is not enough space to reduce the number of horizontal slots.

…croll

Four things Jean-Marc asked for.

labelWidth was typed string and interpolated into grid-template-columns,
where a length CSS cannot parse does not fail alone — it invalidates the
whole declaration, repeat() included, and the grid collapses. It is a
CSS length now, and it no longer goes near the template: the gutter cell
carries its own width, so a bad value costs one label. cellHeight,
cellGap and cellMinWidth take the same type, and each says what it
expects and what it defaults to.

sortOrder and labelMap are read only alongside colorSet — without one
there is no legend of ours to order, and the ChartLegendWrapper the
caller put above owns it. That was true and unwritten; it is written on
both props now.

A cell announced itself as its row and its value, leaving a screen reader
to count columns to find out when it happened. It carries its slot too,
from the same formatSlot the tooltip uses, so the two cannot drift. And
the grid says it is one: role="grid", rows, gridcells — the empty ones
included, or the columns stop lining up for anyone stepping through them.

Then the part that was not asked for as such. Columns share the width
available, so a long axis in a narrow frame kept dividing until the cells
were slivers a couple of pixels wide: present, aligned, and saying
nothing. cellMinWidth is a floor, 12px by default, and past it the grid
scrolls sideways instead.

Scrolling is what made the rest of the layout move. The labels sit
outside the scrolling area rather than pinned inside it, which is worth
spelling out because pinning is the obvious thing to try and it costs
three problems: something opaque to paint, therefore a background the
component has to be told about, therefore a hover ring bleeding past the
box that paints it. Outside, they simply do not move, and the scrollbar
covers the tiles alone rather than promising that the labels move too.
Rows became subgrid to keep the two columns level — the scroller takes
the rows it is placed in rather than sizing its own — and overflow-y is
hidden, because a horizontal scrollbar shortens the box by just enough to
summon a vertical one.

The row labels lost role="rowheader" on the way out of the grid: a
rowheader has to be owned by the row holding its cells. Each row carries
an aria-label instead, each cell still names its row, and the visible
column is aria-hidden so none of it is announced twice.
The page already asks the reader to decide how wide a slot is, and to say
which rule aggregated it. It said nothing about how many of those slots
the frame can hold, which is the decision that comes next and the one the
component now has an opinion about.

It sits under the aggregation section rather than beside the time axis,
because it is the same question: slot width decides what a cell hides,
the floor decides how much of the grid you can see without scrolling for
it. Raising the floor to give each slot more room trades one way of
losing the pattern for another.
@bert-e

bert-e commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

…e its cells

A narrowing frame used to take it out of the cells alone: the label gutter held
a fixed `labelWidth` and the legend held its column, so the tiles were the only
thing left to squeeze, and they reached `cellMinWidth` and scrolled while both
neighbours still had room to give.

Reverse the order, cheapest first. The row labels truncate — they keep the whole
name in a tooltip, so nothing is lost. The legend then drops under the grid and
turns horizontal, which costs nothing at all. Only then do the cells narrow, and
past their floor the grid scrolls as before.

`labelWidth` becomes a cap rather than a width, so the gutter also stops leaving
dead space beside short labels. Two lengths carry the policy: `LABEL_MIN_WIDTH`,
how far the labels give way, and `PREFERRED_CELL_WIDTH`, the cell width they give
way to protect — measured against `cellMinWidth` instead, the cells would always
reach the floor first and the gutter would only yield once scrolling had started.

Which way the legend went is measured rather than predicted: the break depends on
how many columns the axis has, so there is no breakpoint to write. Reading it back
cannot oscillate, since going under only makes the legend wider.

Also fixes two things the responsive work made visible: an x-axis tick wrapped to
two lines inside its single-column cell, and the title was a size and a weight
above the `ChartTitle` that `ChartHeader` gives every other chart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bert-e

bert-e commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

Comment thread src/lib/components/charts/heatmap/Heatmap.tsx Outdated
Comment thread stories/Heatmap/heatmap.guideline.mdx
Tooltip does not wrap its children transparently: it puts a container
and a reference div around them. The role sat on the Cell, two levels
under, so what each row actually owned was an anonymous div and the
gridcells were buried beneath it — the one arrangement ARIA does not
allow, since a row's required owned elements are its cells.

The role moves to a wrapper above the Tooltip. That wrapper is the grid
item now, which is why it is display: grid — TooltipContainer is
inline-block, and behind an ordinary block it would shrink to fit a cell
that has no width of its own, collapsing every tile to nothing.

The gap that let this through is worth as much as the fix. The suite
counted six gridcells and found them by name, and both stayed true with
the cells buried, because neither says anything about who owns them.
There is a test for the ownership itself now; it fails on the old shape,
with the three nulls of the intervening divs.

Empty cells were always correct, having no tooltip to be wrapped in —
which is how the grid came to be right for its holes and wrong for every
painted cell.
… ring

A cell paints its hover and focus ring outside its own box, and the
scroller clips at its padding box, so the ring on any cell along an edge
was cut — top and bottom by the hidden vertical overflow, the leading and
trailing columns by the horizontal one.

Padding gives it that room, and gives the outermost tiles a little air
from the gutter and the frame besides.
The page named sortOrder and described only the default, which left a
reader to find the other two in the type. Documenting the status
shorthand honestly turned out to be the whole point.

'status' does not sort. It returns ['Success', 'Warning', 'Failed']
filtered by what the grid happens to hold, so it is a whitelist: any
value named otherwise keeps its colour on the cells and loses its legend
entry, which leaves it neither readable nor filterable on a grid whose
own guideline says two sections earlier that colour is the only channel.

That catches the absence value as well, and a status history always has
one, since collection has gaps. So the shorthand is not "for grids that
spell their values that way", it is for charts with exactly three
outcomes and no fourth. Heatmaps take the comparator.
Comment thread stories/Heatmap/heatmap.guideline.mdx Outdated
…ries it

The guideline had grown a paragraph where it wanted a list item: three
sort orders, one of them arguing its case at four times the length of the
other two. The page states what 'status' keeps and what it leaves out,
and stops there.

The argument belongs on the prop, where a caller meets it while choosing
rather than while reading a design page, so sortOrder's own doc now
carries the three forms and what the shorthand really does: a whitelist,
not a sort, and a value it drops keeps painting its cells — left
coloured, unexplained and impossible to filter.

One thing worth stating there that was not written anywhere: a
comparator is handed the colorSet keys, not the values behind them. With
labelMap in play it is easy to assume you are ordering what the legend
displays, when you are ordering what the cells hold.
@clement-scality

Copy link
Copy Markdown
Contributor Author

/approve

@bert-e

bert-e commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

In the queue

The changeset has received all authorizations and has been added to the
relevant queue(s). The queue(s) will be merged in the target development
branch(es) as soon as builds have passed.

The changeset will be merged in:

  • ✔️ development/1.0

There is no action required on your side. You will be notified here once
the changeset has been merged. In the unlikely event that the changeset
fails permanently on the queue, a member of the admin team will
contact you to help resolve the matter.

IMPORTANT

Please do not attempt to modify this pull request.

  • Any commit you add on the source branch will trigger a new cycle after the
    current queue is merged.
  • Any commit you add on one of the integration branches will be lost.

If you need this pull request to be removed from the queue, please contact a
member of the admin team now.

The following options are set: approve

@bert-e

bert-e commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

I have successfully merged the changeset of this pull request
into targetted development branches:

  • ✔️ development/1.0

Please check the status of the associated issue None.

Goodbye clement-scality.

@bert-e
bert-e merged commit 7ca2b92 into development/1.0 Sep 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants