import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as VirtualizedGridStories from "./VirtualizedGrid.stories";

<Meta of={VirtualizedGridStories} />

# VirtualizedGrid

**`AdaptiveGrid`, with only the rows you can see mounted.** Same column rule, same
two-box structure, same content cap. The one difference is that a list of 2,000 costs
about as much as a list of 40.

<Canvas of={VirtualizedGridStories.Playground} />

## Props

<Controls />

## When to reach for it

Not by default. `AdaptiveGrid` renders everything it is handed, and that is the right
behaviour: the DOM is searchable with `Ctrl+F`, `Ctrl+End` goes where you expect, and
nothing has to be measured. Reach for this one when the item count is genuinely
unbounded — a library listing, an inbox, a backlog that grows.

The number that prompted it, measured on QueuePilot's Pending page:

| | Every item rendered |
| --- | --- |
| Items | 2,162 |
| DOM nodes | 19,933 |
| `<img>` elements | 2,162 |
| `<button>` elements | 4,371 |
| Time to settle | 7.8 s |
| Scroll rate | ~43 fps |

Nothing about that page was wrong except how much of it existed at once.

## Rows are the unit

The virtualizer counts **rows**, not items. An item-level virtualizer would have to place
every item on both axes; a row-level one gets the inline axis from CSS grid for free and
only ever answers "how far down".

So `itemBlockSize` is a row-height estimate, and unlike `AdaptiveGrid`'s it is not a
commitment — every mounted row is measured and the estimate corrected. It still wants to
be close, because it is what the scrollbar is sized from before anything has been
measured. A bad guess makes the bar jump under the thumb on the first scroll.

## It follows the page's scroll region

The grid never creates a scroll box. It follows the nearest ancestor whose vertical
overflow is `auto` or `scroll`. Inside `Shell`, that is `Main`, so the header and rail stay
available while the grid scrolls. A standalone grid falls back to the browser window.

The cost is that the grid has to know how far down its scroll region it starts. It measures
that offset in a layout effect and feeds it to the virtualizer as `scrollMargin`. Get that
wrong and every row is offset by the chrome above it.

<Canvas of={VirtualizedGridStories.InShellScrollRegion} />

## It waits for a box before it measures anything

`display: none` gives an element no boxes, and the `hidden` attribute is `display: none`.
So a grid inside a hidden tab panel, a collapsed disclosure or a closed `<details>` cannot
be measured at all: its container reads 0px wide and every mounted row reads 0px tall.

That is the absence of a measurement, not a measurement of zero, and the difference is not
academic. Revealing the panel corrects every one of those numbers at once, and a
virtualizer is entitled to read a correction as a **resize** — the one event it may move
the scroll position for. It then compensates for growth that never happened and scrolls the
page. Docket's Triage bar is where this was found: switching tabs moved `Main` by up to
1,190px and took the tab bar off the top of the screen.

So the grid mounts nothing until its container has a box. Put a grid in a tab panel and the
panel holds no rows while it is put away; the rows arrive with the box and are measured once,
correctly. The cost is that a revealed grid re-measures from its estimate rather than
resuming a cache, which is exactly what a routed tab already does.

<Canvas of={VirtualizedGridStories.InHiddenTabPanel} />


## The column rule is inherited, not reimplemented

<Canvas of={VirtualizedGridStories.AllVariants} />

Columns come from `useAdaptiveColumns` — the identical hook `AdaptiveGrid` calls. The
consequence that reads backwards holds here too: a **taller** window takes **fewer**
columns, because height is what a column is bought with.

This is deliberate. A second column heuristic that windowed lists followed and flow lists
did not would be a fleet where the same content lays out two ways depending on how many
of it there are.

## The states that are easy to miss

<Canvas of={VirtualizedGridStories.AllStates} />

An empty list draws a zero-height box and no rows — a windowed grid must not reserve
space for a list that is not there, and it is the state a filter lands on. One item is
one column, the same answer `AdaptiveGrid` gives.

## It is still read from the container

<Canvas of={VirtualizedGridStories.Responsive} />

The browser never moves and the column count still falls. Inline size comes off a
`ResizeObserver` on the container, so a grid beside a rail gets the room it actually has
rather than the room the window claims.

## Accessibility

A mounted window is a lie about length, so the true length is stated rather than implied:

- `role="list"` on the grid, with the `label` prop as its accessible name.
- `aria-setsize` on every cell, carrying the **full** item count.
- `aria-posinset` naming each cell's real position in that full list.
- `role="presentation"` on the row wrappers — they are a positioning device and have no
  business appearing in the structure.

Without `aria-setsize` a screen reader announces "list, 40 items" on a list of 2,000.

Two things windowing genuinely costs, and neither has a fix at this layer: the browser's
`Ctrl+F` only searches what is mounted, and so does "select all". A page that needs
either owes its users a search field of its own.

## `getItemKey`

Pass one. Without it the key is the array index, which is wrong the moment the list is
filtered or an item is removed from the middle — React reuses the mounted subtree and the
wrong tile inherits the wrong open menu. That is not hypothetical; it is what a
dismiss-an-item list does on every interaction.
