@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,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
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
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 "./Shell.stories.tsx"
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
Default,
|
|
17
|
+
Interactive,
|
|
18
|
+
Responsive,
|
|
19
|
+
WithBothRails,
|
|
20
|
+
WithStartRail,
|
|
21
|
+
} = composeStories(stories)
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await setViewport(DESKTOP)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page's own `<header>`, found structurally rather than by
|
|
29
|
+
* role — and the reason is worth writing down.
|
|
30
|
+
*
|
|
31
|
+
* `getByRole("banner")` returns **three** matches on any page with
|
|
32
|
+
* two `Card`s in it, because `Card` renders a `<header>` and
|
|
33
|
+
* testing-library's role mapping does not implement the HTML-AAM
|
|
34
|
+
* scoping rule that makes a `<header>` inside `<section>` generic
|
|
35
|
+
* rather than a banner. Chromium's real accessibility tree does
|
|
36
|
+
* implement it — which is what Playwright, a screen reader and an
|
|
37
|
+
* agent actually read, and why axe files no `landmark-unique` here
|
|
38
|
+
* — so this is a testing-library artefact and not a fault in the
|
|
39
|
+
* page.
|
|
40
|
+
*
|
|
41
|
+
* Asserting through the artefact would mean either shipping a page
|
|
42
|
+
* with no cards on it or teaching the shell to work around a
|
|
43
|
+
* query library. Neither is the component's problem, so the test
|
|
44
|
+
* takes the shell's first `<header>`, which is the one it renders.
|
|
45
|
+
*/
|
|
46
|
+
const getPageHeader = (canvasElement: HTMLElement) => {
|
|
47
|
+
const header = canvasElement.querySelector("header")
|
|
48
|
+
|
|
49
|
+
if (!header) {
|
|
50
|
+
throw new Error("The shell rendered no <header>.")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return header
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The gate, stated as the browser sees it.
|
|
58
|
+
*
|
|
59
|
+
* `scrollWidth > clientWidth` on the documentElement is *exactly*
|
|
60
|
+
* "this page scrolls sideways" — no proxy, no computed-style
|
|
61
|
+
* inference. It is asserted rather than eyeballed because the
|
|
62
|
+
* causes are all invisible in a screenshot taken at the wrong
|
|
63
|
+
* width: a grid track that ignored `min-width: 0`, a `100vw` that
|
|
64
|
+
* counted the scrollbar gutter, a sticky header whose inner
|
|
65
|
+
* container is wider than the viewport.
|
|
66
|
+
*/
|
|
67
|
+
const expectNoHorizontalScroll = async () => {
|
|
68
|
+
const { documentElement } = document
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
documentElement.scrollWidth,
|
|
72
|
+
).toBeLessThanOrEqual(documentElement.clientWidth)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("the shell does not scroll sideways at 390px, with content that tries", async () => {
|
|
76
|
+
await setViewport(PHONE)
|
|
77
|
+
|
|
78
|
+
const { canvas, canvasElement } =
|
|
79
|
+
await mountStory(Responsive)
|
|
80
|
+
|
|
81
|
+
// The fixture is only a fixture if it is really there: an
|
|
82
|
+
// unbroken 130-character path, plus a table wider than the
|
|
83
|
+
// phone. A regression that deleted the content would otherwise
|
|
84
|
+
// turn this into a green no-op.
|
|
85
|
+
await expect(
|
|
86
|
+
canvas.getByText(/Some-Very-Long-Show-Name-S01E01/),
|
|
87
|
+
).toBeVisible()
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
canvas.getByRole("table"),
|
|
91
|
+
).toBeInTheDocument()
|
|
92
|
+
|
|
93
|
+
// The third fixture: a closed drawer parked at
|
|
94
|
+
// `translateX(110%)`. It has to be really parked past the
|
|
95
|
+
// inline edge, because a transform leaves the box in the
|
|
96
|
+
// document's scrollable overflow region — which is why this
|
|
97
|
+
// shape survives every `min-width: 0` in the tree, and why the
|
|
98
|
+
// assertion below is on `documentElement.scrollWidth` rather
|
|
99
|
+
// than on any element's bounding rect.
|
|
100
|
+
const parkedDrawer = canvasElement.querySelector(
|
|
101
|
+
"#parked-drawer",
|
|
102
|
+
) as HTMLElement
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
parkedDrawer.getBoundingClientRect().left,
|
|
106
|
+
).toBeGreaterThan(PHONE.width)
|
|
107
|
+
|
|
108
|
+
const shellRoot =
|
|
109
|
+
canvasElement.firstElementChild as HTMLElement
|
|
110
|
+
|
|
111
|
+
// The drawer resolves against `Shell`, not the initial
|
|
112
|
+
// containing block — which is the only reason the clip below
|
|
113
|
+
// reaches it. `overflow-x: clip` clips descendants whose
|
|
114
|
+
// containing-block chain runs through the clipper, and an
|
|
115
|
+
// absolutely positioned box with no positioned ancestor has no
|
|
116
|
+
// such chain: it lands its overflow straight on
|
|
117
|
+
// `documentElement`, where `document.body.scrollWidth` still
|
|
118
|
+
// reads clean.
|
|
119
|
+
//
|
|
120
|
+
// Measured before `Shell` took `position: relative`:
|
|
121
|
+
// `shellScroll: 390`, `bodyScroll: 390`, `docScroll: 742`.
|
|
122
|
+
await expect(parkedDrawer.offsetParent).toBe(shellRoot)
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
globalThis.getComputedStyle(shellRoot).overflowX,
|
|
126
|
+
).toBe("clip")
|
|
127
|
+
|
|
128
|
+
await expectNoAxeViolations(canvasElement)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("both rails and a full page do not scroll sideways at 390px either", async () => {
|
|
132
|
+
await setViewport(PHONE)
|
|
133
|
+
|
|
134
|
+
const { canvasElement } = await mountStory(WithBothRails)
|
|
135
|
+
|
|
136
|
+
await expectNoHorizontalScroll()
|
|
137
|
+
|
|
138
|
+
await expectNoAxeViolations(canvasElement)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The other half of the same property. A shell that never
|
|
143
|
+
* overflows because it clipped everything would pass the test
|
|
144
|
+
* above; this is the assertion that the frame is actually laid
|
|
145
|
+
* out at the width it was given.
|
|
146
|
+
*/
|
|
147
|
+
test("the shell fills the viewport at desktop width without overflowing it", async () => {
|
|
148
|
+
await setViewport(DESKTOP)
|
|
149
|
+
|
|
150
|
+
await mountStory(WithBothRails)
|
|
151
|
+
|
|
152
|
+
await expectNoHorizontalScroll()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("the shell is one banner, one main, and two uniquely-named rails", async () => {
|
|
156
|
+
const { canvas, canvasElement } =
|
|
157
|
+
await mountStory(WithBothRails)
|
|
158
|
+
|
|
159
|
+
await expect(getPageHeader(canvasElement).tagName).toBe(
|
|
160
|
+
"HEADER",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expectAgentDrivable(canvas, { role: "main" })
|
|
164
|
+
|
|
165
|
+
// Named, so they are two things rather than two of the same
|
|
166
|
+
// thing — which is the whole reason `label` is required.
|
|
167
|
+
expectAgentDrivable(canvas, {
|
|
168
|
+
name: "Sections",
|
|
169
|
+
role: "navigation",
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expectAgentDrivable(canvas, {
|
|
173
|
+
name: "Job detail",
|
|
174
|
+
role: "complementary",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await expectNoAxeViolations(canvasElement)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The rail is in the document **once**, at every width.
|
|
182
|
+
*
|
|
183
|
+
* This is the assertion that would fail the `hidden`/`lg:hidden`
|
|
184
|
+
* implementation mux-magic and mail-sifter both ship — where every
|
|
185
|
+
* control exists twice in the DOM at every viewport, so a test has
|
|
186
|
+
* to be defensively scoped and an agent finds phantom controls.
|
|
187
|
+
* Four links narrow, four links wide.
|
|
188
|
+
*/
|
|
189
|
+
test("collapsing the rail does not duplicate its contents", async () => {
|
|
190
|
+
await setViewport(PHONE)
|
|
191
|
+
|
|
192
|
+
const phone = await mountStory(WithStartRail)
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
phone.canvas.getAllByRole("link", { name: "Queue" }),
|
|
196
|
+
).toHaveLength(1)
|
|
197
|
+
|
|
198
|
+
await setViewport(DESKTOP)
|
|
199
|
+
|
|
200
|
+
const desktop = await mountStory(WithStartRail)
|
|
201
|
+
|
|
202
|
+
await expect(
|
|
203
|
+
desktop.canvas.getAllByRole("link", { name: "Queue" }),
|
|
204
|
+
).toHaveLength(1)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The skip link — missing from all ten of the fleet's hand-rolled
|
|
209
|
+
* shells, which is why it is the shell's job rather than the
|
|
210
|
+
* app's.
|
|
211
|
+
*
|
|
212
|
+
* The assertion is that it **moves focus into `<main>`**, not that
|
|
213
|
+
* an anchor exists. An `href` pointing at a non-focusable target
|
|
214
|
+
* scrolls the page and leaves the next Tab in the header, which
|
|
215
|
+
* looks like a working skip link and is not.
|
|
216
|
+
*/
|
|
217
|
+
test("the first Tab reaches a skip link that lands focus in main", async () => {
|
|
218
|
+
const { canvas, canvasElement } =
|
|
219
|
+
await mountStory(Interactive)
|
|
220
|
+
|
|
221
|
+
const skipLink = expectAgentDrivable(canvas, {
|
|
222
|
+
name: "Skip to main content",
|
|
223
|
+
role: "link",
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const main = canvas.getByRole("main")
|
|
227
|
+
|
|
228
|
+
await expect(skipLink).toHaveAttribute(
|
|
229
|
+
"href",
|
|
230
|
+
`#${main.id}`,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
await userEvent.click(skipLink)
|
|
234
|
+
|
|
235
|
+
await expect(main).toHaveFocus()
|
|
236
|
+
|
|
237
|
+
await expectNoAxeViolations(canvasElement)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("header and main are capped at the same width", async () => {
|
|
241
|
+
await setViewport(DESKTOP)
|
|
242
|
+
|
|
243
|
+
const { canvas, canvasElement } =
|
|
244
|
+
await mountStory(Default)
|
|
245
|
+
|
|
246
|
+
// The inner rows, not the landmarks: the `<header>` and
|
|
247
|
+
// `<main>` are both full-bleed on purpose, and it is the row
|
|
248
|
+
// inside each that carries the cap.
|
|
249
|
+
const headerRow = getPageHeader(canvasElement)
|
|
250
|
+
.firstElementChild as HTMLElement
|
|
251
|
+
|
|
252
|
+
const mainRow = canvas.getByRole("main")
|
|
253
|
+
.firstElementChild as HTMLElement
|
|
254
|
+
|
|
255
|
+
// points-market ships these two disagreeing — its header row is
|
|
256
|
+
// capped at 80rem and its `<main>` is not capped at all — so on
|
|
257
|
+
// a wide monitor the title sits above content that starts to
|
|
258
|
+
// its left. One `contentWidth`, read from one context, is the
|
|
259
|
+
// fix.
|
|
260
|
+
await expect(
|
|
261
|
+
globalThis.getComputedStyle(headerRow).maxInlineSize,
|
|
262
|
+
).toBe(globalThis.getComputedStyle(mainRow).maxInlineSize)
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
headerRow.getBoundingClientRect().width,
|
|
266
|
+
).toBe(mainRow.getBoundingClientRect().width)
|
|
267
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import {
|
|
3
|
+
type ComponentPropsWithRef,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useMemo,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import { toClassName } from "../toClassName.ts"
|
|
9
|
+
import {
|
|
10
|
+
type ContentWidth,
|
|
11
|
+
DEFAULT_CONTENT_WIDTH,
|
|
12
|
+
} from "./contentWidth.ts"
|
|
13
|
+
import { ShellContext } from "./shellContext.ts"
|
|
14
|
+
|
|
15
|
+
export type ShellProps = ComponentPropsWithRef<"div"> & {
|
|
16
|
+
contentWidth?: ContentWidth
|
|
17
|
+
skipLinkLabel?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The page frame: one `Header`, up to two `Rail`s, one `Main`.
|
|
22
|
+
*
|
|
23
|
+
* **The largest duplicated surface in the fleet.** Ten of twelve UI
|
|
24
|
+
* repos hand-roll this, and three of them have a file literally
|
|
25
|
+
* called `AppShell.tsx` — mail-sifter's and points-market's headers
|
|
26
|
+
* are a **byte-identical** string
|
|
27
|
+
* (`sticky top-0 z-40 border-b border-border-subtle bg-surface-base/90 backdrop-blur-md`),
|
|
28
|
+
* arrived at independently, and board-games' is the same structure
|
|
29
|
+
* with `sticky` dropped. The remaining seven spell it as
|
|
30
|
+
* `PageChrome`, `Header`, `DashboardHeader`, `Layout` (twice),
|
|
31
|
+
* `TitleBar`, and inline in `App.tsx`.
|
|
32
|
+
*
|
|
33
|
+
* Three things fall out of owning it once, and each is a bug
|
|
34
|
+
* somewhere in that list today:
|
|
35
|
+
*
|
|
36
|
+
* - **A skip link.** All ten are missing one. A keyboard user
|
|
37
|
+
* tabs through the whole header on every page.
|
|
38
|
+
* - **One width, not two.** `Header` and `Main` read the same
|
|
39
|
+
* `contentWidth` through context — see `shellContext.ts` for
|
|
40
|
+
* the app where they disagree.
|
|
41
|
+
* - **Sticky that is actually sticky.** mux-magic's `PageHeader`
|
|
42
|
+
* is *described* as sticky and sets only `z-index`; it never
|
|
43
|
+
* sets `position: sticky`, so the z-index does nothing and the
|
|
44
|
+
* header scrolls away. `Header`'s `isSticky` sets both or
|
|
45
|
+
* neither.
|
|
46
|
+
*
|
|
47
|
+
* ## The layout is one grid, and the rails do not duplicate
|
|
48
|
+
*
|
|
49
|
+
* Three columns at `md` and up — `auto minmax(0, 1fr) auto`, so an
|
|
50
|
+
* absent rail is a **zero-width track** rather than a conditional
|
|
51
|
+
* subtree — collapsing to a single column below it, where the
|
|
52
|
+
* children simply stack in DOM order. The same `Rail` element is
|
|
53
|
+
* in the document at every width; nothing is rendered twice behind
|
|
54
|
+
* `hidden`/`lg:hidden`.
|
|
55
|
+
*
|
|
56
|
+
* That matters beyond tidiness. mux-magic's `PageHeader` renders
|
|
57
|
+
* its entire control set twice (lines 290–345 and 356–410) and
|
|
58
|
+
* mail-sifter's `TriageQueue` does the same, so every action
|
|
59
|
+
* exists twice in the DOM at every viewport: tests have to be
|
|
60
|
+
* defensively scoped with `within(toolbar)`, and any agent driving
|
|
61
|
+
* the page sees phantom controls it cannot click.
|
|
62
|
+
*
|
|
63
|
+
* `minmax(0, 1fr)` on the middle track is the horizontal-scroll
|
|
64
|
+
* fix, not a formality. A grid track's automatic minimum is
|
|
65
|
+
* `min-content`, so **one long unbroken string in `Main` widens
|
|
66
|
+
* the whole page** — which is the bug plex-channels' narrow view
|
|
67
|
+
* has today. rip-deck's `BayGrid` documents the same fix from the
|
|
68
|
+
* other end.
|
|
69
|
+
*
|
|
70
|
+
* There is no `gap`: an empty `auto` track is 0 wide, but a `gap`
|
|
71
|
+
* beside it is not, so a shell with no rails would carry two
|
|
72
|
+
* mystery gutters. `Rail` and `Main` bring their own padding.
|
|
73
|
+
*
|
|
74
|
+
* ## `overflow-x: clip` — the structural guarantee
|
|
75
|
+
*
|
|
76
|
+
* `minmax(0, 1fr)` fixes the tracks and `Main`'s `wrap-anywhere`
|
|
77
|
+
* fixes the text, but neither reaches the shape that actually
|
|
78
|
+
* broke plex-channels: **a closed panel parked at
|
|
79
|
+
* `transform: translateX(110%)`**. A transform does not take a box
|
|
80
|
+
* out of the document's scrollable overflow region — and neither
|
|
81
|
+
* does `visibility: hidden` — so an off-screen drawer that looks
|
|
82
|
+
* absent still makes the page scroll to it. It is a favourite
|
|
83
|
+
* fleet pattern and no amount of `min-width: 0` touches it.
|
|
84
|
+
*
|
|
85
|
+
* `clip`, not `hidden`, and the two are not interchangeable here:
|
|
86
|
+
* `overflow: hidden` creates a **scroll container**, which would
|
|
87
|
+
* become the sticky containing block for `Header` and freeze it in
|
|
88
|
+
* place. `overflow-x: clip` creates no scrollport, so `Header`
|
|
89
|
+
* keeps sticking to the viewport, and `overflow-y` stays `visible`
|
|
90
|
+
* — a pair that is legal for `clip` and illegal for `hidden`,
|
|
91
|
+
* which would compute the other axis to `auto`.
|
|
92
|
+
*
|
|
93
|
+
* The trade is deliberate: content wider than the frame is
|
|
94
|
+
* **clipped rather than reachable by scrolling sideways**, which is
|
|
95
|
+
* exactly the outcome asked for. Content that genuinely needs to
|
|
96
|
+
* be wider owns a scroll container of its own — the pattern
|
|
97
|
+
* `Main.mdx` documents for tables.
|
|
98
|
+
*
|
|
99
|
+
* ### `relative` is what makes the clip reach anything
|
|
100
|
+
*
|
|
101
|
+
* `overflow-x: clip` only clips descendants whose **containing
|
|
102
|
+
* block chain passes through the clipping element**. An absolutely
|
|
103
|
+
* positioned element with no positioned ancestor resolves against
|
|
104
|
+
* the *initial* containing block instead, so it sails straight
|
|
105
|
+
* past every `overflow` in the tree and lands its overflow on
|
|
106
|
+
* `documentElement` — where `document.body.scrollWidth` still
|
|
107
|
+
* reads clean and only `document.documentElement.scrollWidth`
|
|
108
|
+
* shows it.
|
|
109
|
+
*
|
|
110
|
+
* That is not hypothetical: it is measured. Before this
|
|
111
|
+
* `relative`, the parked-drawer fixture reported `shellScroll:
|
|
112
|
+
* 390`, `bodyScroll: 390` and `docScroll: 742` at a 390px
|
|
113
|
+
* viewport — the shell dutifully clipping a box it had no
|
|
114
|
+
* authority over.
|
|
115
|
+
*
|
|
116
|
+
* The obvious candidate to own it, `Main`'s content column, does
|
|
117
|
+
* **not**: `container-type: inline-size` computes `contain` to
|
|
118
|
+
* `none` in Chromium and establishes no containing block for
|
|
119
|
+
* absolute positioning, which is measured too — the drawer's
|
|
120
|
+
* `offsetParent` was `BODY`. So `Shell` takes the job, which is
|
|
121
|
+
* also the honest place for it: the frame is what an app's parked
|
|
122
|
+
* chrome is parked against.
|
|
123
|
+
*/
|
|
124
|
+
export const Shell = ({
|
|
125
|
+
children,
|
|
126
|
+
className,
|
|
127
|
+
contentWidth = DEFAULT_CONTENT_WIDTH,
|
|
128
|
+
skipLinkLabel = "Skip to main content",
|
|
129
|
+
...divProps
|
|
130
|
+
}: ShellProps): ReactNode => {
|
|
131
|
+
const mainId = useUniqueId()
|
|
132
|
+
|
|
133
|
+
const shellContext = useMemo(
|
|
134
|
+
() => ({ contentWidth, mainId }),
|
|
135
|
+
[contentWidth, mainId],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<ShellContext.Provider value={shellContext}>
|
|
140
|
+
<div
|
|
141
|
+
{...divProps}
|
|
142
|
+
className={toClassName(
|
|
143
|
+
"relative grid min-h-dvh grid-cols-1 grid-rows-[auto_auto_1fr_auto] overflow-x-clip bg-surface-base text-content-primary md:grid-cols-[auto_minmax(0,1fr)_auto] md:grid-rows-[auto_1fr]",
|
|
144
|
+
className,
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{/*
|
|
148
|
+
Hidden by transform rather than by `sr-only`, because
|
|
149
|
+
`focus-visible:not-sr-only` and `focus-visible:fixed`
|
|
150
|
+
both write `position` and which one wins is decided by
|
|
151
|
+
Tailwind's internal property order, not by the order they
|
|
152
|
+
are written in. A translate is one property, and a
|
|
153
|
+
keyboard user's first Tab landing on a visible control is
|
|
154
|
+
the entire feature.
|
|
155
|
+
|
|
156
|
+
`--layer-toast` rather than `--layer-sticky`: it has to
|
|
157
|
+
out-rank the header it is offering to skip past.
|
|
158
|
+
*/}
|
|
159
|
+
<a
|
|
160
|
+
className="fixed top-0 start-0 z-[var(--layer-toast)] m-3 -translate-y-24 rounded-md border border-border-subtle bg-surface-raised px-3 py-2 font-medium text-content-primary text-sm shadow-medium focus-visible:translate-y-0"
|
|
161
|
+
href={`#${mainId}`}
|
|
162
|
+
>
|
|
163
|
+
{skipLinkLabel}
|
|
164
|
+
</a>
|
|
165
|
+
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
</ShellContext.Provider>
|
|
169
|
+
)
|
|
170
|
+
}
|