@charcuterie/ui 2.9.0 → 2.10.1
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.
- package/README.md +9 -0
- package/dist/Header/Header.d.ts +52 -0
- package/dist/Header/Header.d.ts.map +1 -0
- package/dist/Header/Header.js +55 -0
- package/dist/Header/Header.js.map +1 -0
- package/dist/Main/Main.d.ts +113 -0
- package/dist/Main/Main.d.ts.map +1 -0
- package/dist/Main/Main.js +123 -0
- package/dist/Main/Main.js.map +1 -0
- package/dist/Rail/Rail.d.ts +61 -0
- package/dist/Rail/Rail.d.ts.map +1 -0
- package/dist/Rail/Rail.js +64 -0
- package/dist/Rail/Rail.js.map +1 -0
- package/dist/Shell/Shell.d.ts +112 -0
- package/dist/Shell/Shell.d.ts.map +1 -0
- package/dist/Shell/Shell.js +116 -0
- package/dist/Shell/Shell.js.map +1 -0
- package/dist/Shell/contentWidth.d.ts +63 -0
- package/dist/Shell/contentWidth.d.ts.map +1 -0
- package/dist/Shell/contentWidth.js +74 -0
- package/dist/Shell/contentWidth.js.map +1 -0
- package/dist/Shell/shellContext.d.ts +36 -0
- package/dist/Shell/shellContext.d.ts.map +1 -0
- package/dist/Shell/shellContext.js +32 -0
- package/dist/Shell/shellContext.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/viewport.testHelpers.d.ts +31 -0
- package/dist/viewport.testHelpers.d.ts.map +1 -0
- package/dist/viewport.testHelpers.js +34 -0
- package/dist/viewport.testHelpers.js.map +1 -0
- package/package.json +2 -2
- package/src/Header/Header.mdx +72 -0
- package/src/Header/Header.stories.tsx +221 -0
- package/src/Header/Header.test.tsx +122 -0
- package/src/Header/Header.tsx +118 -0
- package/src/Main/Main.mdx +149 -0
- package/src/Main/Main.stories.tsx +164 -0
- package/src/Main/Main.test.tsx +167 -0
- package/src/Main/Main.tsx +174 -0
- package/src/Rail/Rail.mdx +75 -0
- package/src/Rail/Rail.stories.tsx +177 -0
- package/src/Rail/Rail.test.tsx +150 -0
- package/src/Rail/Rail.tsx +102 -0
- package/src/Shell/Shell.mdx +186 -0
- package/src/Shell/Shell.stories.tsx +185 -0
- package/src/Shell/Shell.test.tsx +267 -0
- package/src/Shell/Shell.tsx +170 -0
- package/src/Shell/contentWidth.ts +89 -0
- package/src/Shell/shellContext.ts +41 -0
- package/src/index.ts +16 -0
- package/src/shell.storyHelpers.tsx +258 -0
- package/src/sourceRules.test.ts +17 -1
- package/src/viewport.testHelpers.ts +47 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import { Card } from "../Card/Card.tsx"
|
|
5
|
+
import { Header } from "../Header/Header.tsx"
|
|
6
|
+
import { Rail } from "../Rail/Rail.tsx"
|
|
7
|
+
import { toMaxInlineSize } from "../Shell/contentWidth.ts"
|
|
8
|
+
import { Shell } from "../Shell/Shell.tsx"
|
|
9
|
+
import {
|
|
10
|
+
HeaderSchemeToggle,
|
|
11
|
+
OverflowingContent,
|
|
12
|
+
PageContent,
|
|
13
|
+
RailNavigation,
|
|
14
|
+
} from "../shell.storyHelpers.tsx"
|
|
15
|
+
import { Main } from "./Main.tsx"
|
|
16
|
+
|
|
17
|
+
const CONTENT_WIDTH_OPTIONS = [
|
|
18
|
+
"sm",
|
|
19
|
+
"md",
|
|
20
|
+
"lg",
|
|
21
|
+
"xl",
|
|
22
|
+
"2xl",
|
|
23
|
+
"full",
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Components/Main",
|
|
28
|
+
component: Main,
|
|
29
|
+
parameters: { layout: "fullscreen" },
|
|
30
|
+
argTypes: {
|
|
31
|
+
contentWidth: toStoryChoice(CONTENT_WIDTH_OPTIONS),
|
|
32
|
+
},
|
|
33
|
+
args: {},
|
|
34
|
+
} satisfies Meta<typeof Main>
|
|
35
|
+
|
|
36
|
+
export default meta
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A page whose grid answers to **the column**, not the window.
|
|
42
|
+
*
|
|
43
|
+
* `Main` establishes a container query, so the tile grid below
|
|
44
|
+
* goes one-up, two-up, three-up as the *content column* changes —
|
|
45
|
+
* which is a different number from the window's the moment a rail
|
|
46
|
+
* is open. Open the rail and the same window width gives a
|
|
47
|
+
* narrower grid, which is the case a `md:` media query cannot
|
|
48
|
+
* tell apart and the reason the fleet's poster grids look wrong
|
|
49
|
+
* at intermediate widths.
|
|
50
|
+
*/
|
|
51
|
+
export const Default: Story = {
|
|
52
|
+
render: (mainProps) => (
|
|
53
|
+
<Shell>
|
|
54
|
+
<Header
|
|
55
|
+
actions={<HeaderSchemeToggle />}
|
|
56
|
+
heading="Library"
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
<Rail label="Sections" landmark="navigation">
|
|
60
|
+
<RailNavigation />
|
|
61
|
+
</Rail>
|
|
62
|
+
|
|
63
|
+
<Main {...mainProps}>
|
|
64
|
+
<div className="grid grid-cols-1 gap-4 cq-md:grid-cols-2 cq-xl:grid-cols-3">
|
|
65
|
+
{[
|
|
66
|
+
"Adventure Time",
|
|
67
|
+
"Cowboy Bebop",
|
|
68
|
+
"Deep Space Nine",
|
|
69
|
+
"Twin Peaks",
|
|
70
|
+
"Utopia",
|
|
71
|
+
"Yellowjackets",
|
|
72
|
+
].map((title) => (
|
|
73
|
+
<Card heading={title} key={title} padding="sm">
|
|
74
|
+
<p className="text-content-secondary text-sm">
|
|
75
|
+
4 seasons
|
|
76
|
+
</p>
|
|
77
|
+
</Card>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</Main>
|
|
81
|
+
</Shell>
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The width scale, drawn — because the fleet's real answer to
|
|
87
|
+
* "how wide is the content column" is eight different numbers.
|
|
88
|
+
*
|
|
89
|
+
* Each bar is the actual `max-inline-size` a `contentWidth`
|
|
90
|
+
* resolves to, taken from `toMaxInlineSize` rather than restated,
|
|
91
|
+
* so a change to the `screen.*` scale moves the drawing.
|
|
92
|
+
*
|
|
93
|
+
* There is one bar per step and **no second `<main>`**: `<main>`
|
|
94
|
+
* is the page's one main landmark, so a board that rendered six
|
|
95
|
+
* of them to compare them would be an accessibility violation
|
|
96
|
+
* demonstrating an accessibility component.
|
|
97
|
+
*/
|
|
98
|
+
export const AllVariants: Story = {
|
|
99
|
+
render: () => (
|
|
100
|
+
<div className="flex flex-col gap-3 p-6">
|
|
101
|
+
<h3 className="font-semibold text-content-secondary text-sm">
|
|
102
|
+
`screen.*` steps, plus the `full` escape hatch. `lg`
|
|
103
|
+
is the default.
|
|
104
|
+
</h3>
|
|
105
|
+
|
|
106
|
+
{CONTENT_WIDTH_OPTIONS.map((step) => (
|
|
107
|
+
<div className="flex items-center gap-3" key={step}>
|
|
108
|
+
<span className="w-12 shrink-0 font-mono text-content-muted text-xs">
|
|
109
|
+
{step}
|
|
110
|
+
</span>
|
|
111
|
+
|
|
112
|
+
<div
|
|
113
|
+
className="h-6 w-full rounded-md border border-border-subtle bg-surface-raised"
|
|
114
|
+
style={{ maxInlineSize: toMaxInlineSize(step) }}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The 390px fixture. An unbroken 130-character path and a table
|
|
124
|
+
* wider than the phone it is on, in one column.
|
|
125
|
+
*
|
|
126
|
+
* The path wraps because `Main` sets `overflow-wrap: break-word`;
|
|
127
|
+
* the table cannot wrap at all and lives in a labelled,
|
|
128
|
+
* keyboard-reachable scroll container. Neither widens the page,
|
|
129
|
+
* which is the assertion `Main.test.tsx` makes at a real 390px
|
|
130
|
+
* viewport.
|
|
131
|
+
*/
|
|
132
|
+
export const Responsive: Story = {
|
|
133
|
+
render: (mainProps) => (
|
|
134
|
+
<Shell>
|
|
135
|
+
<Header heading="Transfers" />
|
|
136
|
+
|
|
137
|
+
<Main {...mainProps}>
|
|
138
|
+
<OverflowingContent />
|
|
139
|
+
</Main>
|
|
140
|
+
</Shell>
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Where the skip link lands. `<main>` carries `tabIndex={-1}` so
|
|
146
|
+
* activating it moves **focus** and not merely the scroll
|
|
147
|
+
* position — without that, Safari and Firefox scroll the page and
|
|
148
|
+
* leave the next Tab back up in the header, which reads as the
|
|
149
|
+
* skip link not working.
|
|
150
|
+
*/
|
|
151
|
+
export const Interactive: Story = {
|
|
152
|
+
render: (mainProps) => (
|
|
153
|
+
<Shell>
|
|
154
|
+
<Header
|
|
155
|
+
actions={<HeaderSchemeToggle />}
|
|
156
|
+
heading="Rip Deck"
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
<Main {...mainProps}>
|
|
160
|
+
<PageContent />
|
|
161
|
+
</Main>
|
|
162
|
+
</Shell>
|
|
163
|
+
),
|
|
164
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent } 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 "./Main.stories.tsx"
|
|
14
|
+
|
|
15
|
+
const { Default, Interactive, Responsive } =
|
|
16
|
+
composeStories(stories)
|
|
17
|
+
|
|
18
|
+
afterAll(async () => {
|
|
19
|
+
await setViewport(DESKTOP)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("the content column is capped at the token, not at the window", async () => {
|
|
23
|
+
await setViewport(DESKTOP)
|
|
24
|
+
|
|
25
|
+
const { canvas } = await mountStory(Default)
|
|
26
|
+
|
|
27
|
+
const column = canvas.getByRole("main")
|
|
28
|
+
.firstElementChild as HTMLElement
|
|
29
|
+
|
|
30
|
+
// `screen.lg` — 64rem at the root 16px font size. The number is
|
|
31
|
+
// spelled out here rather than read back from the same
|
|
32
|
+
// `var()` the component wrote, because a test that asks the
|
|
33
|
+
// component what it did agrees with it by construction.
|
|
34
|
+
await expect(
|
|
35
|
+
globalThis.getComputedStyle(column).maxInlineSize,
|
|
36
|
+
).toBe("1024px")
|
|
37
|
+
|
|
38
|
+
await expect(
|
|
39
|
+
column.getBoundingClientRect().width,
|
|
40
|
+
).toBeLessThanOrEqual(1024)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `@container` claim, asserted rather than assumed.
|
|
45
|
+
*
|
|
46
|
+
* A grid inside `Main` answers to the **content column**, which is
|
|
47
|
+
* a different width from the window's the moment a rail is open —
|
|
48
|
+
* the pair a media query cannot tell apart, and the reason the
|
|
49
|
+
* fleet's poster grids look wrong at intermediate widths.
|
|
50
|
+
*/
|
|
51
|
+
test("main is the query container its content responds to", async () => {
|
|
52
|
+
await setViewport(DESKTOP)
|
|
53
|
+
|
|
54
|
+
const { canvas } = await mountStory(Default)
|
|
55
|
+
|
|
56
|
+
const column = canvas.getByRole("main")
|
|
57
|
+
.firstElementChild as HTMLElement
|
|
58
|
+
|
|
59
|
+
// On the column, not on `<main>`. `<main>` is as wide as its
|
|
60
|
+
// grid track (1184px beside the rail here); the column is
|
|
61
|
+
// capped at 1024px. A container declared on the wrong one of
|
|
62
|
+
// those measures a width the content never has.
|
|
63
|
+
await expect(
|
|
64
|
+
globalThis.getComputedStyle(column).containerType,
|
|
65
|
+
).toBe("inline-size")
|
|
66
|
+
|
|
67
|
+
const grid = column.firstElementChild as HTMLElement
|
|
68
|
+
|
|
69
|
+
// Two-up, and that is the whole argument. The column's content
|
|
70
|
+
// box is 976px — past `--cq-md` (32rem), short of `--cq-xl`
|
|
71
|
+
// (64rem) — while the *window* is 1440px, so a `lg:grid-cols-3`
|
|
72
|
+
// media query would have drawn three slivers in 976px of space.
|
|
73
|
+
await expect(
|
|
74
|
+
globalThis
|
|
75
|
+
.getComputedStyle(grid)
|
|
76
|
+
.gridTemplateColumns.split(" "),
|
|
77
|
+
).toHaveLength(2)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("neither an unbroken path nor a wide table widens the page at 390px", async () => {
|
|
81
|
+
await setViewport(PHONE)
|
|
82
|
+
|
|
83
|
+
const { canvas, canvasElement } =
|
|
84
|
+
await mountStory(Responsive)
|
|
85
|
+
|
|
86
|
+
const path = canvas.getByText(
|
|
87
|
+
/Some-Very-Long-Show-Name-S01E01/,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// It wraps rather than overflowing. Without it the element is
|
|
91
|
+
// one 1,000px line.
|
|
92
|
+
await expect(
|
|
93
|
+
path.getBoundingClientRect().width,
|
|
94
|
+
).toBeLessThanOrEqual(PHONE.width)
|
|
95
|
+
|
|
96
|
+
// `anywhere`, not `break-word` — a drift gate, because the two
|
|
97
|
+
// are indistinguishable in a screenshot and only `anywhere`
|
|
98
|
+
// shrinks the **min-content size** a flex or grid item's
|
|
99
|
+
// automatic minimum resolves against. Under `break-word` the ink
|
|
100
|
+
// wraps while the intrinsic contribution stays the full token
|
|
101
|
+
// length, so the string can still force a column open with **no
|
|
102
|
+
// overflowing element box** for a bounding-rect assertion to
|
|
103
|
+
// see. That is the failure shape found in plex-channels, and
|
|
104
|
+
// this line is what stops somebody "tidying" the utility back.
|
|
105
|
+
const column = canvas.getByRole("main")
|
|
106
|
+
.firstElementChild as HTMLElement
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
globalThis.getComputedStyle(column).overflowWrap,
|
|
110
|
+
).toBe("anywhere")
|
|
111
|
+
|
|
112
|
+
const scroller = canvas.getByRole("region", {
|
|
113
|
+
name: "Transfer log, scrollable",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The table genuinely does not fit — otherwise the page-level
|
|
117
|
+
// assertion below passes for the wrong reason.
|
|
118
|
+
await expect(scroller.scrollWidth).toBeGreaterThan(
|
|
119
|
+
scroller.clientWidth,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const { documentElement } = document
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
documentElement.scrollWidth,
|
|
126
|
+
).toBeLessThanOrEqual(documentElement.clientWidth)
|
|
127
|
+
|
|
128
|
+
await expectNoAxeViolations(canvasElement)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `tabIndex={-1}` is the difference between a skip link that
|
|
133
|
+
* works and one that only scrolls. It must not put `<main>` in
|
|
134
|
+
* the tab order, and it must accept focus when sent there.
|
|
135
|
+
*/
|
|
136
|
+
test("main takes focus from a skip link without joining the tab order", async () => {
|
|
137
|
+
await setViewport(DESKTOP)
|
|
138
|
+
|
|
139
|
+
const { canvas, canvasElement } =
|
|
140
|
+
await mountStory(Interactive)
|
|
141
|
+
|
|
142
|
+
const main = canvas.getByRole("main")
|
|
143
|
+
|
|
144
|
+
await expect(main).toHaveAttribute("tabindex", "-1")
|
|
145
|
+
|
|
146
|
+
await userEvent.click(
|
|
147
|
+
expectAgentDrivable(canvas, {
|
|
148
|
+
name: "Skip to main content",
|
|
149
|
+
role: "link",
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
await expect(main).toHaveFocus()
|
|
154
|
+
|
|
155
|
+
// The next Tab leaves `<main>` rather than cycling back into
|
|
156
|
+
// the header, because the skip link put focus *inside* the page
|
|
157
|
+
// rather than merely scrolling to it.
|
|
158
|
+
await userEvent.tab()
|
|
159
|
+
|
|
160
|
+
await expect(main).not.toHaveFocus()
|
|
161
|
+
|
|
162
|
+
await expect(main.contains(document.activeElement)).toBe(
|
|
163
|
+
true,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
await expectNoAxeViolations(canvasElement)
|
|
167
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import {
|
|
3
|
+
type ComponentPropsWithRef,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useContext,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ContentWidth,
|
|
10
|
+
DEFAULT_CONTENT_WIDTH,
|
|
11
|
+
toMaxInlineSize,
|
|
12
|
+
} from "../Shell/contentWidth.ts"
|
|
13
|
+
import { ShellContext } from "../Shell/shellContext.ts"
|
|
14
|
+
import { toClassName } from "../toClassName.ts"
|
|
15
|
+
|
|
16
|
+
export type MainProps = ComponentPropsWithRef<"main"> & {
|
|
17
|
+
contentWidth?: ContentWidth
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `<main>` landmark and the content column.
|
|
22
|
+
*
|
|
23
|
+
* ## The width is the whole component
|
|
24
|
+
*
|
|
25
|
+
* The owner's complaint, verbatim:
|
|
26
|
+
*
|
|
27
|
+
* > "All these apps seem to be really narrow with a very large
|
|
28
|
+
* > max-width. By that I mean 1 column, but waaaaaaay too wide. In
|
|
29
|
+
* > almost all cases, what I really want is something like Rip-Deck
|
|
30
|
+
* > where the app has a narrower main column but the wrapping grids
|
|
31
|
+
* > are all full-width in most of these apps only when you have too
|
|
32
|
+
* > many items."
|
|
33
|
+
*
|
|
34
|
+
* That is two requirements, and they pull opposite ways: a
|
|
35
|
+
* **narrow** column by default, which **widens** when the content
|
|
36
|
+
* is a grid with enough items to justify it. A fixed `max-w-7xl`
|
|
37
|
+
* answers neither — it is too wide for the one-column case, which
|
|
38
|
+
* is the complaint, and it is a hard ceiling on the many-items
|
|
39
|
+
* case.
|
|
40
|
+
*
|
|
41
|
+
* So the cap is a *value*, not a constant, and it arrives from
|
|
42
|
+
* three places in priority order: this component's own
|
|
43
|
+
* `contentWidth`, then `Shell`'s (the usual answer, so the header
|
|
44
|
+
* matches), then `screen.lg`.
|
|
45
|
+
*
|
|
46
|
+
* ### How it composes with the adaptive-columns hook
|
|
47
|
+
*
|
|
48
|
+
* rip-deck's `useLayoutColumns` — being lifted into this library
|
|
49
|
+
* separately — decides a column count from the viewport and folds
|
|
50
|
+
* it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
|
|
51
|
+
* 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
|
|
52
|
+
* `ContentWidth` accepts `` `${number}rem` `` for exactly this:
|
|
53
|
+
*
|
|
54
|
+
* ```tsx
|
|
55
|
+
* const { columns } = useLayoutColumns({ cardCount: bays.length })
|
|
56
|
+
*
|
|
57
|
+
* <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
|
|
58
|
+
* <Header heading="Rip Deck" />
|
|
59
|
+
* <Main>
|
|
60
|
+
* <BayGrid columns={columns} />
|
|
61
|
+
* </Main>
|
|
62
|
+
* </Shell>
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* The seam is deliberately one prop and no hook import: this
|
|
66
|
+
* package must not depend on a layout hook to lay out a page, and
|
|
67
|
+
* the hook must not know that a `Shell` exists. Putting the value
|
|
68
|
+
* on `Shell` rather than on `Main` is what keeps the header's cap
|
|
69
|
+
* in step with the column's as the count changes.
|
|
70
|
+
*
|
|
71
|
+
* ## `@container`, and what `contain: inline-size` costs
|
|
72
|
+
*
|
|
73
|
+
* The content column establishes a container query, so an app's
|
|
74
|
+
* grids inside it respond to **the column's** width rather than
|
|
75
|
+
* the window's — a poster grid beside an open rail and the same
|
|
76
|
+
* grid full-bleed are the pair a media query cannot tell apart,
|
|
77
|
+
* and getting it wrong at intermediate widths is what the fleet's
|
|
78
|
+
* grids do today.
|
|
79
|
+
*
|
|
80
|
+
* `container-type: inline-size` implies `contain: layout style
|
|
81
|
+
* inline-size`, and both halves have consequences worth stating:
|
|
82
|
+
*
|
|
83
|
+
* - **The element may not be sized by its own contents.** Here
|
|
84
|
+
* that is free and in fact wanted: the column has a definite
|
|
85
|
+
* inline size from its parent — itself a grid item with
|
|
86
|
+
* `minmax(0, 1fr)` — so wide content can no longer widen it,
|
|
87
|
+
* which is half of why the page does not scroll sideways.
|
|
88
|
+
* - **`contain: layout` makes it a containing block for
|
|
89
|
+
* `position: fixed` descendants.** An app rendering a fixed
|
|
90
|
+
* element *inside* `Main` gets it positioned against the
|
|
91
|
+
* column, not the viewport. Every overlay in this library
|
|
92
|
+
* portals to `document.body` and is therefore unaffected
|
|
93
|
+
* ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
|
|
94
|
+
* an app's own fixed chrome belongs in `Shell`, beside `Main`
|
|
95
|
+
* rather than in it.
|
|
96
|
+
*
|
|
97
|
+
* ## Two more things it does, both one line
|
|
98
|
+
*
|
|
99
|
+
* `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
|
|
100
|
+
* merely the reading cursor — without it, Safari and Firefox scroll
|
|
101
|
+
* to `<main>` and leave the next Tab back in the header.
|
|
102
|
+
*
|
|
103
|
+
* `wrap-anywhere` so an unbroken string — a filesystem path, a
|
|
104
|
+
* hash, a URL — wraps instead of widening the page.
|
|
105
|
+
*
|
|
106
|
+
* **`anywhere`, not `break-word`, and the difference is the whole
|
|
107
|
+
* point.** Both break a long word onto the next line, so they look
|
|
108
|
+
* identical in a screenshot. Only `anywhere` also shrinks the
|
|
109
|
+
* element's **min-content size** — which is what a flex or grid
|
|
110
|
+
* item's automatic minimum resolves against, and therefore what
|
|
111
|
+
* decides whether the string can push its own column wider. With
|
|
112
|
+
* `break-word` the ink wraps while the intrinsic contribution
|
|
113
|
+
* stays the full length of the token, so the layout above it can
|
|
114
|
+
* still be forced open. This is the second of the two overflow
|
|
115
|
+
* sources found in plex-channels, and the nastier one: it produces
|
|
116
|
+
* **no overflowing element box at all**, so every
|
|
117
|
+
* `getBoundingClientRect()` in a test reads clean while the page
|
|
118
|
+
* scrolls sideways.
|
|
119
|
+
*
|
|
120
|
+
* It is the cheap half of the horizontal-scroll fix; the other
|
|
121
|
+
* halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
|
|
122
|
+
* `<table>` is the one thing none of them can save: tables do not
|
|
123
|
+
* wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
|
|
124
|
+
* `Main.mdx` shows.
|
|
125
|
+
*/
|
|
126
|
+
export const Main = ({
|
|
127
|
+
children,
|
|
128
|
+
className,
|
|
129
|
+
contentWidth,
|
|
130
|
+
id,
|
|
131
|
+
...mainProps
|
|
132
|
+
}: MainProps): ReactNode => {
|
|
133
|
+
const shell = useContext(ShellContext)
|
|
134
|
+
|
|
135
|
+
// Always called, never conditionally: a `Main` outside a `Shell`
|
|
136
|
+
// still needs an id an app can point its own skip link at.
|
|
137
|
+
const fallbackId = useUniqueId()
|
|
138
|
+
|
|
139
|
+
const maxInlineSize = toMaxInlineSize(
|
|
140
|
+
contentWidth ??
|
|
141
|
+
shell?.contentWidth ??
|
|
142
|
+
DEFAULT_CONTENT_WIDTH,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<main
|
|
147
|
+
{...mainProps}
|
|
148
|
+
className={toClassName(
|
|
149
|
+
"col-start-1 row-start-3 min-w-0 md:col-start-2 md:row-start-2",
|
|
150
|
+
className,
|
|
151
|
+
)}
|
|
152
|
+
id={id ?? shell?.mainId ?? fallbackId}
|
|
153
|
+
tabIndex={-1}
|
|
154
|
+
>
|
|
155
|
+
{/*
|
|
156
|
+
`@container` is on the **capped column**, not on `<main>`,
|
|
157
|
+
and the difference is not cosmetic. `<main>` is as wide as
|
|
158
|
+
its grid track — 1184px beside an open rail on a 1440px
|
|
159
|
+
window — while the column inside it is capped at
|
|
160
|
+
`contentWidth`. Declaring the container on `<main>` makes
|
|
161
|
+
every `cq-*` inside answer to a width the content never
|
|
162
|
+
has: a grid would go three-up at `--cq-xl` while sitting in
|
|
163
|
+
976px of space. The container has to be the box the content
|
|
164
|
+
is actually laid out in.
|
|
165
|
+
*/}
|
|
166
|
+
<div
|
|
167
|
+
className="@container mx-auto flex w-full min-w-0 flex-col gap-6 wrap-anywhere px-4 py-6 sm:px-6"
|
|
168
|
+
style={{ maxInlineSize }}
|
|
169
|
+
>
|
|
170
|
+
{children}
|
|
171
|
+
</div>
|
|
172
|
+
</main>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as RailStories from "./Rail.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={RailStories} />
|
|
6
|
+
|
|
7
|
+
# Rail
|
|
8
|
+
|
|
9
|
+
A side column — the app's sections, a filter panel, the selected job's detail. `side` places
|
|
10
|
+
it, `landmark` decides what it is, and `label` is required.
|
|
11
|
+
|
|
12
|
+
<Canvas of={RailStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## `label` is required, and that is not pedantry
|
|
19
|
+
|
|
20
|
+
<Canvas of={RailStories.AllVariants} />
|
|
21
|
+
|
|
22
|
+
Two rails in one page are two landmarks of the same type. Unnamed, a screen reader
|
|
23
|
+
announces "complementary" twice with no way to tell them apart, axe files
|
|
24
|
+
`landmark-unique`, and `getByRole("navigation")` returns two matches — which is worse for an
|
|
25
|
+
agent than the rail not existing, because it picks one at random.
|
|
26
|
+
|
|
27
|
+
Named, the rail becomes the scope: `getByRole("navigation", { name: "Sections" })` is what
|
|
28
|
+
distinguishes its "Settings" link from the header's "Settings" button. Same trick `Card`'s
|
|
29
|
+
heading plays for one of nine identical "Start" buttons on a bay tower.
|
|
30
|
+
|
|
31
|
+
`landmark="navigation"` renders `<nav>`; the default `"complementary"` renders `<aside>`.
|
|
32
|
+
Pick the one the content actually is — a filter panel is not navigation.
|
|
33
|
+
|
|
34
|
+
## It collapses without duplicating itself
|
|
35
|
+
|
|
36
|
+
<Canvas of={RailStories.Responsive} />
|
|
37
|
+
|
|
38
|
+
Below `md` (48rem, which is `screen.md`) the shell is one column and the rail becomes a
|
|
39
|
+
horizontally-scrolling strip: above the content for `side="start"`, below it for
|
|
40
|
+
`side="end"`. Above `md` it is a fixed-width column in the grid.
|
|
41
|
+
|
|
42
|
+
**The same element, restyled.** The fleet's habit is to render the whole thing twice behind
|
|
43
|
+
`hidden` and `lg:hidden` — mux-magic's `PageHeader` across two ~55-line blocks,
|
|
44
|
+
mail-sifter's `TriageQueue` again. The cost is not bytes: every control is in the DOM
|
|
45
|
+
**twice at every viewport**, so tests have to be defensively scoped with `within(toolbar)`,
|
|
46
|
+
any agent driving the page sees phantom controls it cannot click, and the two copies drift
|
|
47
|
+
apart. `Rail.test.tsx` counts the links at 390px and at 1440px and gets twelve both times.
|
|
48
|
+
|
|
49
|
+
A media query is enough here because only the rail's *position* changes, never its
|
|
50
|
+
contents. plex-channels' `App.tsx` shows the pattern for when that is not true — a
|
|
51
|
+
`useMediaQuery` over `matchMedia` through `useSyncExternalStore`, mounting exactly one
|
|
52
|
+
instance and relocating it — and it is strictly more machinery for the same guarantee.
|
|
53
|
+
|
|
54
|
+
The `md` breakpoint and `screen.md` agree by construction: Tailwind v4's default
|
|
55
|
+
`--breakpoint-*` scale and this repo's `screen.*` carry the same five values.
|
|
56
|
+
|
|
57
|
+
## The strip scrolls itself
|
|
58
|
+
|
|
59
|
+
Twelve sections on a phone is what makes a naive rail widen the page. `overflow-x-auto`
|
|
60
|
+
keeps the overflow inside an element that scrolls, and `min-w-0` is on it for the same
|
|
61
|
+
reason `Shell`'s middle track is `minmax(0, 1fr)`: a flex or grid child's automatic minimum
|
|
62
|
+
is its content, so one long unbroken string otherwise pushes the layout out of the viewport.
|
|
63
|
+
|
|
64
|
+
A scrolling region has to be keyboard-reachable. It is here because it is a landmark full of
|
|
65
|
+
links, which is what axe's `scrollable-region-focusable` accepts — **a rail of plain
|
|
66
|
+
non-focusable text would not be**, and that is the honest limit of the pattern. Content
|
|
67
|
+
like that belongs in `Main`.
|
|
68
|
+
|
|
69
|
+
<Canvas of={RailStories.Interactive} />
|
|
70
|
+
|
|
71
|
+
## Width
|
|
72
|
+
|
|
73
|
+
`md:w-64` (16rem) by default, which is what the fleet's nav columns settle on. Override it
|
|
74
|
+
with `className` — the rail's track is `auto`, so it takes whatever definite width the
|
|
75
|
+
element is given, and the content column absorbs the difference through its `minmax(0, 1fr)`.
|