@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.
Files changed (56) hide show
  1. package/README.md +9 -0
  2. package/dist/Header/Header.d.ts +52 -0
  3. package/dist/Header/Header.d.ts.map +1 -0
  4. package/dist/Header/Header.js +55 -0
  5. package/dist/Header/Header.js.map +1 -0
  6. package/dist/Main/Main.d.ts +113 -0
  7. package/dist/Main/Main.d.ts.map +1 -0
  8. package/dist/Main/Main.js +123 -0
  9. package/dist/Main/Main.js.map +1 -0
  10. package/dist/Rail/Rail.d.ts +61 -0
  11. package/dist/Rail/Rail.d.ts.map +1 -0
  12. package/dist/Rail/Rail.js +64 -0
  13. package/dist/Rail/Rail.js.map +1 -0
  14. package/dist/Shell/Shell.d.ts +112 -0
  15. package/dist/Shell/Shell.d.ts.map +1 -0
  16. package/dist/Shell/Shell.js +116 -0
  17. package/dist/Shell/Shell.js.map +1 -0
  18. package/dist/Shell/contentWidth.d.ts +63 -0
  19. package/dist/Shell/contentWidth.d.ts.map +1 -0
  20. package/dist/Shell/contentWidth.js +74 -0
  21. package/dist/Shell/contentWidth.js.map +1 -0
  22. package/dist/Shell/shellContext.d.ts +36 -0
  23. package/dist/Shell/shellContext.d.ts.map +1 -0
  24. package/dist/Shell/shellContext.js +32 -0
  25. package/dist/Shell/shellContext.js.map +1 -0
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/viewport.testHelpers.d.ts +31 -0
  31. package/dist/viewport.testHelpers.d.ts.map +1 -0
  32. package/dist/viewport.testHelpers.js +34 -0
  33. package/dist/viewport.testHelpers.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/Header/Header.mdx +72 -0
  36. package/src/Header/Header.stories.tsx +221 -0
  37. package/src/Header/Header.test.tsx +122 -0
  38. package/src/Header/Header.tsx +118 -0
  39. package/src/Main/Main.mdx +149 -0
  40. package/src/Main/Main.stories.tsx +164 -0
  41. package/src/Main/Main.test.tsx +167 -0
  42. package/src/Main/Main.tsx +174 -0
  43. package/src/Rail/Rail.mdx +75 -0
  44. package/src/Rail/Rail.stories.tsx +177 -0
  45. package/src/Rail/Rail.test.tsx +150 -0
  46. package/src/Rail/Rail.tsx +102 -0
  47. package/src/Shell/Shell.mdx +186 -0
  48. package/src/Shell/Shell.stories.tsx +185 -0
  49. package/src/Shell/Shell.test.tsx +267 -0
  50. package/src/Shell/Shell.tsx +170 -0
  51. package/src/Shell/contentWidth.ts +89 -0
  52. package/src/Shell/shellContext.ts +41 -0
  53. package/src/index.ts +16 -0
  54. package/src/shell.storyHelpers.tsx +258 -0
  55. package/src/sourceRules.test.ts +17 -1
  56. package/src/viewport.testHelpers.ts +47 -0
@@ -0,0 +1,221 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { Badge } from "../Badge/Badge.tsx"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import {
6
+ StoryCell,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Main } from "../Main/Main.tsx"
10
+ import { Shell } from "../Shell/Shell.tsx"
11
+ import {
12
+ HeaderSchemeToggle,
13
+ PageContent,
14
+ ScrollFiller,
15
+ } from "../shell.storyHelpers.tsx"
16
+ import { Header } from "./Header.tsx"
17
+
18
+ const meta = {
19
+ title: "Components/Header",
20
+ component: Header,
21
+ parameters: { layout: "fullscreen" },
22
+ argTypes: {
23
+ headingLevel: {
24
+ control: { type: "radio" },
25
+ options: [1, 2],
26
+ },
27
+ },
28
+ args: {
29
+ headingLevel: 1,
30
+ isSticky: true,
31
+ },
32
+ } satisfies Meta<typeof Header>
33
+
34
+ export default meta
35
+
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ /**
39
+ * The fleet's header, as one component: the app's name, the
40
+ * controls that belong to the frame, a real `<header>` landmark,
41
+ * and a fill that is `surface-raised` rather than the page's own
42
+ * colour.
43
+ */
44
+ export const Default: Story = {
45
+ args: { heading: "Rip Deck" },
46
+ render: (headerProps) => (
47
+ <Shell>
48
+ <Header
49
+ {...headerProps}
50
+ actions={<HeaderSchemeToggle />}
51
+ />
52
+
53
+ <Main>
54
+ <PageContent />
55
+ </Main>
56
+ </Shell>
57
+ ),
58
+ }
59
+
60
+ /**
61
+ * The three slots, in the order they render: the heading, the
62
+ * middle (`children` — a search field, a breadcrumb, a status
63
+ * pill), and `actions`, which is pushed to the far end.
64
+ *
65
+ * Every one is optional. A header with only actions is the
66
+ * image-viewer's `TitleBar`; a header with only a heading is
67
+ * board-games'.
68
+ */
69
+ export const AllStates: Story = {
70
+ render: (headerProps) => (
71
+ <StorySection title="Ten repos hand-roll this row. These are the four shapes they hand-roll it into.">
72
+ <StoryCell align="stretch" label="heading only">
73
+ <Header {...headerProps} heading="Board Games" />
74
+ </StoryCell>
75
+
76
+ <StoryCell align="stretch" label="heading + actions">
77
+ <Header
78
+ {...headerProps}
79
+ actions={<HeaderSchemeToggle />}
80
+ heading="Points Market"
81
+ />
82
+ </StoryCell>
83
+
84
+ <StoryCell
85
+ align="stretch"
86
+ label="heading + middle + actions"
87
+ >
88
+ <Header
89
+ {...headerProps}
90
+ actions={<HeaderSchemeToggle />}
91
+ heading="Mail Sifter"
92
+ >
93
+ <Badge intent="info">412 unread</Badge>
94
+ </Header>
95
+ </StoryCell>
96
+
97
+ <StoryCell align="stretch" label="actions only">
98
+ <Header {...headerProps}>
99
+ <Button appearance="ghost" size="sm">
100
+ Open
101
+ </Button>
102
+ </Header>
103
+ </StoryCell>
104
+ </StorySection>
105
+ ),
106
+ }
107
+
108
+ /**
109
+ * **`isSticky` sets `position` and `z-index` together, and that
110
+ * is the point.** mux-magic's `PageHeader` is documented as a
111
+ * sticky header and sets only the z-index, so it scrolls away
112
+ * like any other block — valid CSS, stated intent, no effect.
113
+ *
114
+ * Scroll the panel below. The z-index is `--layer-sticky` (100)
115
+ * from the token scale, so a `Menu` opened from the header — at
116
+ * `--layer-modal` (400) — still paints over it.
117
+ */
118
+ export const AllVariants: Story = {
119
+ render: (headerProps) => (
120
+ <div className="h-96 overflow-y-auto border border-border-subtle">
121
+ <Shell>
122
+ <Header
123
+ {...headerProps}
124
+ actions={<HeaderSchemeToggle />}
125
+ heading="Scroll me"
126
+ />
127
+
128
+ <Main>
129
+ <PageContent />
130
+
131
+ <ScrollFiller />
132
+ </Main>
133
+ </Shell>
134
+ </div>
135
+ ),
136
+ }
137
+
138
+ /**
139
+ * `isSticky={false}` — the header scrolls away with the page, and
140
+ * takes its z-index with it rather than leaving a stacking
141
+ * context nobody asked for. board-games' `AppShell.tsx` is this
142
+ * one: the same structure as mail-sifter's and points-market's,
143
+ * minus `sticky`.
144
+ */
145
+ export const NotSticky: Story = {
146
+ args: { heading: "Board Games", isSticky: false },
147
+ render: (headerProps) => (
148
+ <div className="h-96 overflow-y-auto border border-border-subtle">
149
+ <Shell>
150
+ <Header
151
+ {...headerProps}
152
+ actions={<HeaderSchemeToggle />}
153
+ />
154
+
155
+ <Main>
156
+ <PageContent />
157
+
158
+ <ScrollFiller />
159
+ </Main>
160
+ </Shell>
161
+ </div>
162
+ ),
163
+ }
164
+
165
+ /**
166
+ * The width contract. **The `<header>` is full-bleed and only its
167
+ * inner row is capped**, which is what stops a constrained header
168
+ * from looking like a floating island on an ultrawide — and the
169
+ * cap it uses is `Shell`'s, so the title always sits directly
170
+ * above the content it names.
171
+ *
172
+ * points-market is the app where those two disagree: its header
173
+ * row is capped at 80rem and its `<main>` is not capped at all.
174
+ */
175
+ export const Responsive: Story = {
176
+ render: (headerProps) => (
177
+ <Shell contentWidth="sm">
178
+ <Header
179
+ {...headerProps}
180
+ actions={<HeaderSchemeToggle />}
181
+ heading="Narrow by contract"
182
+ />
183
+
184
+ <Main>
185
+ <PageContent />
186
+ </Main>
187
+ </Shell>
188
+ ),
189
+ }
190
+
191
+ /**
192
+ * What an agent reaches for. The header is a landmark, so
193
+ * `getByRole("banner")` scopes every query inside it — which is
194
+ * how one of the three "Settings" buttons on a page becomes
195
+ * addressable.
196
+ */
197
+ export const Interactive: Story = {
198
+ args: { heading: "Rip Deck" },
199
+ render: (headerProps) => (
200
+ <Shell>
201
+ <Header
202
+ {...headerProps}
203
+ actions={
204
+ <>
205
+ <Button appearance="outline" size="sm">
206
+ Settings
207
+ </Button>
208
+
209
+ <HeaderSchemeToggle />
210
+ </>
211
+ }
212
+ />
213
+
214
+ <Main>
215
+ <Button appearance="outline" size="sm">
216
+ Settings
217
+ </Button>
218
+ </Main>
219
+ </Shell>
220
+ ),
221
+ }
@@ -0,0 +1,122 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, within } from "storybook/test"
3
+ import { 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 * as stories from "./Header.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Interactive,
14
+ NotSticky,
15
+ Responsive,
16
+ } = composeStories(stories)
17
+
18
+ const getHeader = (canvasElement: HTMLElement) => {
19
+ const header = canvasElement.querySelector("header")
20
+
21
+ if (!header) {
22
+ throw new Error("The story rendered no <header>.")
23
+ }
24
+
25
+ return header
26
+ }
27
+
28
+ /**
29
+ * **The mux-magic bug, as a red/green test.**
30
+ *
31
+ * `PageHeader.tsx:173` sets a z-index and calls itself sticky. It
32
+ * never sets `position: sticky`, so the header scrolls away and
33
+ * the z-index has nothing to stack against — valid CSS, a comment
34
+ * stating the intent, and no way to see it without scrolling a
35
+ * running app.
36
+ *
37
+ * Both halves are asserted together because it is the *pairing*
38
+ * that was wrong, and either half alone passes on the broken
39
+ * implementation.
40
+ */
41
+ test("isSticky sets position and z-index together", async () => {
42
+ const { canvasElement } = await mountStory(AllVariants)
43
+
44
+ const styles = globalThis.getComputedStyle(
45
+ getHeader(canvasElement),
46
+ )
47
+
48
+ await expect(styles.position).toBe("sticky")
49
+
50
+ // `--layer-sticky`, not a hand-picked number. The fleet's
51
+ // headers currently sit at z-40, z-50, z-10 and z-[100] with no
52
+ // ordering guarantee between them and anything else.
53
+ await expect(styles.zIndex).toBe("100")
54
+
55
+ await expectNoAxeViolations(canvasElement)
56
+ })
57
+
58
+ test("isSticky={false} sets neither", async () => {
59
+ const { canvasElement } = await mountStory(NotSticky)
60
+
61
+ const styles = globalThis.getComputedStyle(
62
+ getHeader(canvasElement),
63
+ )
64
+
65
+ await expect(styles.position).toBe("static")
66
+
67
+ // Not merely "some other number": a `z-index` on a static
68
+ // element is inert, and leaving one behind is how a stacking
69
+ // context outlives the thing that needed it.
70
+ await expect(styles.zIndex).toBe("auto")
71
+ })
72
+
73
+ /**
74
+ * The reason a header is a landmark at all. Two "Settings"
75
+ * buttons on the page, and an agent can still say which one it
76
+ * means.
77
+ */
78
+ test("the header is a banner an agent can scope queries to", async () => {
79
+ const { canvas, canvasElement } =
80
+ await mountStory(Interactive)
81
+
82
+ const banner = expectAgentDrivable(canvas, {
83
+ role: "banner",
84
+ })
85
+
86
+ // Unscoped, this is ambiguous on purpose — the story renders one
87
+ // in the header and one in the page.
88
+ await expect(
89
+ canvas.getAllByRole("button", { name: "Settings" }),
90
+ ).toHaveLength(2)
91
+
92
+ expectAgentDrivable(within(banner), {
93
+ name: "Settings",
94
+ role: "button",
95
+ })
96
+
97
+ // And the fleet's scheme control lives in the actions slot,
98
+ // which is the composition `Shell` promises.
99
+ expectAgentDrivable(within(banner), {
100
+ name: /colour scheme/i,
101
+ role: "button",
102
+ })
103
+
104
+ await expectNoAxeViolations(canvasElement)
105
+ })
106
+
107
+ test("the heading is the page's h1 by default", async () => {
108
+ const { canvas, canvasElement } =
109
+ await mountStory(Responsive)
110
+
111
+ await expect(
112
+ canvas.getByRole("heading", { level: 1 }),
113
+ ).toHaveTextContent("Narrow by contract")
114
+
115
+ await expectNoAxeViolations(canvasElement)
116
+ })
117
+
118
+ test("every slot arrangement the fleet ships is clean under axe", async () => {
119
+ const { canvasElement } = await mountStory(AllStates)
120
+
121
+ await expectNoAxeViolations(canvasElement)
122
+ })
@@ -0,0 +1,118 @@
1
+ import {
2
+ type ComponentPropsWithRef,
3
+ type ReactNode,
4
+ useContext,
5
+ } from "react"
6
+
7
+ import {
8
+ type ContentWidth,
9
+ DEFAULT_CONTENT_WIDTH,
10
+ toMaxInlineSize,
11
+ } from "../Shell/contentWidth.ts"
12
+ import { ShellContext } from "../Shell/shellContext.ts"
13
+ import { toClassName } from "../toClassName.ts"
14
+
15
+ export type HeaderProps =
16
+ ComponentPropsWithRef<"header"> & {
17
+ actions?: ReactNode
18
+ contentWidth?: ContentWidth
19
+ heading?: string
20
+ headingLevel?: 1 | 2
21
+ isSticky?: boolean
22
+ }
23
+
24
+ /**
25
+ * The page header — the `<header>` landmark, the app's name, and
26
+ * the controls that belong to the frame rather than to the page.
27
+ *
28
+ * **`isSticky` defaults to true, and it sets `position` as well as
29
+ * `z-index`.** That pairing is the whole point. mux-magic's
30
+ * `PageHeader` is documented as a sticky header and sets only
31
+ * `z-index` (`PageHeader.tsx:173`) — it is a flex-column shell that
32
+ * scrolls away like any other block, and the z-index it carries
33
+ * has nothing to stack against. The bug is invisible: the CSS is
34
+ * valid, the intent is stated in a comment, and nobody scrolls a
35
+ * component in a review. Here the two cannot come apart, because
36
+ * one boolean writes both.
37
+ *
38
+ * The z-index is `--layer-sticky` from the token scale, never a
39
+ * hand-picked number. The fleet's headers currently sit at `z-40`
40
+ * (mail-sifter, points-market), `z-50` (rip-deck), `z-10`
41
+ * (spoolbuddy) and `z-[100]` (mux-magic), with no ordering
42
+ * guarantee between a header and anything else — which is exactly
43
+ * what the scale exists to settle: `sticky` (100) is below
44
+ * `dropdown` (200), `modal` (400) and `toast` (500), so a menu
45
+ * opened *from* the header paints over it.
46
+ *
47
+ * ## The width comes from `Shell`
48
+ *
49
+ * The `<header>` itself is full-bleed — the border and the
50
+ * translucent fill run edge to edge — and only its **inner row** is
51
+ * capped, which is what makes a constrained header look right on an
52
+ * ultrawide. That cap is `Shell`'s `contentWidth`, not a second
53
+ * opinion: see `shellContext.ts` for the shipped app whose header
54
+ * and `<main>` disagree by 80rem.
55
+ *
56
+ * ## No invented colours
57
+ *
58
+ * `bg-surface-raised/85` + `backdrop-blur-md` + `border-border-subtle`.
59
+ * The fleet's copy of this line is `bg-surface-base/90`, which puts
60
+ * the header at the *same* colour as the page behind it and leans
61
+ * entirely on the blur to separate them — so it vanishes against a
62
+ * page that is not scrolled. `raised` means *more separated from
63
+ * base*, which is the role this element actually wants.
64
+ */
65
+ export const Header = ({
66
+ actions,
67
+ children,
68
+ className,
69
+ contentWidth,
70
+ heading,
71
+ headingLevel = 1,
72
+ isSticky = true,
73
+ ...headerProps
74
+ }: HeaderProps): ReactNode => {
75
+ const shell = useContext(ShellContext)
76
+
77
+ const Heading = `h${headingLevel}` as const
78
+
79
+ const maxInlineSize = toMaxInlineSize(
80
+ contentWidth ??
81
+ shell?.contentWidth ??
82
+ DEFAULT_CONTENT_WIDTH,
83
+ )
84
+
85
+ return (
86
+ <header
87
+ {...headerProps}
88
+ className={toClassName(
89
+ "col-span-full row-start-1 border-border-subtle border-b bg-surface-raised/85 backdrop-blur-md",
90
+ isSticky && "sticky top-0 z-[var(--layer-sticky)]",
91
+ className,
92
+ )}
93
+ >
94
+ <div
95
+ className="mx-auto flex w-full min-w-0 flex-wrap items-center gap-3 px-4 py-3 sm:px-6"
96
+ style={{ maxInlineSize }}
97
+ >
98
+ {heading ? (
99
+ <Heading className="min-w-0 truncate font-semibold text-lg leading-tight">
100
+ {heading}
101
+ </Heading>
102
+ ) : null}
103
+
104
+ {children ? (
105
+ <div className="flex min-w-0 flex-1 items-center gap-2">
106
+ {children}
107
+ </div>
108
+ ) : null}
109
+
110
+ {actions ? (
111
+ <div className="ms-auto flex shrink-0 items-center gap-2">
112
+ {actions}
113
+ </div>
114
+ ) : null}
115
+ </div>
116
+ </header>
117
+ )
118
+ }
@@ -0,0 +1,149 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MainStories from "./Main.stories";
4
+
5
+ <Meta of={MainStories} />
6
+
7
+ # Main
8
+
9
+ The `<main>` landmark and the content column. **Its width is the crux of the whole shell.**
10
+
11
+ The owner's complaint, verbatim:
12
+
13
+ > "All these apps seem to be really narrow with a very large max-width. By that I mean 1
14
+ > column, but waaaaaaay too wide. In almost all cases, what I really want is something like
15
+ > Rip-Deck where the app has a narrower main column but the wrapping grids are all
16
+ > full-width in most of these apps only when you have too many items."
17
+
18
+ <Canvas of={MainStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## The width is a value, not a constant
25
+
26
+ The cap is the least standardized number in the fleet: `max-w-5xl` (board-games, portly),
27
+ `max-w-6xl` *and* `max-w-3xl` mixed inside one app (mux-magic), `max-w-7xl`
28
+ (gallery-downloader, spoolbuddy), `max-w-[90rem]` (mail-sifter), `80rem` (points-market), a
29
+ raw `1400px` in plain CSS (plex-channels), a JS-computed one (rip-deck), and none at all in
30
+ three more.
31
+
32
+ `contentWidth` accepts three shapes, and the third is why this is a value rather than a
33
+ constant:
34
+
35
+ | Shape | Example | For |
36
+ | --- | --- | --- |
37
+ | A `screen.*` step | `contentWidth="lg"` | The ordinary page. `lg` (64rem) is the default. |
38
+ | `"full"` | `contentWidth="full"` | A wall of tiles that genuinely wants the window. |
39
+ | A `rem` literal | ``contentWidth={`${rem}rem`}`` | A cap computed at runtime. |
40
+
41
+ <Canvas of={MainStories.AllVariants} />
42
+
43
+ **`max-w-*` is not used and must not be.** Tailwind v4 owns `--container-*` for that scale
44
+ at *different* sizes than our `screen.*`, which is the collision that renamed the
45
+ container-query scale to `--cq-*`. The cap is an inline `max-inline-size` reading
46
+ `var(--screen-*)`, so it stays a token instead of becoming a literal.
47
+
48
+ ## How it composes with the adaptive-columns hook
49
+
50
+ rip-deck's `useLayoutColumns` — lifted into this library separately — decides a column count
51
+ from the viewport, then folds it into a page cap with `contentMaxWidthRem(columns)`: one
52
+ column → 56rem, two → 72rem, three → 106rem. It returns a **number of rem**, which is
53
+ exactly the third shape above:
54
+
55
+ ```tsx
56
+ const { columns } = useLayoutColumns({ cardCount: bays.length })
57
+
58
+ <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
59
+ <Header heading="Rip Deck" />
60
+
61
+ <Main>
62
+ <BayGrid columns={columns} />
63
+ </Main>
64
+ </Shell>
65
+ ```
66
+
67
+ The seam is one prop and no import in either direction: this package does not depend on a
68
+ layout hook to lay out a page, and the hook does not know a `Shell` exists. The value goes
69
+ on **`Shell`** rather than on `Main` so the header's cap stays in step with the column's as
70
+ the count changes — which is the same reason the two share one `contentWidth` at all.
71
+
72
+ ## `@container` is on the column, not on `<main>`
73
+
74
+ A grid inside the content column answers to **the column's** width. A poster grid beside an
75
+ open rail and the same grid full-bleed are the pair a media query cannot tell apart, and
76
+ getting that wrong at intermediate widths is what the fleet's grids do today.
77
+
78
+ The container is declared on the capped inner column rather than on `<main>` deliberately.
79
+ `<main>` is as wide as its grid track — 1184px beside an open rail on a 1440px window —
80
+ while the column inside it is capped at `contentWidth`. A container on `<main>` measures a
81
+ width the content never has: a grid would go three-up at `--cq-xl` while sitting in 976px
82
+ of space.
83
+
84
+ **`container-type: inline-size` implies `contain: layout style inline-size`**, and both
85
+ halves have consequences:
86
+
87
+ - **The element may not be sized by its own contents.** Here that is wanted — the column
88
+ has a definite inline size from its parent, so wide content can no longer widen it, which
89
+ is half of why the page does not scroll sideways.
90
+ - **`contain: layout` makes it a containing block for `position: fixed` descendants.** An
91
+ app rendering fixed chrome *inside* `Main` gets it positioned against the column rather
92
+ than the viewport. Every overlay in this library portals to `document.body` and is
93
+ unaffected; an app's own fixed chrome belongs in `Shell`, beside `Main`.
94
+
95
+ ## Two kinds of overflow, two different answers
96
+
97
+ <Canvas of={MainStories.Responsive} />
98
+
99
+ `wrap-anywhere` on the column handles content that *can* wrap and does not — a filesystem
100
+ path, a hash, a URL, none of which contain a break opportunity.
101
+
102
+ **`anywhere`, not `break-word`.** Both break the long word onto the next line, so the two are
103
+ indistinguishable in a screenshot. Only `anywhere` also shrinks the element's **min-content
104
+ size**, which is what a flex or grid item's automatic minimum resolves against — and
105
+ therefore what decides whether the string can force its own column wider. Under
106
+ `break-word` the ink wraps while the intrinsic contribution stays the full token length, so
107
+ the page can still scroll sideways with **no overflowing element box anywhere in the tree**
108
+ for a `getBoundingClientRect()` assertion to catch. That is why the gate asserts
109
+ `document.documentElement.scrollWidth`, and why `Main.test.tsx` pins the computed value.
110
+
111
+ That is the cheap half of the horizontal-scroll fix; the other halves are `minmax(0, 1fr)`
112
+ and `overflow-x: clip` on `Shell`.
113
+
114
+ A `<table>` is the one thing neither can save. Table columns have min-content widths that no
115
+ `overflow-wrap` reaches, so a wide table goes in a scroll container:
116
+
117
+ ```tsx
118
+ <section
119
+ aria-label="Transfer log, scrollable"
120
+ className="charcuterie-scrollbar overflow-x-auto rounded-lg border border-border-subtle"
121
+ >
122
+ <table>…</table>
123
+ </section>
124
+ ```
125
+
126
+ Because that container scrolls, the columns off screen have to be reachable **without a
127
+ mouse** — axe's `scrollable-region-focusable`, which is WCAG 2.1.1. There are two ways to
128
+ satisfy it and the choice is not a matter of taste:
129
+
130
+ 1. **The content contains something focusable.** A row that ends in a link is enough:
131
+ tabbing to it scrolls the container into view. This is the case above and the one the
132
+ `Responsive` story ships, because a log row with no way into the record is a poor table
133
+ anyway.
134
+ 2. **`tabIndex={0}` on the container.** The answer for a table of pure text, and the one
135
+ most guidance quotes. Note that Biome's `noNoninteractiveTabindex` rejects it on a
136
+ non-interactive element and its **unsafe fix removes the attribute before a suppression
137
+ is evaluated** — so `yarn lint` will silently turn a compliant table into a
138
+ keyboard-trap. Prefer (1) where the data allows it.
139
+
140
+ `aria-label` on the container is worth it either way: it turns the scroller into a named
141
+ region, so the table is announced as a thing rather than as loose content.
142
+
143
+ ## `tabIndex={-1}`
144
+
145
+ <Canvas of={MainStories.Interactive} />
146
+
147
+ `<main>` accepts focus so `Shell`'s skip link moves **focus** and not merely the scroll
148
+ position. Without it, Safari and Firefox scroll to `<main>` and leave the next Tab back up
149
+ in the header — which looks exactly like a skip link that works.