@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.
Files changed (80) hide show
  1. package/README.md +9 -0
  2. package/dist/Dialog/Dialog.d.ts.map +1 -1
  3. package/dist/Dialog/Dialog.js +3 -1
  4. package/dist/Dialog/Dialog.js.map +1 -1
  5. package/dist/Field/Field.d.ts.map +1 -1
  6. package/dist/Field/Field.js +1 -1
  7. package/dist/Field/Field.js.map +1 -1
  8. package/dist/Field/FieldGroup.d.ts.map +1 -1
  9. package/dist/Field/FieldGroup.js +1 -1
  10. package/dist/Field/FieldGroup.js.map +1 -1
  11. package/dist/Header/Header.d.ts +52 -0
  12. package/dist/Header/Header.d.ts.map +1 -0
  13. package/dist/Header/Header.js +55 -0
  14. package/dist/Header/Header.js.map +1 -0
  15. package/dist/Main/Main.d.ts +113 -0
  16. package/dist/Main/Main.d.ts.map +1 -0
  17. package/dist/Main/Main.js +123 -0
  18. package/dist/Main/Main.js.map +1 -0
  19. package/dist/Rail/Rail.d.ts +61 -0
  20. package/dist/Rail/Rail.d.ts.map +1 -0
  21. package/dist/Rail/Rail.js +64 -0
  22. package/dist/Rail/Rail.js.map +1 -0
  23. package/dist/Shell/Shell.d.ts +112 -0
  24. package/dist/Shell/Shell.d.ts.map +1 -0
  25. package/dist/Shell/Shell.js +116 -0
  26. package/dist/Shell/Shell.js.map +1 -0
  27. package/dist/Shell/contentWidth.d.ts +63 -0
  28. package/dist/Shell/contentWidth.d.ts.map +1 -0
  29. package/dist/Shell/contentWidth.js +74 -0
  30. package/dist/Shell/contentWidth.js.map +1 -0
  31. package/dist/Shell/shellContext.d.ts +36 -0
  32. package/dist/Shell/shellContext.d.ts.map +1 -0
  33. package/dist/Shell/shellContext.js +32 -0
  34. package/dist/Shell/shellContext.js.map +1 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  36. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  38. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  39. package/dist/Tooltip/Tooltip.js +5 -1
  40. package/dist/Tooltip/Tooltip.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  46. package/dist/mountStory.testHelpers.js +56 -0
  47. package/dist/mountStory.testHelpers.js.map +1 -1
  48. package/dist/viewport.testHelpers.d.ts +31 -0
  49. package/dist/viewport.testHelpers.d.ts.map +1 -0
  50. package/dist/viewport.testHelpers.js +34 -0
  51. package/dist/viewport.testHelpers.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/Dialog/Dialog.tsx +17 -1
  54. package/src/Field/Field.tsx +10 -2
  55. package/src/Field/FieldGroup.tsx +4 -2
  56. package/src/Header/Header.mdx +72 -0
  57. package/src/Header/Header.stories.tsx +221 -0
  58. package/src/Header/Header.test.tsx +122 -0
  59. package/src/Header/Header.tsx +118 -0
  60. package/src/Main/Main.mdx +149 -0
  61. package/src/Main/Main.stories.tsx +164 -0
  62. package/src/Main/Main.test.tsx +167 -0
  63. package/src/Main/Main.tsx +174 -0
  64. package/src/Rail/Rail.mdx +75 -0
  65. package/src/Rail/Rail.stories.tsx +177 -0
  66. package/src/Rail/Rail.test.tsx +150 -0
  67. package/src/Rail/Rail.tsx +102 -0
  68. package/src/Shell/Shell.mdx +186 -0
  69. package/src/Shell/Shell.stories.tsx +185 -0
  70. package/src/Shell/Shell.test.tsx +267 -0
  71. package/src/Shell/Shell.tsx +170 -0
  72. package/src/Shell/contentWidth.ts +89 -0
  73. package/src/Shell/shellContext.ts +41 -0
  74. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  75. package/src/Tooltip/Tooltip.tsx +5 -1
  76. package/src/index.ts +16 -0
  77. package/src/mountStory.testHelpers.ts +63 -0
  78. package/src/shell.storyHelpers.tsx +258 -0
  79. package/src/sourceRules.test.ts +17 -1
  80. package/src/viewport.testHelpers.ts +47 -0
@@ -62,6 +62,67 @@ type MountableStory = {
62
62
  */
63
63
  const mountedCanvases: HTMLElement[] = []
64
64
 
65
+ /**
66
+ * One frame past the mount, so post-mount effects have applied
67
+ * before anything reads the DOM.
68
+ *
69
+ * `storybook-addon-pseudo-states` applies its forced
70
+ * `:hover`/`:focus`/`:active` classes **a tick after render**, not
71
+ * during it — `vrtCapture.mjs` documents the same window and closes
72
+ * it with a settle, after "a real flake seen on `*--all-states`
73
+ * stories".
74
+ *
75
+ * This suite has the identical window and never closed it, and the
76
+ * consequence is worse here than a pixel diff: the a11y addon's
77
+ * `afterEach` runs axe the moment `run()` resolves, so a story
78
+ * whose forced state lands late is audited **un-forced**, and one
79
+ * whose state lands early is audited **hovered**. That is a
80
+ * contrast check reading `intent-accent-solid` on one run and
81
+ * `intent-accent-solid-hover` on the next — which is precisely the
82
+ * shape of `ButtonLink`'s intermittent `color-contrast` failure in
83
+ * CI, reported against `#6A64F0`, the daylight `solidHover`.
84
+ *
85
+ * Two frames rather than a fixed delay: React flushes effects and
86
+ * the addon applies classes within a frame, so this is a barrier
87
+ * tied to the browser's own scheduling instead of a millisecond
88
+ * count guessed against one machine. `vrtCapture` can afford its
89
+ * 400ms because it takes ~700 shots; this runs before every one of
90
+ * 222 tests.
91
+ */
92
+ /**
93
+ * Captured at module load, **before any test can replace it**.
94
+ *
95
+ * `Toast.test.tsx` stubs `requestAnimationFrame` and *holds* the
96
+ * callbacks, deliberately, to prove that a dismiss arriving during
97
+ * the enter frame is not undone by it. A settle that called the
98
+ * live `globalThis.requestAnimationFrame` would hand its own
99
+ * continuation to that stub and never be released — which is
100
+ * exactly what happened: three Toast tests went from passing to a
101
+ * flat 15s timeout.
102
+ *
103
+ * Binding the native one keeps a true frame barrier while staying
104
+ * immune to a stub, and it does not disturb the test doing the
105
+ * stubbing: the component's frames still go to its stub, only this
106
+ * helper's do not.
107
+ */
108
+ const requestNativeFrame =
109
+ globalThis.requestAnimationFrame?.bind(globalThis)
110
+
111
+ const settlePostMountEffects = () =>
112
+ new Promise<void>((resolve) => {
113
+ if (!requestNativeFrame) {
114
+ globalThis.setTimeout(resolve, 0)
115
+
116
+ return
117
+ }
118
+
119
+ requestNativeFrame(() => {
120
+ requestNativeFrame(() => {
121
+ resolve()
122
+ })
123
+ })
124
+ })
125
+
65
126
  export const mountStory = async (
66
127
  story: MountableStory,
67
128
  ): Promise<{
@@ -87,6 +148,8 @@ export const mountStory = async (
87
148
 
88
149
  await story.run({ canvasElement })
89
150
 
151
+ await settlePostMountEffects()
152
+
90
153
  return {
91
154
  body: within(document.body),
92
155
  canvas: within(canvasElement),
@@ -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
+ )
@@ -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
- expect(componentNames.length).toBe(38)
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 }