import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import { useEffect, useState } from "react" import { toStoryChoice } from "../argTypes.storyHelpers.ts" import { Button } from "../Button/Button.tsx" import { Card } from "../Card/Card.tsx" import { Header } from "../Header/Header.tsx" import { Rail } from "../Rail/Rail.tsx" import { toMaxInlineSize } from "../Shell/contentWidth.ts" import { Shell } from "../Shell/Shell.tsx" import { Spinner } from "../Spinner/Spinner.tsx" import { ExternalLinkFooter, HeaderSchemeToggle, OverflowingContent, PageContent, ParkedDrawer, RailNavigation, ScrollFiller, } from "../shell.storyHelpers.tsx" import { Main } from "./Main.tsx" import { ScrollMemoryProvider } from "./ScrollMemoryProvider.tsx" const CONTENT_WIDTH_OPTIONS = [ "sm", "md", "lg", "xl", "2xl", "full", ] as const const meta = { title: "Components/Layout/Main", component: Main, parameters: { layout: "fullscreen" }, argTypes: { contentWidth: toStoryChoice(CONTENT_WIDTH_OPTIONS), }, args: {}, } satisfies Meta export default meta type Story = StoryObj /** * A page whose grid answers to **the column**, not the window. * * `Main` establishes a container query, so the tile grid below * goes one-up, two-up, three-up as the *content column* changes — * which is a different number from the window's the moment a rail * is open. Open the rail and the same window width gives a * narrower grid, which is the case a `md:` media query cannot * tell apart and the reason the fleet's poster grids look wrong * at intermediate widths. */ export const Playground: Story = { parameters: playgroundParameters, render: (mainProps) => (
} heading="Library" />
{[ "Adventure Time", "Cowboy Bebop", "Deep Space Nine", "Twin Peaks", "Utopia", "Yellowjackets", ].map((title) => (

4 seasons

))}
), } /** * The width scale, drawn — because the fleet's real answer to * "how wide is the content column" is eight different numbers. * * Each bar is the actual `max-inline-size` a `contentWidth` * resolves to, taken from `toMaxInlineSize` rather than restated, * so a change to the `screen.*` scale moves the drawing. * * There is one bar per step and **no second `
`**: `
` * is the page's one main landmark, so a board that rendered six * of them to compare them would be an accessibility violation * demonstrating an accessibility component. */ export const AllVariants: Story = { render: () => (

`screen.*` steps, plus the `full` escape hatch. `lg` is the default.

{CONTENT_WIDTH_OPTIONS.map((step) => (
{step}
))}
), } /** * The 390px fixture: everything that tries to make the **page** * scroll when only `
` should, in one column. * * Sideways, three ways. The path wraps because `Main` sets * `overflow-wrap: anywhere`; the table cannot wrap at all and * lives in a labelled, keyboard-reachable scroll container; and * the drawer parked at `translateX(110%)` is clipped. The drawer * is **inside** `Main` here, unlike `Shell.stories.tsx`, so the * element clipping it is `Main` — `position: relative` moved that * job in from the frame, and `overflow-x: hidden` is `Main` * taking it. * * Downwards, once, and it is the shape nobody looks for: the * footer link's `sr-only` "(opens in a new tab)" span is * `position: absolute`, so it resolves against the nearest * positioned ancestor. Before `Main` was one that was `Shell`, * outside the scrollport, and the span's static position past the * fold became scrollable overflow on the page. `ScrollFiller` is * what puts it past the fold; without the height there is nothing * to escape. * * The assertions are in `Main.test.tsx` at a real 390px viewport * — a story is a demo. */ export const Responsive: Story = { render: (mainProps) => (
{/* Twice. One is a few dozen pixels short of filling an 844px phone, and the footer below has to sit past the fold or there is nothing for its hidden span to escape to. */}
), } /** * Where the skip link lands. `
` carries `tabIndex={-1}` so * activating it moves **focus** and not merely the scroll * position — without that, Safari and Firefox scroll the page and * leave the next Tab back up in the header, which reads as the * skip link not working. */ export const Interactive: Story = { render: (mainProps) => (
} heading="Rip Deck" />
), } const EPISODE_TITLES = Array.from( { length: 40 }, (_unused, index) => `Episode ${index + 1}`, ) type HistoryEntry = { episode: string | null isExpanded: boolean key: string } const SPECIAL_TITLES = Array.from( { length: 8 }, (_unused, index) => `Special ${index + 1}`, ) const toPath = (entry: HistoryEntry) => entry.episode === null ? "/library" : `/library/${entry.episode}` /** * A history stack, small enough to read and honest about the two * things that matter. * * **Back returns to the entry it left, with the key it had.** That * key is what the offset is filed under, and reusing it is the * whole mechanism. * * **Expanding the specials is a NEW entry on the SAME path.** That * is what `setSearchParams` does for a filter chip or an expanded * group, and it is the case that has to leave the scrollport alone. * The key is one the memory has never seen, so a memory that read * "unseen ⇒ top" would throw the reader to the top of the list * every time they opened a group. * * The toggle sits in the **header**, not in the list, and that is * not decoration: a control inside the scrollport is scrolled into * view before it is pressed, which moves the very offset the demo * is about. It cost an hour of chasing a bug that was the driver's. * * `contentDelayMs` is the other half of the problem. A real list is * fetched, so the commit that changes the route draws an empty * scrollport and the rows land later — an offset applied against * that clamps to `0`. Set it and the restore has to wait for the * content, which is the case the `ResizeObserver` in * `useScrollMemory` exists for. */ const ScrollMemoryDemo = ({ contentDelayMs, }: { contentDelayMs: number }) => { const [entries, setEntries] = useState([ { episode: null, isExpanded: false, key: "entry-1" }, ]) const [entryIndex, setEntryIndex] = useState(0) const [isContentReady, setIsContentReady] = useState( contentDelayMs === 0, ) const entry = entries[entryIndex] useEffect(() => { if (contentDelayMs === 0) { return undefined } setIsContentReady(false) const timer = globalThis.setTimeout(() => { setIsContentReady(true) }, contentDelayMs) return () => { globalThis.clearTimeout(timer) } }, [contentDelayMs]) const push = (pushed: Omit) => { const stack = [ ...entries.slice(0, entryIndex + 1), { ...pushed, key: `entry-${entries.length + 1}` }, ] setEntries(stack) setEntryIndex(stack.length - 1) } const titles = entry?.isExpanded ? [...EPISODE_TITLES, ...SPECIAL_TITLES] : EPISODE_TITLES return (
} heading="Library" />
{!isContentReady && } {isContentReady && entry?.episode == null && (
{titles.map((title) => ( ))}
)} {isContentReady && entry?.episode != null && (

A page short enough that the scrollport collapses behind it. Press Back: the list returns to where it was, not to the top.

)}
) } /** * The reader's place, kept. * * `Shell` makes `
` the page's only vertical scrollport, and * a browser restores the **document** scroller and nothing else — * so without a `ScrollMemoryProvider` this list comes back at the * top every time, in every browser, with no setting that changes * it. * * Scroll the list, open an episode, then press Back. * * Then scroll again and press **Expand the specials**. That is a * new history entry on the same page, and the scrollport does not * move — a filter is not a navigation, however the router files * it. */ export const ScrollMemory: Story = { render: () => , } /** * The same thing, with the rows arriving 250ms late — which is * what a fetched list does, and what makes a naive * `scrollTop = offset` do nothing at all. * * Back lands on an empty scrollport with no room for the offset, * so the browser clamps it to `0`. The restore therefore re-applies * as the content grows and stops the moment the offset lands, or * the moment the reader scrolls. */ export const ScrollMemoryWithLateContent: Story = { render: () => , }