@charcuterie/ui 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -1
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +1 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts.map +1 -1
- package/dist/Field/FieldGroup.js +1 -1
- package/dist/Field/FieldGroup.js.map +1 -1
- 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/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +5 -1
- package/dist/Tooltip/Tooltip.js.map +1 -1
- 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/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +56 -0
- package/dist/mountStory.testHelpers.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/Dialog/Dialog.tsx +17 -1
- package/src/Field/Field.tsx +10 -2
- package/src/Field/FieldGroup.tsx +4 -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/SortableTableHeader/SortableTableHeader.tsx +6 -1
- package/src/Tooltip/Tooltip.tsx +5 -1
- package/src/index.ts +16 -0
- package/src/mountStory.testHelpers.ts +63 -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,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.
|
|
@@ -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
|
+
}
|