@charcuterie/ui 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +9 -0
  2. package/dist/Dialog/Dialog.d.ts.map +1 -1
  3. package/dist/Dialog/Dialog.js +3 -1
  4. package/dist/Dialog/Dialog.js.map +1 -1
  5. package/dist/Field/Field.d.ts.map +1 -1
  6. package/dist/Field/Field.js +1 -1
  7. package/dist/Field/Field.js.map +1 -1
  8. package/dist/Field/FieldGroup.d.ts.map +1 -1
  9. package/dist/Field/FieldGroup.js +1 -1
  10. package/dist/Field/FieldGroup.js.map +1 -1
  11. package/dist/Header/Header.d.ts +52 -0
  12. package/dist/Header/Header.d.ts.map +1 -0
  13. package/dist/Header/Header.js +55 -0
  14. package/dist/Header/Header.js.map +1 -0
  15. package/dist/Main/Main.d.ts +113 -0
  16. package/dist/Main/Main.d.ts.map +1 -0
  17. package/dist/Main/Main.js +123 -0
  18. package/dist/Main/Main.js.map +1 -0
  19. package/dist/Rail/Rail.d.ts +61 -0
  20. package/dist/Rail/Rail.d.ts.map +1 -0
  21. package/dist/Rail/Rail.js +64 -0
  22. package/dist/Rail/Rail.js.map +1 -0
  23. package/dist/Shell/Shell.d.ts +112 -0
  24. package/dist/Shell/Shell.d.ts.map +1 -0
  25. package/dist/Shell/Shell.js +116 -0
  26. package/dist/Shell/Shell.js.map +1 -0
  27. package/dist/Shell/contentWidth.d.ts +63 -0
  28. package/dist/Shell/contentWidth.d.ts.map +1 -0
  29. package/dist/Shell/contentWidth.js +74 -0
  30. package/dist/Shell/contentWidth.js.map +1 -0
  31. package/dist/Shell/shellContext.d.ts +36 -0
  32. package/dist/Shell/shellContext.d.ts.map +1 -0
  33. package/dist/Shell/shellContext.js +32 -0
  34. package/dist/Shell/shellContext.js.map +1 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  36. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  38. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  39. package/dist/Tooltip/Tooltip.js +5 -1
  40. package/dist/Tooltip/Tooltip.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  46. package/dist/mountStory.testHelpers.js +56 -0
  47. package/dist/mountStory.testHelpers.js.map +1 -1
  48. package/dist/viewport.testHelpers.d.ts +31 -0
  49. package/dist/viewport.testHelpers.d.ts.map +1 -0
  50. package/dist/viewport.testHelpers.js +34 -0
  51. package/dist/viewport.testHelpers.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/Dialog/Dialog.tsx +17 -1
  54. package/src/Field/Field.tsx +10 -2
  55. package/src/Field/FieldGroup.tsx +4 -2
  56. package/src/Header/Header.mdx +72 -0
  57. package/src/Header/Header.stories.tsx +221 -0
  58. package/src/Header/Header.test.tsx +122 -0
  59. package/src/Header/Header.tsx +118 -0
  60. package/src/Main/Main.mdx +149 -0
  61. package/src/Main/Main.stories.tsx +164 -0
  62. package/src/Main/Main.test.tsx +167 -0
  63. package/src/Main/Main.tsx +174 -0
  64. package/src/Rail/Rail.mdx +75 -0
  65. package/src/Rail/Rail.stories.tsx +177 -0
  66. package/src/Rail/Rail.test.tsx +150 -0
  67. package/src/Rail/Rail.tsx +102 -0
  68. package/src/Shell/Shell.mdx +186 -0
  69. package/src/Shell/Shell.stories.tsx +185 -0
  70. package/src/Shell/Shell.test.tsx +267 -0
  71. package/src/Shell/Shell.tsx +170 -0
  72. package/src/Shell/contentWidth.ts +89 -0
  73. package/src/Shell/shellContext.ts +41 -0
  74. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  75. package/src/Tooltip/Tooltip.tsx +5 -1
  76. package/src/index.ts +16 -0
  77. package/src/mountStory.testHelpers.ts +63 -0
  78. package/src/shell.storyHelpers.tsx +258 -0
  79. package/src/sourceRules.test.ts +17 -1
  80. package/src/viewport.testHelpers.ts +47 -0
@@ -0,0 +1,150 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, within } from "storybook/test"
3
+ import { afterAll, test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ PHONE,
11
+ setViewport,
12
+ } from "../viewport.testHelpers.ts"
13
+ import * as stories from "./Rail.stories.tsx"
14
+
15
+ const { AllVariants, Default, Interactive, Responsive } =
16
+ composeStories(stories)
17
+
18
+ afterAll(async () => {
19
+ await setViewport(DESKTOP)
20
+ })
21
+
22
+ test("landmark decides the element, and both are named", async () => {
23
+ const { canvas, canvasElement } =
24
+ await mountStory(AllVariants)
25
+
26
+ const navigation = expectAgentDrivable(canvas, {
27
+ name: "Sections",
28
+ role: "navigation",
29
+ })
30
+
31
+ await expect(navigation.tagName).toBe("NAV")
32
+
33
+ const complementary = expectAgentDrivable(canvas, {
34
+ name: "Job detail",
35
+ role: "complementary",
36
+ })
37
+
38
+ await expect(complementary.tagName).toBe("ASIDE")
39
+
40
+ await expectNoAxeViolations(canvasElement)
41
+ })
42
+
43
+ /**
44
+ * The collapse is a restyle, not a second render.
45
+ *
46
+ * Twelve links at 390px and twelve links at 1440px — the number
47
+ * that would be twenty-four in the `hidden`/`lg:hidden`
48
+ * implementation mux-magic and mail-sifter both ship, at every
49
+ * viewport, where an agent cannot tell the visible copy from the
50
+ * hidden one.
51
+ */
52
+ test("the same links exist once at both widths", async () => {
53
+ await setViewport(PHONE)
54
+
55
+ const phone = await mountStory(Responsive)
56
+
57
+ const phoneRail = expectAgentDrivable(phone.canvas, {
58
+ name: "Sections",
59
+ role: "navigation",
60
+ })
61
+
62
+ await expect(
63
+ within(phoneRail).getAllByRole("link"),
64
+ ).toHaveLength(12)
65
+
66
+ // A strip, not a column: the flex axis is what actually
67
+ // changed. Asserting the axis rather than a height is the
68
+ // lesson from `Alert`'s container-query test, where comparing
69
+ // heights passed with the responsive rule deleted.
70
+ await expect(
71
+ globalThis.getComputedStyle(phoneRail).flexDirection,
72
+ ).toBe("row")
73
+
74
+ await setViewport(DESKTOP)
75
+
76
+ const desktop = await mountStory(Responsive)
77
+
78
+ const desktopRail = expectAgentDrivable(desktop.canvas, {
79
+ name: "Sections",
80
+ role: "navigation",
81
+ })
82
+
83
+ await expect(
84
+ within(desktopRail).getAllByRole("link"),
85
+ ).toHaveLength(12)
86
+
87
+ await expect(
88
+ globalThis.getComputedStyle(desktopRail).flexDirection,
89
+ ).toBe("column")
90
+ })
91
+
92
+ /**
93
+ * Twelve sections on a phone is the case that makes a naive rail
94
+ * widen the page. The strip owns its overflow instead.
95
+ */
96
+ test("a rail too long for a phone scrolls itself, not the page", async () => {
97
+ await setViewport(PHONE)
98
+
99
+ const { canvas, canvasElement } =
100
+ await mountStory(Responsive)
101
+
102
+ const rail = expectAgentDrivable(canvas, {
103
+ name: "Sections",
104
+ role: "navigation",
105
+ })
106
+
107
+ // The fixture really does overflow — otherwise the assertion
108
+ // below is true for the wrong reason.
109
+ await expect(rail.scrollWidth).toBeGreaterThan(
110
+ rail.clientWidth,
111
+ )
112
+
113
+ const { documentElement } = document
114
+
115
+ await expect(
116
+ documentElement.scrollWidth,
117
+ ).toBeLessThanOrEqual(documentElement.clientWidth)
118
+
119
+ // A scrolling region has to be reachable without a mouse. It is
120
+ // here because it is a landmark full of links, which is what
121
+ // axe's `scrollable-region-focusable` accepts — a rail of plain
122
+ // text would not be, and that is the honest limit of the
123
+ // pattern.
124
+ await expectNoAxeViolations(canvasElement)
125
+ })
126
+
127
+ test("the rail scopes its own links away from the header's", async () => {
128
+ await setViewport(DESKTOP)
129
+
130
+ const { canvas, canvasElement } =
131
+ await mountStory(Interactive)
132
+
133
+ const rail = expectAgentDrivable(canvas, {
134
+ name: "Sections",
135
+ role: "navigation",
136
+ })
137
+
138
+ expectAgentDrivable(within(rail), {
139
+ name: "Settings",
140
+ role: "link",
141
+ })
142
+
143
+ await expectNoAxeViolations(canvasElement)
144
+ })
145
+
146
+ test("a single start rail is clean under axe", async () => {
147
+ const { canvasElement } = await mountStory(Default)
148
+
149
+ await expectNoAxeViolations(canvasElement)
150
+ })
@@ -0,0 +1,102 @@
1
+ import type {
2
+ ComponentPropsWithRef,
3
+ ReactNode,
4
+ } from "react"
5
+
6
+ import { toClassName } from "../toClassName.ts"
7
+
8
+ export type RailLandmark = "complementary" | "navigation"
9
+
10
+ export type RailSide = "end" | "start"
11
+
12
+ export type RailProps = ComponentPropsWithRef<"aside"> & {
13
+ label: string
14
+ landmark?: RailLandmark
15
+ side?: RailSide
16
+ }
17
+
18
+ const LANDMARK_ELEMENT: Record<
19
+ RailLandmark,
20
+ "aside" | "nav"
21
+ > = {
22
+ complementary: "aside",
23
+ navigation: "nav",
24
+ }
25
+
26
+ /**
27
+ * A side rail — the nav column, the filter panel, the drive list.
28
+ *
29
+ * `side` places it, `landmark` decides what it *is*, and `label`
30
+ * is required for a reason: two unnamed rails in one page are two
31
+ * landmarks of the same type with no way to tell them apart, which
32
+ * is axe's `landmark-unique` and, more to the point, is a screen
33
+ * reader announcing "complementary" twice. It is also what makes
34
+ * the rail scopeable — `getByRole("navigation", { name: "Sections" })`
35
+ * disambiguates a "Settings" link that also exists in the header.
36
+ *
37
+ * ## It collapses without duplicating itself
38
+ *
39
+ * Below `md` the shell is one column and the rail becomes a
40
+ * horizontally-scrolling strip above (`side="start"`) or below
41
+ * (`side="end"`) the content. Above it, the rail is a fixed-width
42
+ * column in the grid.
43
+ *
44
+ * **The same element, restyled — not a second copy.** The fleet's
45
+ * habit is to render the whole thing twice behind `hidden` and
46
+ * `lg:hidden`: mux-magic's `PageHeader` does it across two ~55-line
47
+ * blocks and mail-sifter's `TriageQueue` does it again. The cost is
48
+ * not bytes, it is that every control exists **twice in the DOM at
49
+ * every viewport** — so tests must be defensively scoped, an agent
50
+ * driving the page finds two of each button and cannot tell which
51
+ * is the visible one, and the two copies drift.
52
+ *
53
+ * A media query gets the whole job done here because only the
54
+ * rail's *position* changes, never its contents. plex-channels'
55
+ * `App.tsx` shows the pattern for when that is not true — a
56
+ * `useMediaQuery` over `matchMedia` through `useSyncExternalStore`,
57
+ * mounting exactly one instance and relocating it — and it is
58
+ * strictly more machinery for the same guarantee, so it is not what
59
+ * this component uses.
60
+ *
61
+ * The `md` breakpoint is 48rem, which is `screen.md`: Tailwind v4's
62
+ * default `--breakpoint-*` scale and this repo's `screen.*` scale
63
+ * carry the same five values, so `md:` and the token agree by
64
+ * construction rather than by coincidence maintained by hand.
65
+ *
66
+ * ## The narrow strip scrolls itself
67
+ *
68
+ * `overflow-x-auto` on the rail is what keeps a twelve-item nav
69
+ * from widening the *page* at 390px: the overflow is contained in
70
+ * an element that scrolls, which is the sanctioned answer for
71
+ * content that genuinely cannot wrap. `min-w-0` is on it for the
72
+ * same reason `Shell`'s middle track is `minmax(0, 1fr)` — a flex
73
+ * or grid child's automatic minimum is its content, and one long
74
+ * unbroken string otherwise pushes the whole layout out of the
75
+ * viewport.
76
+ */
77
+ export const Rail = ({
78
+ children,
79
+ className,
80
+ label,
81
+ landmark = "complementary",
82
+ side = "start",
83
+ ...railProps
84
+ }: RailProps): ReactNode => {
85
+ const Element = LANDMARK_ELEMENT[landmark]
86
+
87
+ return (
88
+ <Element
89
+ {...railProps}
90
+ aria-label={label}
91
+ className={toClassName(
92
+ "flex min-w-0 gap-2 overflow-x-auto border-border-subtle p-4 md:row-start-2 md:w-64 md:flex-col md:overflow-x-visible",
93
+ side === "start"
94
+ ? "row-start-2 border-b md:col-start-1 md:border-b-0 md:border-e"
95
+ : "row-start-4 border-t md:col-start-3 md:border-t-0 md:border-s",
96
+ className,
97
+ )}
98
+ >
99
+ {children}
100
+ </Element>
101
+ )
102
+ }
@@ -0,0 +1,186 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ShellStories from "./Shell.stories";
4
+
5
+ <Meta of={ShellStories} />
6
+
7
+ # Shell
8
+
9
+ **The fleet's largest duplicated surface.** Ten of twelve UI repos hand-roll the page
10
+ chrome, and three of them have a file literally named `AppShell.tsx` — mail-sifter's and
11
+ points-market's headers are a *byte-identical* string, arrived at independently:
12
+
13
+ ```tsx
14
+ <header className="sticky top-0 z-40 border-b border-border-subtle bg-surface-base/90 backdrop-blur-md">
15
+ ```
16
+
17
+ board-games' is the same structure with `sticky` dropped. The other seven spell the same
18
+ idea as `PageChrome`, `Header`, `DashboardHeader`, `Layout` (twice), `TitleBar`, and
19
+ inline in `App.tsx`.
20
+
21
+ The owner's ask, verbatim:
22
+
23
+ > "We need a header component. Apps are all doing it differently, and there's no
24
+ > standardized way to do a sticky header + narrow view... A Unified App Shell basically."
25
+
26
+ <Canvas of={ShellStories.Default} />
27
+
28
+ ## Props
29
+
30
+ <Controls />
31
+
32
+ ## The three templates
33
+
34
+ The controls differ per app. The structure does not — so these are meant to be copied
35
+ whole, and the only edits are what goes in `actions`, in the rails, and in `Main`.
36
+
37
+ ### 1. Header + main
38
+
39
+ Eight of the fleet's ten hand-rolled shells are this shape, and most apps need nothing
40
+ else.
41
+
42
+ ```tsx
43
+ <Shell>
44
+ <Header actions={<ColorSchemeSwitcher icons={ICONS} />} heading="Rip Deck" />
45
+
46
+ <Main>
47
+ <PageContent />
48
+ </Main>
49
+ </Shell>
50
+ ```
51
+
52
+ ### 2. Header + a start rail
53
+
54
+ <Canvas of={ShellStories.WithStartRail} />
55
+
56
+ ```tsx
57
+ <Shell>
58
+ <Header actions={<ColorSchemeSwitcher icons={ICONS} />} heading="Comic Ingest" />
59
+
60
+ <Rail label="Sections" landmark="navigation">
61
+ <NavLinks />
62
+ </Rail>
63
+
64
+ <Main>
65
+ <PageContent />
66
+ </Main>
67
+ </Shell>
68
+ ```
69
+
70
+ `label` is required, and `landmark="navigation"` makes it a `<nav>`. Both matter the
71
+ moment there are two rails.
72
+
73
+ ### 3. Header + both rails
74
+
75
+ <Canvas of={ShellStories.WithBothRails} />
76
+
77
+ ```tsx
78
+ <Shell>
79
+ <Header actions={<ColorSchemeSwitcher icons={ICONS} />} heading="Rip Deck" />
80
+
81
+ <Rail label="Sections" landmark="navigation">
82
+ <NavLinks />
83
+ </Rail>
84
+
85
+ <Main>
86
+ <PageContent />
87
+ </Main>
88
+
89
+ <Rail label="Job detail" side="end">
90
+ <JobDetail />
91
+ </Rail>
92
+ </Shell>
93
+ ```
94
+
95
+ Order the children the way they should be **read**: header, start rail, main, end rail.
96
+ Below `md` the grid is one column and that DOM order is the reading order; above it, the
97
+ `side` prop places each rail in its own track.
98
+
99
+ ## One width, not two
100
+
101
+ `contentWidth` is stated once, on `Shell`, and `Header` and `Main` both read it. That is a
102
+ bug fix, not tidiness: **points-market's header is capped at 80rem and its `<main>` is not
103
+ capped at all**, so on a wide monitor the page title sits above content that starts to its
104
+ left and runs past its right. Nothing errors; the two values were simply written on
105
+ different days.
106
+
107
+ Either component still takes its own `contentWidth` for a real reason — a full-bleed hero
108
+ under a constrained header — but that now takes a prop somebody wrote on purpose.
109
+
110
+ The scale and how it composes with a runtime-computed cap are on the
111
+ [`Main`](?path=/docs/components-main--docs) page.
112
+
113
+ ## The rails collapse without duplicating
114
+
115
+ Below `md` the shell is a single column and each `Rail` becomes a horizontally-scrolling
116
+ strip. **The same element, restyled — never a second copy.** mux-magic's `PageHeader`
117
+ renders its entire control set twice (lines 290–345 and 356–410) behind `hidden` /
118
+ `lg:hidden`, and mail-sifter's `TriageQueue` does it again. The cost is not bytes: every
119
+ action exists twice in the DOM at *every* viewport, so tests must be defensively scoped
120
+ with `within(toolbar)`, an agent driving the page finds two of each button with no way to
121
+ tell which is visible, and the two copies drift.
122
+
123
+ plex-channels' `App.tsx` shows the pattern for when the contents genuinely have to differ
124
+ — a `useMediaQuery` over `matchMedia` through `useSyncExternalStore`, mounting exactly one
125
+ instance and relocating it. It is strictly more machinery for the same guarantee, so a
126
+ rail that only changes *position* uses a media query and nothing else.
127
+
128
+ <Canvas of={ShellStories.Responsive} />
129
+
130
+ ## It does not scroll sideways, and that is a test
131
+
132
+ Plex Channels' narrow view scrolls left and right as wide as its content area. The usual
133
+ causes are all shell faults: a grid track that ignores `min-width: 0`, a `100vw` that
134
+ counts the scrollbar gutter, or a sticky header whose inner container is wider than the
135
+ viewport.
136
+
137
+ There are **three** shapes of it, and each needs a different answer:
138
+
139
+ | Shape | Answer |
140
+ | --- | --- |
141
+ | A grid track sized to `min-content` | `minmax(0, 1fr)` on the middle track |
142
+ | Text ink from a long unbroken token | `wrap-anywhere` on `Main`'s column |
143
+ | A box parked off-screen by a transform | `position: relative` + `overflow-x: clip` here |
144
+
145
+ The third is the one that broke plex-channels: a closed panel at
146
+ `transform: translateX(110%)`. **A transform does not remove a box from the document's
147
+ scrollable overflow region**, and neither does `visibility: hidden` — so a drawer nobody can
148
+ see still makes the page scroll to it.
149
+
150
+ `clip`, not `hidden`: `overflow: hidden` creates a scroll container, which would become the
151
+ sticky containing block for `Header` and freeze it. `clip` creates no scrollport, and it is
152
+ the one value that may pair with `overflow-y: visible`.
153
+
154
+ The `relative` is not decoration. `overflow-x: clip` only clips descendants whose containing
155
+ block chain runs through the clipper, and an absolutely positioned box with no positioned
156
+ ancestor resolves against the *initial* containing block instead — sailing past every
157
+ `overflow` in the tree. Measured, before `Shell` took `relative`: `shellScroll: 390`,
158
+ `bodyScroll: 390`, **`docScroll: 742`** at a 390px viewport. The obvious candidate to own it
159
+ — `Main`'s content column — does not, because `container-type: inline-size` computes
160
+ `contain` to `none` in Chromium and establishes no containing block for absolute
161
+ positioning; the drawer's `offsetParent` was `BODY`.
162
+
163
+ `Shell.test.tsx` and `Main.test.tsx` assert
164
+ `document.documentElement.scrollWidth <= clientWidth` at a real 390px viewport against a
165
+ story carrying all three shapes at once, with each fixture separately asserted to actually
166
+ overflow. **`documentElement`, not `body`** — the parked-drawer case leaves
167
+ `document.body.scrollWidth` reading a clean 390 while the page scrolls, and it leaves no
168
+ overflowing element box for a `getBoundingClientRect()` assertion to catch either.
169
+
170
+ There is no grid `gap`: an absent rail is a zero-width `auto` track, but a gap beside it is
171
+ not, so a shell with no rails would carry two mystery gutters. `Rail` and `Main` bring
172
+ their own padding.
173
+
174
+ ## The skip link
175
+
176
+ <Canvas of={ShellStories.Interactive} />
177
+
178
+ All ten hand-rolled shells are missing one, so on every one of those apps a keyboard user
179
+ starts each page by tabbing through the header. `Shell` renders it, points it at the
180
+ `<main>` it generated the id for, and `Main` carries `tabIndex={-1}` so activating it moves
181
+ **focus** rather than only the scroll position — an `href` at a non-focusable target leaves
182
+ the next Tab back in the header, which looks like a working skip link and is not.
183
+
184
+ It is hidden by a transform rather than by `sr-only`, because `focus-visible:not-sr-only`
185
+ and `focus-visible:fixed` both write `position` and which one wins is decided by Tailwind's
186
+ internal property order rather than by the order they are written in.
@@ -0,0 +1,185 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { toStoryChoice } from "../argTypes.storyHelpers.ts"
4
+ import { Header } from "../Header/Header.tsx"
5
+ import { Main } from "../Main/Main.tsx"
6
+ import { Rail } from "../Rail/Rail.tsx"
7
+ import {
8
+ HeaderSchemeToggle,
9
+ OverflowingContent,
10
+ PageContent,
11
+ RailDetail,
12
+ RailNavigation,
13
+ } from "../shell.storyHelpers.tsx"
14
+ import { Shell } from "./Shell.tsx"
15
+
16
+ /**
17
+ * `full` last, because it is the escape hatch rather than a step:
18
+ * everything before it is a `screen.*` token, and an app reaching
19
+ * for it is saying "this page is a wall of tiles", not picking a
20
+ * slightly larger number.
21
+ */
22
+ const CONTENT_WIDTH_OPTIONS = [
23
+ "sm",
24
+ "md",
25
+ "lg",
26
+ "xl",
27
+ "2xl",
28
+ "full",
29
+ ] as const
30
+
31
+ const meta = {
32
+ title: "Components/Shell",
33
+ component: Shell,
34
+ parameters: { layout: "fullscreen" },
35
+ argTypes: {
36
+ contentWidth: toStoryChoice(CONTENT_WIDTH_OPTIONS),
37
+ },
38
+ args: {
39
+ contentWidth: "lg",
40
+ skipLinkLabel: "Skip to main content",
41
+ },
42
+ } satisfies Meta<typeof Shell>
43
+
44
+ export default meta
45
+
46
+ type Story = StoryObj<typeof meta>
47
+
48
+ /**
49
+ * **Template 1 — header + main.** The shape eight of the fleet's
50
+ * ten hand-rolled shells are, and the whole of what most apps
51
+ * need. Copy it verbatim; only the contents of `actions` and
52
+ * `Main` differ per app.
53
+ */
54
+ export const Default: Story = {
55
+ render: (shellProps) => (
56
+ <Shell {...shellProps}>
57
+ <Header
58
+ actions={<HeaderSchemeToggle />}
59
+ heading="Rip Deck"
60
+ />
61
+
62
+ <Main>
63
+ <PageContent />
64
+ </Main>
65
+ </Shell>
66
+ ),
67
+ }
68
+
69
+ /**
70
+ * **Template 2 — header + a start rail.** The rail is the app's
71
+ * sections, so it is `landmark="navigation"` and gets a name an
72
+ * agent can scope to. Below `md` it becomes the strip above the
73
+ * content — the same element, not a second copy.
74
+ */
75
+ export const WithStartRail: Story = {
76
+ render: (shellProps) => (
77
+ <Shell {...shellProps}>
78
+ <Header
79
+ actions={<HeaderSchemeToggle />}
80
+ heading="Comic Ingest"
81
+ />
82
+
83
+ <Rail label="Sections" landmark="navigation">
84
+ <RailNavigation />
85
+ </Rail>
86
+
87
+ <Main>
88
+ <PageContent />
89
+ </Main>
90
+ </Shell>
91
+ ),
92
+ }
93
+
94
+ /**
95
+ * **Template 3 — header + both rails.** Sections on one side,
96
+ * the selected thing's detail on the other. Two rails is where
97
+ * naming stops being optional: two unnamed landmarks of the same
98
+ * type are indistinguishable to a screen reader and to an agent.
99
+ */
100
+ export const WithBothRails: Story = {
101
+ render: (shellProps) => (
102
+ <Shell {...shellProps}>
103
+ <Header
104
+ actions={<HeaderSchemeToggle />}
105
+ heading="Rip Deck"
106
+ />
107
+
108
+ <Rail label="Sections" landmark="navigation">
109
+ <RailNavigation />
110
+ </Rail>
111
+
112
+ <Main>
113
+ <PageContent />
114
+ </Main>
115
+
116
+ <Rail label="Job detail" side="end">
117
+ <RailDetail />
118
+ </Rail>
119
+ </Shell>
120
+ ),
121
+ }
122
+
123
+ /**
124
+ * The one the tests point a 390px viewport at.
125
+ *
126
+ * Both rails, plus the two shapes of content that make a page
127
+ * scroll sideways: an unbroken 130-character path, and a table
128
+ * wider than the phone it is on. Plex Channels' narrow view
129
+ * scrolls left and right today for the first reason; the second
130
+ * is what an `overflow-wrap` alone cannot save.
131
+ *
132
+ * `document.documentElement.scrollWidth <= clientWidth` at 390px
133
+ * is the assertion, and it is in `Shell.test.tsx` rather than
134
+ * here — a story is a demo.
135
+ */
136
+ export const Responsive: Story = {
137
+ render: (shellProps) => (
138
+ <Shell {...shellProps}>
139
+ <Header
140
+ actions={<HeaderSchemeToggle />}
141
+ heading="Transfers"
142
+ />
143
+
144
+ <Rail label="Sections" landmark="navigation">
145
+ <RailNavigation />
146
+ </Rail>
147
+
148
+ <Main>
149
+ <OverflowingContent />
150
+ </Main>
151
+
152
+ <Rail label="Job detail" side="end">
153
+ <RailDetail />
154
+ </Rail>
155
+ </Shell>
156
+ ),
157
+ }
158
+
159
+ /**
160
+ * What a keyboard user meets first. Tab once: the skip link
161
+ * appears at the top of the page and lands focus **inside**
162
+ * `<main>`, past the header and the rail.
163
+ *
164
+ * None of the fleet's ten hand-rolled shells has one, so on every
165
+ * one of those apps the first Tab starts a walk through the
166
+ * header — every page, every navigation.
167
+ */
168
+ export const Interactive: Story = {
169
+ render: (shellProps) => (
170
+ <Shell {...shellProps}>
171
+ <Header
172
+ actions={<HeaderSchemeToggle />}
173
+ heading="Rip Deck"
174
+ />
175
+
176
+ <Rail label="Sections" landmark="navigation">
177
+ <RailNavigation />
178
+ </Rail>
179
+
180
+ <Main>
181
+ <PageContent />
182
+ </Main>
183
+ </Shell>
184
+ ),
185
+ }