@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide the content column is allowed to get — the one number
|
|
3
|
+
* the fleet has never agreed on.
|
|
4
|
+
*
|
|
5
|
+
* Ten repos hand-roll their page chrome and **no two of them cap
|
|
6
|
+
* the column at the same width**: `max-w-5xl` (board-games,
|
|
7
|
+
* portly), `max-w-6xl` and `max-w-3xl` mixed inside one app
|
|
8
|
+
* (mux-magic), `max-w-7xl` (gallery-downloader, spoolbuddy),
|
|
9
|
+
* `max-w-[90rem]` (mail-sifter), `80rem` (points-market), a raw
|
|
10
|
+
* `1400px` in plain CSS (plex-channels), a JS-computed one
|
|
11
|
+
* (rip-deck), and none at all in three more. It is the least
|
|
12
|
+
* standardized value in the entire fleet.
|
|
13
|
+
*
|
|
14
|
+
* Two shapes are accepted, because the fleet genuinely has two
|
|
15
|
+
* cases and collapsing them is what produced the mess:
|
|
16
|
+
*
|
|
17
|
+
* 1. A **`screen.*` step** — the ordinary page. Named, so a
|
|
18
|
+
* reviewer reads `contentWidth="lg"` rather than deciding
|
|
19
|
+
* whether `max-w-6xl` was chosen or inherited.
|
|
20
|
+
* 2. A **`${number}rem` literal** — the runtime-computed case.
|
|
21
|
+
* rip-deck's `contentMaxWidthRem(columns)` widens the cap as
|
|
22
|
+
* the grid gains columns (1 → 56rem, 2 → 72rem, 3 → 106rem),
|
|
23
|
+
* and no fixed scale can express a number that is decided
|
|
24
|
+
* after the viewport is measured.
|
|
25
|
+
*
|
|
26
|
+
* `"full"` is the third answer and it is deliberate rather than a
|
|
27
|
+
* hole: a wall of poster tiles wants the whole window, and an app
|
|
28
|
+
* that wants that today reaches the same place by *omitting* the
|
|
29
|
+
* cap, which is indistinguishable from having forgotten it.
|
|
30
|
+
*
|
|
31
|
+
* **`max-w-*` is not used and must not be.** Tailwind v4 owns
|
|
32
|
+
* `--container-*` for that scale at *different* sizes than our
|
|
33
|
+
* `screen.*`, which is the collision that renamed our
|
|
34
|
+
* container-query scale to `--cq-*`
|
|
35
|
+
* ([decision](../../../../docs/decisions/2026-07-29-container-query-scale-is-cq-not-container.md)).
|
|
36
|
+
* The cap is therefore an inline `max-inline-size` reading
|
|
37
|
+
* `var(--screen-*)`, so it stays a token rather than becoming a
|
|
38
|
+
* literal the moment it is written down.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { screen } from "@charcuterie/tokens"
|
|
42
|
+
|
|
43
|
+
/** `sm` | `md` | `lg` | `xl` | `2xl`, from the token scale. */
|
|
44
|
+
export type ScreenStep = keyof typeof screen
|
|
45
|
+
|
|
46
|
+
export type ContentWidth =
|
|
47
|
+
| ScreenStep
|
|
48
|
+
| "full"
|
|
49
|
+
| `${number}rem`
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derived from the scale rather than restated, so a step added to
|
|
53
|
+
* `screen.*` is accepted here without a second edit — and one
|
|
54
|
+
* removed stops typechecking at every call site.
|
|
55
|
+
*/
|
|
56
|
+
const SCREEN_STEPS = new Set<string>(Object.keys(screen))
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The `max-inline-size` a `contentWidth` resolves to, or
|
|
60
|
+
* `undefined` for `"full"` — which is the absence of the property
|
|
61
|
+
* rather than a very large value, so nothing has to be un-set.
|
|
62
|
+
*/
|
|
63
|
+
export const toMaxInlineSize = (
|
|
64
|
+
contentWidth: ContentWidth,
|
|
65
|
+
): string | undefined => {
|
|
66
|
+
if (contentWidth === "full") {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (SCREEN_STEPS.has(contentWidth)) {
|
|
71
|
+
return `var(--screen-${contentWidth})`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return contentWidth
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The cap an app gets without asking, and it is deliberately
|
|
79
|
+
* **narrower** than most of what the fleet ships.
|
|
80
|
+
*
|
|
81
|
+
* The owner's complaint, verbatim: *"All these apps seem to be
|
|
82
|
+
* really narrow with a very large max-width. By that I mean 1
|
|
83
|
+
* column, but waaaaaaay too wide."* A single column of prose or
|
|
84
|
+
* form fields at `max-w-7xl` is one line of text the eye has to
|
|
85
|
+
* track across a monitor. `screen.lg` is 64rem — wide enough for a
|
|
86
|
+
* two-up card grid, narrow enough to read — and an app whose
|
|
87
|
+
* content genuinely fills a window says so, once, in one place.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_CONTENT_WIDTH: ContentWidth = "lg"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `Shell` tells `Header` and `Main` so they cannot disagree.
|
|
3
|
+
*
|
|
4
|
+
* This context exists because of one specific bug, present in the
|
|
5
|
+
* fleet today: **points-market's header is width-constrained and
|
|
6
|
+
* its `<main>` is not.** `AppShell.tsx` wraps the header row in
|
|
7
|
+
* `.pm-constrained` (80rem) and then renders
|
|
8
|
+
* `<main className="px-4 py-6 sm:px-6">` with no cap at all, so on
|
|
9
|
+
* a wide monitor the page title sits above content that starts to
|
|
10
|
+
* its left and runs past its right. Nothing errors, both values
|
|
11
|
+
* are individually reasonable, and the two were simply written on
|
|
12
|
+
* different days.
|
|
13
|
+
*
|
|
14
|
+
* A width that has to be stated twice will eventually be stated
|
|
15
|
+
* twice differently. So it is stated **once**, on `Shell`, and the
|
|
16
|
+
* two elements that have to agree read it from here. Either can
|
|
17
|
+
* still override for a genuine reason — a full-bleed hero `Main`
|
|
18
|
+
* under a constrained `Header` is a real design — but that now
|
|
19
|
+
* takes a prop somebody wrote on purpose.
|
|
20
|
+
*
|
|
21
|
+
* `mainId` rides along for the same reason: the skip link's `href`
|
|
22
|
+
* and the `<main>` it lands on are one value, generated once.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createContext } from "react"
|
|
26
|
+
|
|
27
|
+
import type { ContentWidth } from "./contentWidth.ts"
|
|
28
|
+
|
|
29
|
+
export type ShellContextValue = {
|
|
30
|
+
contentWidth: ContentWidth
|
|
31
|
+
mainId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `null` rather than a default object, so `Header` and `Main` can
|
|
36
|
+
* tell "no `Shell` above me" from "a `Shell` that chose the
|
|
37
|
+
* defaults" — the first case has to generate its own `<main>` id,
|
|
38
|
+
* the second must not.
|
|
39
|
+
*/
|
|
40
|
+
export const ShellContext =
|
|
41
|
+
createContext<ShellContextValue | null>(null)
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,8 @@ export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
|
|
|
113
113
|
export { FieldGroup } from "./Field/FieldGroup.tsx"
|
|
114
114
|
export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
|
|
115
115
|
export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
|
|
116
|
+
export type { HeaderProps } from "./Header/Header.tsx"
|
|
117
|
+
export { Header } from "./Header/Header.tsx"
|
|
116
118
|
export type { IconButtonProps } from "./IconButton/IconButton.tsx"
|
|
117
119
|
export { IconButton } from "./IconButton/IconButton.tsx"
|
|
118
120
|
export type { IntentAppearance } from "./intentStyles.ts"
|
|
@@ -142,6 +144,8 @@ export type {
|
|
|
142
144
|
LogViewerProps,
|
|
143
145
|
} from "./LogViewer/LogViewer.tsx"
|
|
144
146
|
export { LogViewer } from "./LogViewer/LogViewer.tsx"
|
|
147
|
+
export type { MainProps } from "./Main/Main.tsx"
|
|
148
|
+
export { Main } from "./Main/Main.tsx"
|
|
145
149
|
export type {
|
|
146
150
|
MediaTileProps,
|
|
147
151
|
MediaTileRatio,
|
|
@@ -174,6 +178,12 @@ export type {
|
|
|
174
178
|
RadioItem,
|
|
175
179
|
} from "./RadioGroup/RadioGroup.tsx"
|
|
176
180
|
export { RadioGroup } from "./RadioGroup/RadioGroup.tsx"
|
|
181
|
+
export type {
|
|
182
|
+
RailLandmark,
|
|
183
|
+
RailProps,
|
|
184
|
+
RailSide,
|
|
185
|
+
} from "./Rail/Rail.tsx"
|
|
186
|
+
export { Rail } from "./Rail/Rail.tsx"
|
|
177
187
|
export { AnchorLink } from "./RouterLink/AnchorLink.tsx"
|
|
178
188
|
export {
|
|
179
189
|
RouterLinkProvider,
|
|
@@ -196,6 +206,12 @@ export type {
|
|
|
196
206
|
SelectProps,
|
|
197
207
|
} from "./Select/Select.tsx"
|
|
198
208
|
export { Select } from "./Select/Select.tsx"
|
|
209
|
+
export type {
|
|
210
|
+
ContentWidth,
|
|
211
|
+
ScreenStep,
|
|
212
|
+
} from "./Shell/contentWidth.ts"
|
|
213
|
+
export type { ShellProps } from "./Shell/Shell.tsx"
|
|
214
|
+
export { Shell } from "./Shell/Shell.tsx"
|
|
199
215
|
export type {
|
|
200
216
|
SkeletonProps,
|
|
201
217
|
SkeletonShape,
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { ColorSchemeMode } from "@charcuterie/logic"
|
|
2
|
+
import { type ReactNode, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import { Badge } from "./Badge/Badge.tsx"
|
|
5
|
+
import { Button } from "./Button/Button.tsx"
|
|
6
|
+
import { Card } from "./Card/Card.tsx"
|
|
7
|
+
import { ColorSchemeToggle } from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
|
|
8
|
+
import {
|
|
9
|
+
MonitorIcon,
|
|
10
|
+
MoonIcon,
|
|
11
|
+
SunIcon,
|
|
12
|
+
} from "./icons.storyHelpers.tsx"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The furniture the four shell stories share: a rail's worth of
|
|
16
|
+
* links, a page's worth of cards, the scheme control that lives in
|
|
17
|
+
* the header, and the three things that make a page scroll
|
|
18
|
+
* sideways.
|
|
19
|
+
*
|
|
20
|
+
* `.storyHelpers.tsx` rather than `.tsx` because `react/no-multi-comp`
|
|
21
|
+
* is switched off for that suffix, and it is excluded from the
|
|
22
|
+
* package build so none of this ships.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A scheme control with its own state — `ColorSchemeToggle` rather
|
|
27
|
+
* than `ColorSchemeSwitcher`, deliberately.
|
|
28
|
+
*
|
|
29
|
+
* The connected switcher writes `data-scheme` onto `<html>` and a
|
|
30
|
+
* mode into `localStorage`. In a shell story that is a component
|
|
31
|
+
* reaching outside its own canvas to re-theme Storybook and leave
|
|
32
|
+
* a value behind for the next test, which is exactly the kind of
|
|
33
|
+
* cross-test coupling `mountStory` exists to prevent. The
|
|
34
|
+
* presentational half proves the same thing about `Header` — that
|
|
35
|
+
* the actions slot holds the fleet's scheme control — and touches
|
|
36
|
+
* nothing.
|
|
37
|
+
*/
|
|
38
|
+
export const HeaderSchemeToggle = (): ReactNode => {
|
|
39
|
+
const [mode, setMode] =
|
|
40
|
+
useState<ColorSchemeMode>("system")
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ColorSchemeToggle
|
|
44
|
+
icons={{
|
|
45
|
+
dark: <MoonIcon />,
|
|
46
|
+
light: <SunIcon />,
|
|
47
|
+
system: <MonitorIcon />,
|
|
48
|
+
}}
|
|
49
|
+
mode={mode}
|
|
50
|
+
onCycle={setMode}
|
|
51
|
+
size="sm"
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a start rail holds: the app's sections. */
|
|
57
|
+
export const RailNavigation = (): ReactNode => (
|
|
58
|
+
<>
|
|
59
|
+
{["Queue", "Library", "Transfers", "Settings"].map(
|
|
60
|
+
(section) => (
|
|
61
|
+
<a
|
|
62
|
+
className="rounded-md px-3 py-2 text-content-secondary text-sm hover:bg-intent-neutral-surface hover:text-content-primary"
|
|
63
|
+
href={`#${section.toLowerCase()}`}
|
|
64
|
+
key={section}
|
|
65
|
+
>
|
|
66
|
+
{section}
|
|
67
|
+
</a>
|
|
68
|
+
),
|
|
69
|
+
)}
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
/** What an end rail holds: the detail of whatever is selected. */
|
|
74
|
+
export const RailDetail = (): ReactNode => (
|
|
75
|
+
<>
|
|
76
|
+
<span className="font-semibold text-content-primary text-sm">
|
|
77
|
+
Selected job
|
|
78
|
+
</span>
|
|
79
|
+
|
|
80
|
+
<span className="text-content-secondary text-sm">
|
|
81
|
+
Disc 3 of 9 · reading at 6.0x
|
|
82
|
+
</span>
|
|
83
|
+
|
|
84
|
+
<Badge intent="success">Healthy</Badge>
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/** An ordinary page, so a template shows something. */
|
|
89
|
+
export const PageContent = (): ReactNode => (
|
|
90
|
+
<>
|
|
91
|
+
<Card heading="Tonight's queue">
|
|
92
|
+
<p className="text-content-secondary text-sm">
|
|
93
|
+
Nine discs, three bays. The column stays readable at
|
|
94
|
+
any window width because the cap is a token rather
|
|
95
|
+
than a number somebody typed.
|
|
96
|
+
</p>
|
|
97
|
+
</Card>
|
|
98
|
+
|
|
99
|
+
<Card
|
|
100
|
+
actions={<Button size="sm">Start</Button>}
|
|
101
|
+
heading="Bay 3"
|
|
102
|
+
>
|
|
103
|
+
<p className="text-content-secondary text-sm">
|
|
104
|
+
Idle since 21:04.
|
|
105
|
+
</p>
|
|
106
|
+
</Card>
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enough page to scroll, with **no landmarks in it**.
|
|
112
|
+
*
|
|
113
|
+
* `<PageContent />` twice would be the obvious way to make a tall
|
|
114
|
+
* page and it is an accessibility violation: `Card` takes a
|
|
115
|
+
* `region` landmark from its heading, so two copies are two pairs
|
|
116
|
+
* of identically-named regions — axe's `landmark-unique`, and the
|
|
117
|
+
* same "which one did the agent mean" ambiguity in a test. A
|
|
118
|
+
* sticky-header demo needs height, not more landmarks.
|
|
119
|
+
*/
|
|
120
|
+
export const ScrollFiller = (): ReactNode => (
|
|
121
|
+
<>
|
|
122
|
+
{[
|
|
123
|
+
"Bays are polled every two seconds.",
|
|
124
|
+
"A stalled bay is one that has not moved a byte in forty.",
|
|
125
|
+
"Verification runs after the last title, never during.",
|
|
126
|
+
"The tower's USB hub is the single point of failure.",
|
|
127
|
+
"Discs are ejected only once the hash matches.",
|
|
128
|
+
"A retry lands in a different bay when one is free.",
|
|
129
|
+
"Nothing here is a landmark, which is the point.",
|
|
130
|
+
"Scroll to watch the header stay where it is.",
|
|
131
|
+
].map((line) => (
|
|
132
|
+
<p
|
|
133
|
+
className="text-content-secondary text-sm"
|
|
134
|
+
key={line}
|
|
135
|
+
>
|
|
136
|
+
{line}
|
|
137
|
+
</p>
|
|
138
|
+
))}
|
|
139
|
+
</>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The three things that actually make a page scroll sideways, and
|
|
144
|
+
* each needs a different answer.
|
|
145
|
+
*
|
|
146
|
+
* The **path** is content that *can* wrap and does not, because
|
|
147
|
+
* nothing in it is a break opportunity. `Main`'s `wrap-anywhere`
|
|
148
|
+
* handles it, so it is dropped in raw on purpose — if the shell
|
|
149
|
+
* regresses, this is the fixture that notices.
|
|
150
|
+
*
|
|
151
|
+
* The **table** is content that genuinely cannot wrap: a table's
|
|
152
|
+
* columns have min-content widths and no amount of
|
|
153
|
+
* `overflow-wrap` reaches them. The only correct answer is a
|
|
154
|
+
* scroll container around it — and because that container
|
|
155
|
+
* scrolls, the columns off screen have to be reachable without a
|
|
156
|
+
* mouse, which is axe's `scrollable-region-focusable`.
|
|
157
|
+
*
|
|
158
|
+
* There are two ways to satisfy it and this fixture takes the
|
|
159
|
+
* better one: the row ends in a **link**, so tabbing to it
|
|
160
|
+
* scrolls the container into view and the region needs nothing
|
|
161
|
+
* else. The other way is `tabIndex={0}` on the container itself,
|
|
162
|
+
* which is what a table of pure text needs — and which Biome's
|
|
163
|
+
* `noNoninteractiveTabindex` rejects, unsafely-fixing the
|
|
164
|
+
* attribute away before a suppression is even evaluated.
|
|
165
|
+
* `Main.mdx` states both.
|
|
166
|
+
*/
|
|
167
|
+
export const OverflowingContent = (): ReactNode => (
|
|
168
|
+
<>
|
|
169
|
+
{/*
|
|
170
|
+
The third shape, and the one that broke plex-channels: a
|
|
171
|
+
closed drawer parked off the inline edge by a transform.
|
|
172
|
+
|
|
173
|
+
It is still laid out, still painted, and still part of the
|
|
174
|
+
document's scrollable overflow region — a transform does not
|
|
175
|
+
remove a box from it, and neither does `visibility: hidden`.
|
|
176
|
+
So the page scrolls sideways to reach a panel nobody can see,
|
|
177
|
+
and every `min-width: 0` in the tree is irrelevant. `Shell`'s
|
|
178
|
+
`overflow-x: clip` is what actually answers it.
|
|
179
|
+
*/}
|
|
180
|
+
<div
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
className="pointer-events-none absolute top-0 end-0 h-40 w-80 translate-x-[110%] rounded-lg border border-border-subtle bg-surface-raised"
|
|
183
|
+
id="parked-drawer"
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
<p className="text-content-secondary text-sm">
|
|
187
|
+
/mnt/Bunnies/Family/Media/Television/Some-Very-Long-Show-Name/Season-01/Some-Very-Long-Show-Name-S01E01-Pilot-2160p-HDR-DTS-HD-MA.mkv
|
|
188
|
+
</p>
|
|
189
|
+
|
|
190
|
+
<section
|
|
191
|
+
aria-label="Transfer log, scrollable"
|
|
192
|
+
className="charcuterie-scrollbar overflow-x-auto rounded-lg border border-border-subtle"
|
|
193
|
+
>
|
|
194
|
+
<table className="w-max border-collapse text-sm">
|
|
195
|
+
<caption className="p-2 text-content-muted text-xs">
|
|
196
|
+
Wider than a phone, on purpose.
|
|
197
|
+
</caption>
|
|
198
|
+
|
|
199
|
+
<thead>
|
|
200
|
+
<tr>
|
|
201
|
+
{[
|
|
202
|
+
"Started",
|
|
203
|
+
"Source volume",
|
|
204
|
+
"Destination dataset",
|
|
205
|
+
"Bytes moved",
|
|
206
|
+
"Throughput",
|
|
207
|
+
"Verified",
|
|
208
|
+
"Job",
|
|
209
|
+
].map((column) => (
|
|
210
|
+
<th
|
|
211
|
+
className="whitespace-nowrap border-border-subtle border-b p-2 text-start text-content-secondary"
|
|
212
|
+
key={column}
|
|
213
|
+
scope="col"
|
|
214
|
+
>
|
|
215
|
+
{column}
|
|
216
|
+
</th>
|
|
217
|
+
))}
|
|
218
|
+
</tr>
|
|
219
|
+
</thead>
|
|
220
|
+
|
|
221
|
+
<tbody>
|
|
222
|
+
<tr>
|
|
223
|
+
{[
|
|
224
|
+
"21:04:11",
|
|
225
|
+
"/mnt/Bunnies/Kevin/Incoming",
|
|
226
|
+
"/mnt/Bunnies/Family/Media/Television",
|
|
227
|
+
"48,912,338,112",
|
|
228
|
+
"612 MB/s",
|
|
229
|
+
"sha256 ok",
|
|
230
|
+
].map((cell) => (
|
|
231
|
+
<td
|
|
232
|
+
className="whitespace-nowrap p-2 text-content-primary"
|
|
233
|
+
key={cell}
|
|
234
|
+
>
|
|
235
|
+
{cell}
|
|
236
|
+
</td>
|
|
237
|
+
))}
|
|
238
|
+
|
|
239
|
+
{/*
|
|
240
|
+
The keyboard route into the off-screen columns.
|
|
241
|
+
Tabbing to it scrolls the container, which is what
|
|
242
|
+
axe's `scrollable-region-focusable` accepts and what
|
|
243
|
+
makes the wide table usable without a mouse.
|
|
244
|
+
*/}
|
|
245
|
+
<td className="whitespace-nowrap p-2">
|
|
246
|
+
<a
|
|
247
|
+
className="text-intent-accent-content underline"
|
|
248
|
+
href="#transfer-4812"
|
|
249
|
+
>
|
|
250
|
+
Open
|
|
251
|
+
</a>
|
|
252
|
+
</td>
|
|
253
|
+
</tr>
|
|
254
|
+
</tbody>
|
|
255
|
+
</table>
|
|
256
|
+
</section>
|
|
257
|
+
</>
|
|
258
|
+
)
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -201,10 +201,19 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
|
|
|
201
201
|
|
|
202
202
|
// A canary on the derivation itself: if this ever comes back
|
|
203
203
|
// empty the rule silently passes forever.
|
|
204
|
+
// `Main` joins them with the app shell. It is the one whose
|
|
205
|
+
// `contain: inline-size` is *wanted* rather than tolerated —
|
|
206
|
+
// content may no longer widen the content column, which is half
|
|
207
|
+
// of why the shell does not scroll sideways at 390px — and the
|
|
208
|
+
// `contain: layout` that rides along with it is documented in
|
|
209
|
+
// `Main.tsx` and `Main.mdx`, because it makes `<main>` the
|
|
210
|
+
// containing block for any `position: fixed` an app renders
|
|
211
|
+
// inside it.
|
|
204
212
|
expect(containerComponents).toEqual([
|
|
205
213
|
"Alert",
|
|
206
214
|
"Card",
|
|
207
215
|
"EmptyState",
|
|
216
|
+
"Main",
|
|
208
217
|
"MediaTile",
|
|
209
218
|
])
|
|
210
219
|
|
|
@@ -342,7 +351,14 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
342
351
|
// than components anybody stories, and live in `RouterLink/` and
|
|
343
352
|
// `reactRouter/` — outside this count by the `<Name>/<Name>.tsx`
|
|
344
353
|
// rule, exactly as `Overlay/`'s parts are.
|
|
345
|
-
|
|
354
|
+
// The unified app shell adds four: `Shell`, `Header`, `Rail`,
|
|
355
|
+
// `Main` — the fleet's largest single duplication, where ten of
|
|
356
|
+
// twelve UI repos hand-roll the page chrome and three of them
|
|
357
|
+
// have a file called `AppShell.tsx`: +4 -> 42, on top of the link family's 38. `contentWidth.ts`
|
|
358
|
+
// and `shellContext.ts` are `Shell`'s members and stay out of
|
|
359
|
+
// this count by the `<Name>/<Name>.tsx` rule, exactly as
|
|
360
|
+
// `mediaStatus.ts` does.
|
|
361
|
+
expect(componentNames.length).toBe(42)
|
|
346
362
|
|
|
347
363
|
for (const name of componentNames) {
|
|
348
364
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { page } from "@vitest/browser/context"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resize the browser and **wait until the page agrees**.
|
|
5
|
+
*
|
|
6
|
+
* `page.viewport()` resolves when the driver has asked for the
|
|
7
|
+
* resize, not when the tester frame has been laid out at the new
|
|
8
|
+
* size. On the first resize in a file that gap is real: the
|
|
9
|
+
* assertion runs against the *previous* width, so a
|
|
10
|
+
* horizontal-scroll test at 390px silently measures a 1440px page
|
|
11
|
+
* and reports a number nobody can explain. It is a race, so it
|
|
12
|
+
* fails intermittently — which is the worst possible property for
|
|
13
|
+
* the one gate that says the shell does not scroll sideways.
|
|
14
|
+
*
|
|
15
|
+
* Polling `innerWidth` is the honest wait: it is the same number
|
|
16
|
+
* the media queries and the layout resolved against, so when it
|
|
17
|
+
* matches, the thing under test really is at the width the test
|
|
18
|
+
* claims.
|
|
19
|
+
*/
|
|
20
|
+
export const setViewport = async ({
|
|
21
|
+
height,
|
|
22
|
+
width,
|
|
23
|
+
}: {
|
|
24
|
+
height: number
|
|
25
|
+
width: number
|
|
26
|
+
}): Promise<void> => {
|
|
27
|
+
await page.viewport(width, height)
|
|
28
|
+
|
|
29
|
+
const deadline = Date.now() + 2000
|
|
30
|
+
|
|
31
|
+
while (globalThis.innerWidth !== width) {
|
|
32
|
+
if (Date.now() > deadline) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`The viewport never reached ${width}px — it is ${globalThis.innerWidth}px. Every width assertion after this point would be measuring the wrong page.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await new Promise((resolve) => {
|
|
39
|
+
globalThis.requestAnimationFrame(resolve)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A phone in portrait — the width plex-channels scrolls at. */
|
|
45
|
+
export const PHONE = { height: 844, width: 390 }
|
|
46
|
+
|
|
47
|
+
export const DESKTOP = { height: 900, width: 1440 }
|