@charcuterie/ui 0.1.0 → 0.2.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 (106) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
  106. package/CHANGELOG.md +0 -13
@@ -0,0 +1,135 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ StoryCell,
5
+ StoryGrid,
6
+ } from "../board.storyHelpers.tsx"
7
+ import type { LogLine } from "./LogViewer.tsx"
8
+ import { LogViewer } from "./LogViewer.tsx"
9
+
10
+ const RIP_LINES: LogLine[] = [
11
+ {
12
+ key: "1",
13
+ text: "14:02:09 Opening /dev/sr0",
14
+ },
15
+ {
16
+ key: "2",
17
+ text: "14:02:11 AACS handshake complete, reading…",
18
+ },
19
+ {
20
+ intent: "warning",
21
+ key: "3",
22
+ text: "14:02:44 Title 4: 2 read errors, retrying",
23
+ },
24
+ {
25
+ key: "4",
26
+ text: "14:03:02 Title 4: recovered",
27
+ },
28
+ {
29
+ intent: "success",
30
+ key: "5",
31
+ text: "14:31:57 9 titles written to /mnt/rips",
32
+ },
33
+ ]
34
+
35
+ /**
36
+ * Long enough to overflow the pane, which is the only way to story
37
+ * the behaviour that matters here.
38
+ */
39
+ const LONG_LINES: LogLine[] = Array.from(
40
+ { length: 60 },
41
+ (_unused, index) => ({
42
+ key: String(index),
43
+ text: `14:${String(index).padStart(2, "0")}:00 Reading sector ${index * 2048}`,
44
+ }),
45
+ )
46
+
47
+ const meta = {
48
+ title: "Components/LogViewer",
49
+ component: LogViewer,
50
+ parameters: { layout: "padded" },
51
+ args: {
52
+ isAnnounced: false,
53
+ label: "Bay 3 rip log",
54
+ lines: RIP_LINES,
55
+ maxLines: 500,
56
+ },
57
+ } satisfies Meta<typeof LogViewer>
58
+
59
+ export default meta
60
+
61
+ type Story = StoryObj<typeof meta>
62
+
63
+ export const Default: Story = {
64
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
65
+ }
66
+
67
+ export const AllVariants: Story = {
68
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
69
+ render: () => (
70
+ <StoryGrid columns={2}>
71
+ <StoryCell label="short">
72
+ <LogViewer
73
+ label="Bay 1 rip log"
74
+ lines={RIP_LINES}
75
+ />
76
+ </StoryCell>
77
+
78
+ <StoryCell label="overflowing">
79
+ <LogViewer
80
+ label="Bay 2 rip log"
81
+ lines={LONG_LINES}
82
+ />
83
+ </StoryCell>
84
+
85
+ <StoryCell label="capped at 10 lines">
86
+ <LogViewer
87
+ label="Bay 4 rip log"
88
+ lines={LONG_LINES}
89
+ maxLines={10}
90
+ />
91
+ </StoryCell>
92
+
93
+ <StoryCell label="announced">
94
+ <LogViewer
95
+ isAnnounced
96
+ label="Bay 5 result"
97
+ lines={RIP_LINES}
98
+ />
99
+ </StoryCell>
100
+ </StoryGrid>
101
+ ),
102
+ }
103
+
104
+ /**
105
+ * Empty is a state, not a blank box. mux-magic's pane says
106
+ * "Waiting for log lines…" and rip-deck's says nothing at all,
107
+ * which reads as a broken subscription.
108
+ */
109
+ export const AllStates: Story = {
110
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
111
+ render: () => (
112
+ <StoryGrid columns={2}>
113
+ <StoryCell label="empty">
114
+ <LogViewer label="Bay 6 rip log" lines={[]} />
115
+ </StoryCell>
116
+
117
+ <StoryCell label="with intents">
118
+ <LogViewer
119
+ label="Bay 7 rip log"
120
+ lines={RIP_LINES}
121
+ />
122
+ </StoryCell>
123
+ </StoryGrid>
124
+ ),
125
+ }
126
+
127
+ /**
128
+ * Scroll the tall pane up and the **Jump to latest** button
129
+ * appears; scroll back to the bottom and it goes away. Following is
130
+ * pinned to the user's own position, which is the difference between
131
+ * a log that follows and one that fights you.
132
+ */
133
+ export const Interactive: Story = {
134
+ args: { label: "Bay 8 rip log", lines: LONG_LINES },
135
+ }
@@ -0,0 +1,173 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, waitFor } 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 "./LogViewer.stories.tsx"
9
+
10
+ const { AllStates, AllVariants, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("the pane is named, and is a log", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ // Found by its name, not by a `data-log-id`. mux-magic's pane
18
+ // carries one of those — a `data-testid` under a different name,
19
+ // which this package's `sourceRules.test.ts` bans as a practice
20
+ // rather than as a spelling.
21
+ const pane = expectAgentDrivable(canvas, {
22
+ name: "Bay 3 rip log",
23
+ role: "log",
24
+ })
25
+
26
+ await expect(pane).toHaveTextContent(
27
+ "AACS handshake complete",
28
+ )
29
+
30
+ await expectNoAxeViolations(canvasElement)
31
+ })
32
+
33
+ /**
34
+ * The fix for mux-magic's defect, asserted as the behaviour rather
35
+ * than as the precondition. Its auto-scroll runs in an effect with
36
+ * **empty deps** — once, on mount, when the pane is empty and
37
+ * `scrollHeight` is `clientHeight` — so every line after it arrives
38
+ * below the fold.
39
+ */
40
+ test("a full pane is scrolled to the bottom", async () => {
41
+ const { canvas } = await mountStory(Interactive)
42
+
43
+ const pane = expectAgentDrivable(canvas, {
44
+ name: "Bay 8 rip log",
45
+ role: "log",
46
+ })
47
+
48
+ // The precondition — that there *is* something to scroll. On its
49
+ // own this proves nothing, which is the lesson `Tabs` learned the
50
+ // expensive way from `scrollWidth > clientWidth`.
51
+ await expect(pane.scrollHeight).toBeGreaterThan(
52
+ pane.clientHeight,
53
+ )
54
+
55
+ // And the behaviour: it is actually at the end.
56
+ await waitFor(() => {
57
+ expect(
58
+ pane.scrollHeight -
59
+ pane.scrollTop -
60
+ pane.clientHeight,
61
+ ).toBeLessThan(4)
62
+ })
63
+ })
64
+
65
+ /**
66
+ * Following is pinned to the user's own position. Scrolling back to
67
+ * read the line that mentioned an error must not be undone twice a
68
+ * second — which is what the naive fix (scroll to bottom on every
69
+ * render) does.
70
+ */
71
+ test("scrolling away stops the follow and offers a way back", async () => {
72
+ const { canvas } = await mountStory(Interactive)
73
+
74
+ const pane = expectAgentDrivable(canvas, {
75
+ name: "Bay 8 rip log",
76
+ role: "log",
77
+ })
78
+
79
+ await expect(
80
+ canvas.queryAllByRole("button", {
81
+ name: "Jump to latest",
82
+ }),
83
+ ).toHaveLength(0)
84
+
85
+ pane.scrollTop = 0
86
+
87
+ pane.dispatchEvent(new Event("scroll", { bubbles: true }))
88
+
89
+ const jump = await waitFor(() =>
90
+ expectAgentDrivable(canvas, {
91
+ name: "Jump to latest",
92
+ role: "button",
93
+ }),
94
+ )
95
+
96
+ jump.click()
97
+
98
+ await waitFor(() => {
99
+ expect(
100
+ pane.scrollHeight -
101
+ pane.scrollTop -
102
+ pane.clientHeight,
103
+ ).toBeLessThan(4)
104
+ })
105
+ })
106
+
107
+ /**
108
+ * A live log emitting a line every 300 ms is a screen reader that
109
+ * cannot be interrupted. `role="log"` is correct; `aria-live` is off
110
+ * unless the lines *are* the outcome.
111
+ */
112
+ test("a log does not announce itself unless asked", async () => {
113
+ const { canvas } = await mountStory(AllVariants)
114
+
115
+ await expect(
116
+ expectAgentDrivable(canvas, {
117
+ name: "Bay 1 rip log",
118
+ role: "log",
119
+ }),
120
+ ).toHaveAttribute("aria-live", "off")
121
+
122
+ await expect(
123
+ expectAgentDrivable(canvas, {
124
+ name: "Bay 5 result",
125
+ role: "log",
126
+ }),
127
+ ).toHaveAttribute("aria-live", "polite")
128
+ })
129
+
130
+ test("maxLines drops the oldest lines from the DOM", async () => {
131
+ const { canvas } = await mountStory(AllVariants)
132
+
133
+ const capped = expectAgentDrivable(canvas, {
134
+ name: "Bay 4 rip log",
135
+ role: "log",
136
+ })
137
+
138
+ // A rip emits a line every few hundred milliseconds for an hour,
139
+ // and the browser keeps every node.
140
+ await expect(capped.querySelectorAll("p")).toHaveLength(
141
+ 10,
142
+ )
143
+
144
+ // The *last* ten, not the first — a log you have to scroll up
145
+ // through to reach the newest line is backwards.
146
+ await expect(capped).toHaveTextContent(
147
+ "Reading sector 120832",
148
+ )
149
+ })
150
+
151
+ test("a scrollable pane is reachable by keyboard", async () => {
152
+ const { canvas, canvasElement } =
153
+ await mountStory(AllStates)
154
+
155
+ // Chrome made scrollers focusable automatically in 2024; Firefox
156
+ // and Safari have not. Without it a keyboard user can see the pane
157
+ // and cannot scroll it.
158
+ await expect(
159
+ expectAgentDrivable(canvas, {
160
+ name: "Bay 7 rip log",
161
+ role: "log",
162
+ }).tabIndex,
163
+ ).toBe(0)
164
+
165
+ await expect(
166
+ expectAgentDrivable(canvas, {
167
+ name: "Bay 6 rip log",
168
+ role: "log",
169
+ }),
170
+ ).toHaveTextContent("Waiting for output…")
171
+
172
+ await expectNoAxeViolations(canvasElement)
173
+ })
@@ -0,0 +1,195 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useEffect, useRef, useState } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { INTENT_CONTENT_CLASS } from "../intentStyles.ts"
7
+ import { toClassName } from "../toClassName.ts"
8
+
9
+ export type LogLine = {
10
+ intent?: IntentName
11
+ key: string
12
+ text: string
13
+ }
14
+
15
+ export type LogViewerProps = {
16
+ className?: string
17
+ /**
18
+ * Announce new lines to a screen reader. **Off by default**, and
19
+ * that is a considered choice rather than an oversight — see
20
+ * below.
21
+ */
22
+ isAnnounced?: boolean
23
+ /** The pane's accessible name. Required. */
24
+ label: string
25
+ lines: LogLine[]
26
+ /**
27
+ * Oldest lines past this are dropped from the DOM. A rip in
28
+ * progress emits a line every few hundred milliseconds for an
29
+ * hour, and the browser keeps every node.
30
+ */
31
+ maxLines?: number
32
+ }
33
+
34
+ /**
35
+ * Three repos render a scrolling log pane —
36
+ * mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
37
+ * `LogModal` — and between them they have four defects that this
38
+ * component exists to not have.
39
+ *
40
+ * ### 1. The one that follows nothing
41
+ *
42
+ * ```tsx
43
+ * useEffect(() => {
44
+ * const pane = paneRef.current
45
+ * if (pane) pane.scrollTop = pane.scrollHeight
46
+ * }, []) // ← empty deps
47
+ * ```
48
+ *
49
+ * That is mux-magic's auto-scroll, and it runs **once**, on mount,
50
+ * when the pane is empty and `scrollHeight` is `clientHeight`. Every
51
+ * line after it arrives below the fold. It reads as correct, it is
52
+ * the shape everyone writes, and the log simply never follows.
53
+ *
54
+ * ### 2. Following that cannot be escaped
55
+ *
56
+ * The naive fix — scroll to the bottom on every render — is worse:
57
+ * a user scrolling back to read the line that mentioned an error is
58
+ * yanked to the end twice a second. Following has to be *pinned to
59
+ * the user's own position*, which is what `isFollowing` below is: it
60
+ * turns off the moment the user scrolls away and back on when they
61
+ * return to the bottom, and there is a button for when they do not
62
+ * want to scroll all that way.
63
+ *
64
+ * ### 3. `data-log-id`
65
+ *
66
+ * mux-magic's pane carries one. It is a `data-testid` under a
67
+ * different name — a handle only the test suite can see — and this
68
+ * package's `sourceRules.test.ts` bans the practice rather than the
69
+ * spelling. The pane here is found by its `label`, the same query
70
+ * Playwright makes.
71
+ *
72
+ * ### 4. Announcing an hour of build output
73
+ *
74
+ * `role="log"` is the correct role and `aria-live` is off unless
75
+ * asked for. A live log pane emitting a line every 300 ms is a
76
+ * screen reader that cannot be interrupted and a user who cannot
77
+ * read anything else on the page — the accessible answer for a
78
+ * long-running job is a `LiveStatusIndicator` announcing *state*
79
+ * and a log that is there to be read when wanted. `isAnnounced`
80
+ * exists for the short case: a five-line result panel where the
81
+ * lines *are* the outcome.
82
+ *
83
+ * ### Not every boolean is a state kind
84
+ *
85
+ * `isFollowing` is `useState`, not `useVisibility`, and this is the
86
+ * first component in the library to say so out loud. The kinds in
87
+ * `@charcuterie/logic` earn their keep when state is **registered**
88
+ * (members join a group), **composed** (focus and selection
89
+ * disagreeing), or **shared** (a trigger and a panel at opposite
90
+ * ends of a tree). None of that is true here: one component reads
91
+ * one boolean it wrote itself. Reaching for `useVisibility` because
92
+ * it is also a boolean would make `isVisible` mean "is following",
93
+ * which is how a state layer stops meaning anything.
94
+ */
95
+ export const LogViewer = ({
96
+ className,
97
+ isAnnounced = false,
98
+ label,
99
+ lines,
100
+ maxLines = 500,
101
+ }: LogViewerProps): ReactNode => {
102
+ const paneRef = useRef<HTMLDivElement>(null)
103
+
104
+ const [isFollowing, setIsFollowing] = useState(true)
105
+
106
+ const shownLines =
107
+ lines.length > maxLines
108
+ ? lines.slice(lines.length - maxLines)
109
+ : lines
110
+
111
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `shownLines` is read through the DOM, not through the closure — `pane.scrollHeight` is a *consequence* of the lines having rendered, so the linter sees a dependency it cannot trace and calls it unnecessary. Removing it is not a style change: the effect then runs only when `isFollowing` flips, which is mux-magic's `}, [])` bug rebuilt exactly. `yarn lint` runs `--write --unsafe`, so this was applied silently once already and the log stopped following.
112
+ useEffect(() => {
113
+ const pane = paneRef.current
114
+
115
+ if (!pane || !isFollowing) {
116
+ return
117
+ }
118
+
119
+ pane.scrollTop = pane.scrollHeight
120
+ // `shownLines` rather than an empty array — this is the whole fix
121
+ // for defect 1, and the dependency *is* the fix.
122
+ }, [isFollowing, shownLines])
123
+
124
+ return (
125
+ <div
126
+ className={toClassName(
127
+ "relative flex flex-col",
128
+ className,
129
+ )}
130
+ >
131
+ <div
132
+ aria-label={label}
133
+ // `off` unless asked. See the note above; this is the
134
+ // difference between an accessible log and an unusable page.
135
+ aria-live={isAnnounced ? "polite" : "off"}
136
+ className="max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs"
137
+ onScroll={(scrollEvent) => {
138
+ const pane = scrollEvent.currentTarget
139
+
140
+ // A few pixels of slack, because a fractional device pixel
141
+ // ratio means `scrollTop + clientHeight` never lands
142
+ // exactly on `scrollHeight` — an equality test here
143
+ // un-pins the pane on a 125% display and on nothing else,
144
+ // which is the kind of bug that gets reported as "it works
145
+ // on my machine".
146
+ const isPinnedToBottom =
147
+ pane.scrollHeight -
148
+ pane.scrollTop -
149
+ pane.clientHeight <
150
+ 4
151
+
152
+ setIsFollowing(isPinnedToBottom)
153
+ }}
154
+ ref={paneRef}
155
+ role="log"
156
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Chrome started doing it automatically in 2024; Firefox and Safari have not. The rule is right about `<div>`s in general and wrong about scrollers.
157
+ tabIndex={0}
158
+ >
159
+ {shownLines.length === 0 ? (
160
+ <p className="text-content-muted">
161
+ Waiting for output…
162
+ </p>
163
+ ) : (
164
+ shownLines.map((line) => (
165
+ <p
166
+ className={toClassName(
167
+ "whitespace-pre-wrap break-all",
168
+ line.intent === undefined
169
+ ? null
170
+ : INTENT_CONTENT_CLASS[line.intent],
171
+ )}
172
+ key={line.key}
173
+ >
174
+ {line.text}
175
+ </p>
176
+ ))
177
+ )}
178
+ </div>
179
+
180
+ {isFollowing ? null : (
181
+ <Button
182
+ appearance="soft"
183
+ className="absolute inset-x-0 bottom-2 mx-auto w-fit"
184
+ intent="neutral"
185
+ onClick={() => {
186
+ setIsFollowing(true)
187
+ }}
188
+ size="sm"
189
+ >
190
+ Jump to latest
191
+ </Button>
192
+ )}
193
+ </div>
194
+ )
195
+ }
@@ -0,0 +1,103 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MenuStories from "./Menu.stories";
4
+
5
+ <Meta of={MenuStories} />
6
+
7
+ # Menu
8
+
9
+ A menu is **not** a listbox, and mux-magic's `TypePicker` is the fleet's one attempt at
10
+ telling them apart — it renders `role="menu"` over items that set a value, which is a
11
+ listbox wearing the wrong role.
12
+
13
+ <Canvas of={MenuStories.Default} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## `menuitem` versus `option`
20
+
21
+ | | `menuitem` | `option` |
22
+ | --- | --- | --- |
23
+ | What it is | an **action** — Retry, Skip, Eject | a **value** you are choosing |
24
+ | Announced as | "menu, 3 items" | "listbox, selected, 2 of 4" |
25
+ | Leaves state behind | no | yes |
26
+ | Lives in | this component | `Select` |
27
+
28
+ The last row is why it matters to an agent driving the app: after choosing an `option` it
29
+ needs to know what the control now holds. After invoking a `menuitem` there is nothing to
30
+ read back. Getting the role wrong is invisible in a browser and invisible to axe.
31
+
32
+ ## A `[popover]` that was never shown is `display: none`
33
+
34
+ The panel calls `showPopover()` from its ref callback, like `Popover` does — plus an
35
+ `isConnected` guard and an effect that `Popover` does not need.
36
+
37
+ `FloatingFocusManager` renders a pair of focus guards around its child, so React commits this
38
+ subtree in an order where the ref can fire while the node is still **detached**.
39
+ `showPopover()` on a disconnected element throws `InvalidStateError`, which surfaces as an
40
+ unhandled rejection rather than a failing assertion — and if it is simply swallowed, the menu
41
+ renders, typechecks, and is invisible to `getByRole` and to a user alike.
42
+
43
+ The effect is the backstop, because effects run after the tree is connected. Both are
44
+ idempotent: `:popover-open` is the same question.
45
+
46
+ ## There is no `label` prop, and that corrects a lesson
47
+
48
+ `Spinner` (M3), `Popover` and `Modal` (M4) all take a **required** name, because a
49
+ `role="status"` and a `role="dialog"` take none from their content. The obvious move here was
50
+ a `label` prop doing the same job — it was written that way first, and then measured as
51
+ doing **nothing**.
52
+
53
+ `useRole(context, { role: "menu" })` puts `aria-labelledby` on the panel pointing at the
54
+ trigger, and `aria-labelledby` beats `aria-label`. The menu was already named "Bay 3", from
55
+ the button, and the prop was silently discarded. That is also the APG menu-button pattern.
56
+
57
+ So the rule the other three teach is narrower than it looked:
58
+
59
+ > An overlay with **no trigger relationship** needs its own name.
60
+
61
+ A menu has one, so the requirement moves to the trigger — which `Button` and `IconButton`
62
+ already enforce.
63
+
64
+ ## One kind, and that is the other half of the `Tabs` argument
65
+
66
+ `Tabs` needed **two** state kinds because a tab is chosen *and* focused, and in `manual`
67
+ activation those two disagree on purpose. A menu needs **one** — `RovingFocus` — because a
68
+ menu item is never selected, only invoked.
69
+
70
+ Two components, opposite conclusions, same model. That is the composition claim being paid
71
+ off rather than asserted.
72
+
73
+ <Canvas of={MenuStories.AllVariants} />
74
+
75
+ ## Focus moves in, once
76
+
77
+ A menu is one of the few patterns where focus must jump on open, or the keyboard user who
78
+ pressed Enter is still on the trigger with a menu they cannot reach.
79
+
80
+ It happens in the panel's own effect rather than through `FloatingFocusManager`'s
81
+ `initialFocus`, because the element to focus is whichever one `RovingFocus` says is active —
82
+ and that is not known until the items have registered. `initialFocus={-1}` turns the
83
+ manager's own aim off, so there is one owner for the caret.
84
+
85
+ `modal={false}`, so Tab **leaves** the menu and lands on the next control in the page, which
86
+ is what closes it. A trapped menu is a dialog.
87
+
88
+ <Canvas of={MenuStories.AllStates} />
89
+
90
+ ## Registration is membership
91
+
92
+ A disabled item never registers with the roving group, so the arrow keys skip it with
93
+ nothing in `RovingFocus` knowing what "disabled" means. It stays in the DOM and stays
94
+ announced — "not right now", rather than "does not exist".
95
+
96
+ ## No type-ahead, deliberately
97
+
98
+ The APG lists type-ahead as **optional** for a menu and **required** for a listbox, and none
99
+ of the fleet's menus are long enough to need it — the largest has six items. Adding it means
100
+ a keystroke buffer and a timer in a component that has neither, for a menu you can cross in
101
+ three arrow presses.
102
+
103
+ <Canvas of={MenuStories.Interactive} />