import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import { useState } from "react" import { getColumnChoices } from "../AdaptiveGrid/chooseColumns.ts" import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts" import { Button } from "../Button/Button.tsx" import { StorySection } from "../board.storyHelpers.tsx" import { Card } from "../Card/Card.tsx" import { Header } from "../Header/Header.tsx" import { Main } from "../Main/Main.tsx" import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx" import { Shell } from "../Shell/Shell.tsx" import { Tabs } from "../Tabs/Tabs.tsx" import { VirtualizedGrid } from "./VirtualizedGrid.tsx" /** * A fixed block size, so a story shows the rule rather than the * height of whichever frame Storybook happened to render it in. * The same seam `AdaptiveGrid`'s stories inject, and for the same * reason: in a real chromium `innerHeight` is a read-only accessor * and assigning to it is a silent no-op. */ const fixedBlockSize = ( blockSize: number, ): BlockSizeResolver => ({ get: () => blockSize, subscribe: () => () => {}, }) const COLUMN_ITEMS = getColumnChoices({ maxColumns: 4, }).map((choice) => ({ label: String(choice), value: String(choice), })) type Bay = { id: string label: string } /** * Two thousand of them, which is the point. * * The number is not decoration: it is roughly what QueuePilot's * Pending page holds, and it is the size at which rendering every * item stops being viable. A story with twelve items would mount * the same component and prove nothing about it. */ const MANY_ITEMS: Bay[] = Array.from( { length: 2000 }, (_, index) => ({ id: `bay-${index + 1}`, label: `Bay ${index + 1}`, }), ) const Item = ({ label }: { label: string }) => (

{label}

{/* * An unbroken string with no wrap opportunity — the thing a * grid item's `min-content` minimum turns into a horizontal * scrollbar. `truncate` clips it; `[&>*]:min-w-0` on the row * is what lets the track get small enough for the clipping to * matter. */}

/mnt/Bunnies/Family/Media/very/long/unbroken/path/that/would/push/a/track/wide

) const meta = { title: "Components/Layout/VirtualizedGrid", component: VirtualizedGrid, parameters: { layout: "padded" }, args: { blockSizeResolver: fixedBlockSize(1080), chromeBlockSize: 260, getItemKey: (item: Bay) => item.id, itemBlockSize: 150, items: MANY_ITEMS, label: "Bays", renderItem: (item: Bay) => , }, argTypes: { blockSizeResolver: { control: false }, getItemKey: { control: false }, items: { control: false }, renderItem: { control: false }, }, } satisfies Meta> export default meta type Story = StoryObj /** * Two thousand items, of which a few dozen exist. * * The scrollbar is sized for all 2,000 — `getTotalSize()` is the * full list — so the page behaves as though every item were there. * Inspect the DOM and count: the row elements are the ones on * screen plus `overscanRows` above and below. */ export const Playground: Story = { parameters: playgroundParameters, } /** * The app-shell contract: `Main` is the one vertical scroll region, * and the grid follows it rather than adding document height. * * This is QueuePilot's Pending shape. It is a separate story because * a standalone grid still follows the browser window and both paths * need a real consumer. */ export const InShellScrollRegion: Story = { parameters: { layout: "fullscreen" }, render: (gridProps) => (
), } /** * Two grids in a tab bar, one of them hidden — the shape that found * the scroll bug. * * A `hidden` panel is `display: none`, so the grid inside it has no * box: its container measures 0px wide (one column) and every * mounted row measures 0px tall. Revealing the panel corrects all * of that at once, and a virtualizer is entitled to read a * correction as a resize and compensate the scroll position for it. * * Docket's Triage bar is where it showed: clicking a tab scrolled * `Main` down 266px and put the tab bar off the top of the screen. * So the claim this story exists to hold is one number — **switching * tabs leaves `Main.scrollTop` where it was.** * * The chrome above the grid is not decoration either. The false * adjustment is roughly the distance from the top of the scroll * region to the top of the grid, so a story with the grid flush * against the top of `Main` would have had nothing to measure and * would have passed while the bug was still there. */ export const InHiddenTabPanel: Story = { parameters: { layout: "fullscreen" }, render: (gridProps) => (

Switching tabs must not move the page.

), key: "loaded", label: "Loaded", }, { content: ( ), key: "reserved", label: "Reserved", }, ]} />
), } /** * The same 2,000 items at three window heights. * * Inherited from `AdaptiveGrid` rather than reimplemented, so the * rule that reads backwards holds here too: a **taller** window * takes **fewer** columns, because height is what a column is * bought with. Windowing changes how many rows are mounted, never * how many columns are drawn. */ export const AllVariants: Story = { render: (gridProps) => ( {[ { blockSize: 1440, label: "1440px tall", }, { blockSize: 900, label: "900px tall" }, { blockSize: 600, label: "600px tall" }, ].map((panel) => (
{panel.label}
))}
), } /** * Empty, one item, and two thousand. * * The empty case 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, and the case that would otherwise * stretch a lone card across an ultrawide. */ export const AllStates: Story = { render: (gridProps) => (
{[ { items: [], label: "no items" }, { items: MANY_ITEMS.slice(0, 1), label: "one item", }, { items: MANY_ITEMS, label: "2,000 items" }, ].map((panel) => (
{panel.label}
))}
), } /** * Three container widths, with the column floor dropped to 160px. * * The browser never moves and the column count still falls, which * is the honest demonstration that the inline size comes off a * `ResizeObserver` on the container rather than off the window. */ export const Responsive: Story = { args: { blockSizeResolver: fixedBlockSize(600), minColumnInlineSize: 160, // Every row mounted. This story is about the column count, not // the window, and the VRT shot of the whole root is taller than // the viewport: the grids re-window while it is taken, so which // rows were drawn depended on the runner's timing (release PR // #319 drew 13 rows where the baseline drew 5). overscanRows: 60, }, render: (gridProps) => (
{[ { inlineSize: "15rem", label: "15rem container" }, { inlineSize: "24rem", label: "24rem container" }, { inlineSize: "34rem", label: "34rem container" }, ].map((panel) => (
{panel.label}
))}
), } /** * The manual override beside the automatic answer, on a windowed * list. * * Changing the count re-rows 2,000 items — the row count is * `ceil(items / columns)` — so this is also the case that proves * the virtualizer is re-measured rather than left holding * positions for a layout that no longer exists. */ export const Interactive: Story = { render: (gridProps) => { const [choice, setChoice] = useState("auto") return ( { setChoice(value ?? "auto") }} selectedValue={choice} /> ) }, }