@charcuterie/ui 2.9.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.
- 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 +1 -1
- 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,177 @@
|
|
|
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 { Shell } from "../Shell/Shell.tsx"
|
|
7
|
+
import {
|
|
8
|
+
HeaderSchemeToggle,
|
|
9
|
+
PageContent,
|
|
10
|
+
RailDetail,
|
|
11
|
+
RailNavigation,
|
|
12
|
+
} from "../shell.storyHelpers.tsx"
|
|
13
|
+
import { Rail } from "./Rail.tsx"
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: "Components/Rail",
|
|
17
|
+
component: Rail,
|
|
18
|
+
parameters: { layout: "fullscreen" },
|
|
19
|
+
argTypes: {
|
|
20
|
+
landmark: toStoryChoice([
|
|
21
|
+
"complementary",
|
|
22
|
+
"navigation",
|
|
23
|
+
] as const),
|
|
24
|
+
side: toStoryChoice(["start", "end"] as const),
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
landmark: "complementary",
|
|
28
|
+
side: "start",
|
|
29
|
+
},
|
|
30
|
+
} satisfies Meta<typeof Rail>
|
|
31
|
+
|
|
32
|
+
export default meta
|
|
33
|
+
|
|
34
|
+
type Story = StoryObj<typeof meta>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The sections column. `landmark="navigation"` because that is
|
|
38
|
+
* what it is, and `label` because a rail nobody can name is a
|
|
39
|
+
* rail an agent cannot scope to — `getByRole("navigation", {
|
|
40
|
+
* name: "Sections" })` is what distinguishes its "Settings" link
|
|
41
|
+
* from the header's "Settings" button.
|
|
42
|
+
*/
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
label: "Sections",
|
|
46
|
+
landmark: "navigation",
|
|
47
|
+
},
|
|
48
|
+
render: (railProps) => (
|
|
49
|
+
<Shell>
|
|
50
|
+
<Header
|
|
51
|
+
actions={<HeaderSchemeToggle />}
|
|
52
|
+
heading="Comic Ingest"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
<Rail {...railProps}>
|
|
56
|
+
<RailNavigation />
|
|
57
|
+
</Rail>
|
|
58
|
+
|
|
59
|
+
<Main>
|
|
60
|
+
<PageContent />
|
|
61
|
+
</Main>
|
|
62
|
+
</Shell>
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Both sides at once, and both kinds of landmark: a `<nav>` at
|
|
68
|
+
* the start for the app's sections, an `<aside>` at the end for
|
|
69
|
+
* the selected thing's detail.
|
|
70
|
+
*
|
|
71
|
+
* Two rails is where the required `label` stops being pedantry.
|
|
72
|
+
* Two unnamed landmarks of the same type are the same announcement
|
|
73
|
+
* twice to a screen reader and two identical matches to an agent,
|
|
74
|
+
* which is worse than one of them not existing.
|
|
75
|
+
*/
|
|
76
|
+
export const AllVariants: Story = {
|
|
77
|
+
args: { label: "Sections", landmark: "navigation" },
|
|
78
|
+
render: (railProps) => (
|
|
79
|
+
<Shell>
|
|
80
|
+
<Header
|
|
81
|
+
actions={<HeaderSchemeToggle />}
|
|
82
|
+
heading="Rip Deck"
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<Rail {...railProps}>
|
|
86
|
+
<RailNavigation />
|
|
87
|
+
</Rail>
|
|
88
|
+
|
|
89
|
+
<Main>
|
|
90
|
+
<PageContent />
|
|
91
|
+
</Main>
|
|
92
|
+
|
|
93
|
+
<Rail label="Job detail" side="end">
|
|
94
|
+
<RailDetail />
|
|
95
|
+
</Rail>
|
|
96
|
+
</Shell>
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* **The collapse, and what it is not.** Narrow the window past
|
|
102
|
+
* 48rem (`screen.md`): the rail stops being a column and becomes
|
|
103
|
+
* a horizontally-scrolling strip above the content.
|
|
104
|
+
*
|
|
105
|
+
* It is the **same element**, restyled. The fleet's habit is to
|
|
106
|
+
* render the whole thing twice behind `hidden` / `lg:hidden` —
|
|
107
|
+
* mux-magic's `PageHeader` across two ~55-line blocks,
|
|
108
|
+
* mail-sifter's `TriageQueue` again — which puts every control in
|
|
109
|
+
* the DOM twice at every viewport. Count the links here at any
|
|
110
|
+
* width and there are four.
|
|
111
|
+
*
|
|
112
|
+
* The strip scrolls itself rather than widening the page, which
|
|
113
|
+
* is why twelve sections on a phone still leave
|
|
114
|
+
* `document.documentElement.scrollWidth` where it was.
|
|
115
|
+
*/
|
|
116
|
+
export const Responsive: Story = {
|
|
117
|
+
args: { label: "Sections", landmark: "navigation" },
|
|
118
|
+
render: (railProps) => (
|
|
119
|
+
<Shell>
|
|
120
|
+
<Header heading="Twelve sections" />
|
|
121
|
+
|
|
122
|
+
<Rail {...railProps}>
|
|
123
|
+
{[
|
|
124
|
+
"Queue",
|
|
125
|
+
"Library",
|
|
126
|
+
"Transfers",
|
|
127
|
+
"Settings",
|
|
128
|
+
"Devices",
|
|
129
|
+
"Schedules",
|
|
130
|
+
"Notifications",
|
|
131
|
+
"Integrations",
|
|
132
|
+
"Storage",
|
|
133
|
+
"Diagnostics",
|
|
134
|
+
"Backups",
|
|
135
|
+
"About",
|
|
136
|
+
].map((section) => (
|
|
137
|
+
<a
|
|
138
|
+
className="whitespace-nowrap rounded-md px-3 py-2 text-content-secondary text-sm hover:bg-intent-neutral-surface hover:text-content-primary"
|
|
139
|
+
href={`#${section.toLowerCase()}`}
|
|
140
|
+
key={section}
|
|
141
|
+
>
|
|
142
|
+
{section}
|
|
143
|
+
</a>
|
|
144
|
+
))}
|
|
145
|
+
</Rail>
|
|
146
|
+
|
|
147
|
+
<Main>
|
|
148
|
+
<PageContent />
|
|
149
|
+
</Main>
|
|
150
|
+
</Shell>
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What an agent drives. The rail is a named landmark, so its
|
|
156
|
+
* links are reachable *through* it — the same trick `Card`'s
|
|
157
|
+
* heading plays for a bay's "Start" button.
|
|
158
|
+
*/
|
|
159
|
+
export const Interactive: Story = {
|
|
160
|
+
args: { label: "Sections", landmark: "navigation" },
|
|
161
|
+
render: (railProps) => (
|
|
162
|
+
<Shell>
|
|
163
|
+
<Header
|
|
164
|
+
actions={<HeaderSchemeToggle />}
|
|
165
|
+
heading="Rip Deck"
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<Rail {...railProps}>
|
|
169
|
+
<RailNavigation />
|
|
170
|
+
</Rail>
|
|
171
|
+
|
|
172
|
+
<Main>
|
|
173
|
+
<PageContent />
|
|
174
|
+
</Main>
|
|
175
|
+
</Shell>
|
|
176
|
+
),
|
|
177
|
+
}
|
|
@@ -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.
|