@charcuterie/ui 0.1.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +161 -0
  3. package/dist/Alert/Alert.d.ts +88 -0
  4. package/dist/Alert/Alert.d.ts.map +1 -0
  5. package/dist/Alert/Alert.js +67 -0
  6. package/dist/Alert/Alert.js.map +1 -0
  7. package/dist/Badge/Badge.d.ts +54 -0
  8. package/dist/Badge/Badge.d.ts.map +1 -0
  9. package/dist/Badge/Badge.js +54 -0
  10. package/dist/Badge/Badge.js.map +1 -0
  11. package/dist/Badge/useIsTextClipped.d.ts +22 -0
  12. package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
  13. package/dist/Badge/useIsTextClipped.js +57 -0
  14. package/dist/Badge/useIsTextClipped.js.map +1 -0
  15. package/dist/Button/Button.d.ts +58 -0
  16. package/dist/Button/Button.d.ts.map +1 -0
  17. package/dist/Button/Button.js +37 -0
  18. package/dist/Button/Button.js.map +1 -0
  19. package/dist/Card/Card.d.ts +44 -0
  20. package/dist/Card/Card.d.ts.map +1 -0
  21. package/dist/Card/Card.js +59 -0
  22. package/dist/Card/Card.js.map +1 -0
  23. package/dist/EmptyState/EmptyState.d.ts +42 -0
  24. package/dist/EmptyState/EmptyState.d.ts.map +1 -0
  25. package/dist/EmptyState/EmptyState.js +37 -0
  26. package/dist/EmptyState/EmptyState.js.map +1 -0
  27. package/dist/IconButton/IconButton.d.ts +34 -0
  28. package/dist/IconButton/IconButton.d.ts.map +1 -0
  29. package/dist/IconButton/IconButton.js +23 -0
  30. package/dist/IconButton/IconButton.js.map +1 -0
  31. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
  32. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
  33. package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
  34. package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
  35. package/dist/MediaTile/MediaTile.d.ts +46 -0
  36. package/dist/MediaTile/MediaTile.d.ts.map +1 -0
  37. package/dist/MediaTile/MediaTile.js +79 -0
  38. package/dist/MediaTile/MediaTile.js.map +1 -0
  39. package/dist/MediaTile/mediaStatus.d.ts +22 -0
  40. package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
  41. package/dist/MediaTile/mediaStatus.js +24 -0
  42. package/dist/MediaTile/mediaStatus.js.map +1 -0
  43. package/dist/Modal/Modal.d.ts +66 -0
  44. package/dist/Modal/Modal.d.ts.map +1 -0
  45. package/dist/Modal/Modal.js +111 -0
  46. package/dist/Modal/Modal.js.map +1 -0
  47. package/dist/Modal/lockScrollBehind.d.ts +22 -0
  48. package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
  49. package/dist/Modal/lockScrollBehind.js +48 -0
  50. package/dist/Modal/lockScrollBehind.js.map +1 -0
  51. package/dist/Popover/Popover.d.ts +66 -0
  52. package/dist/Popover/Popover.d.ts.map +1 -0
  53. package/dist/Popover/Popover.js +88 -0
  54. package/dist/Popover/Popover.js.map +1 -0
  55. package/dist/ProgressBar/ProgressBar.d.ts +51 -0
  56. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
  57. package/dist/ProgressBar/ProgressBar.js +50 -0
  58. package/dist/ProgressBar/ProgressBar.js.map +1 -0
  59. package/dist/ProgressBar/progressValue.d.ts +44 -0
  60. package/dist/ProgressBar/progressValue.d.ts.map +1 -0
  61. package/dist/ProgressBar/progressValue.js +55 -0
  62. package/dist/ProgressBar/progressValue.js.map +1 -0
  63. package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
  64. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  65. package/dist/SegmentedControl/SegmentedControl.js +129 -0
  66. package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
  67. package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
  68. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
  69. package/dist/SegmentedControl/SegmentedOption.js +40 -0
  70. package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
  71. package/dist/Skeleton/Skeleton.d.ts +30 -0
  72. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  73. package/dist/Skeleton/Skeleton.js +43 -0
  74. package/dist/Skeleton/Skeleton.js.map +1 -0
  75. package/dist/Spinner/Spinner.d.ts +37 -0
  76. package/dist/Spinner/Spinner.d.ts.map +1 -0
  77. package/dist/Spinner/Spinner.js +33 -0
  78. package/dist/Spinner/Spinner.js.map +1 -0
  79. package/dist/Tabs/TabTrigger.d.ts +32 -0
  80. package/dist/Tabs/TabTrigger.d.ts.map +1 -0
  81. package/dist/Tabs/TabTrigger.js +62 -0
  82. package/dist/Tabs/TabTrigger.js.map +1 -0
  83. package/dist/Tabs/Tabs.d.ts +95 -0
  84. package/dist/Tabs/Tabs.d.ts.map +1 -0
  85. package/dist/Tabs/Tabs.js +195 -0
  86. package/dist/Tabs/Tabs.js.map +1 -0
  87. package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
  88. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  89. package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
  90. package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
  91. package/dist/controlStyles.d.ts +50 -0
  92. package/dist/controlStyles.d.ts.map +1 -0
  93. package/dist/controlStyles.js +60 -0
  94. package/dist/controlStyles.js.map +1 -0
  95. package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
  96. package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
  97. package/dist/expectNoAxeViolations.testHelpers.js +32 -0
  98. package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
  99. package/dist/index.d.ts +75 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +55 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/intentStyles.d.ts +76 -0
  104. package/dist/intentStyles.d.ts.map +1 -0
  105. package/dist/intentStyles.js +150 -0
  106. package/dist/intentStyles.js.map +1 -0
  107. package/dist/mountStory.testHelpers.d.ts +30 -0
  108. package/dist/mountStory.testHelpers.d.ts.map +1 -0
  109. package/dist/mountStory.testHelpers.js +19 -0
  110. package/dist/mountStory.testHelpers.js.map +1 -0
  111. package/dist/statusIntent.d.ts +33 -0
  112. package/dist/statusIntent.d.ts.map +1 -0
  113. package/dist/statusIntent.js +99 -0
  114. package/dist/statusIntent.js.map +1 -0
  115. package/dist/styles.css +130 -0
  116. package/dist/testing/expectAgentDrivable.d.ts +53 -0
  117. package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
  118. package/dist/testing/expectAgentDrivable.js +163 -0
  119. package/dist/testing/expectAgentDrivable.js.map +1 -0
  120. package/dist/testing/index.d.ts +11 -0
  121. package/dist/testing/index.d.ts.map +1 -0
  122. package/dist/testing/index.js +10 -0
  123. package/dist/testing/index.js.map +1 -0
  124. package/dist/toClassName.d.ts +12 -0
  125. package/dist/toClassName.d.ts.map +1 -0
  126. package/dist/toClassName.js +12 -0
  127. package/dist/toClassName.js.map +1 -0
  128. package/dist/tokens.d.ts +16 -0
  129. package/dist/tokens.d.ts.map +1 -0
  130. package/dist/tokens.js +16 -0
  131. package/dist/tokens.js.map +1 -0
  132. package/package.json +70 -0
  133. package/src/Alert/Alert.mdx +70 -0
  134. package/src/Alert/Alert.stories.tsx +218 -0
  135. package/src/Alert/Alert.test.tsx +112 -0
  136. package/src/Alert/Alert.tsx +166 -0
  137. package/src/Badge/Badge.mdx +43 -0
  138. package/src/Badge/Badge.stories.tsx +229 -0
  139. package/src/Badge/Badge.test.tsx +153 -0
  140. package/src/Badge/Badge.tsx +130 -0
  141. package/src/Badge/useIsTextClipped.ts +77 -0
  142. package/src/Button/Button.mdx +44 -0
  143. package/src/Button/Button.stories.tsx +175 -0
  144. package/src/Button/Button.test.tsx +114 -0
  145. package/src/Button/Button.tsx +125 -0
  146. package/src/Card/Card.mdx +53 -0
  147. package/src/Card/Card.stories.tsx +207 -0
  148. package/src/Card/Card.test.tsx +56 -0
  149. package/src/Card/Card.tsx +142 -0
  150. package/src/EmptyState/EmptyState.mdx +40 -0
  151. package/src/EmptyState/EmptyState.stories.tsx +183 -0
  152. package/src/EmptyState/EmptyState.test.tsx +41 -0
  153. package/src/EmptyState/EmptyState.tsx +114 -0
  154. package/src/IconButton/IconButton.mdx +34 -0
  155. package/src/IconButton/IconButton.stories.tsx +191 -0
  156. package/src/IconButton/IconButton.test.tsx +70 -0
  157. package/src/IconButton/IconButton.tsx +54 -0
  158. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
  159. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
  160. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
  161. package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
  162. package/src/MediaTile/MediaTile.mdx +51 -0
  163. package/src/MediaTile/MediaTile.stories.tsx +211 -0
  164. package/src/MediaTile/MediaTile.test.tsx +41 -0
  165. package/src/MediaTile/MediaTile.tsx +227 -0
  166. package/src/MediaTile/mediaStatus.ts +28 -0
  167. package/src/Modal/Modal.mdx +113 -0
  168. package/src/Modal/Modal.stories.tsx +272 -0
  169. package/src/Modal/Modal.test.tsx +248 -0
  170. package/src/Modal/Modal.tsx +228 -0
  171. package/src/Modal/lockScrollBehind.ts +59 -0
  172. package/src/Popover/Popover.mdx +102 -0
  173. package/src/Popover/Popover.stories.tsx +247 -0
  174. package/src/Popover/Popover.test.tsx +191 -0
  175. package/src/Popover/Popover.tsx +164 -0
  176. package/src/ProgressBar/ProgressBar.mdx +48 -0
  177. package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
  178. package/src/ProgressBar/ProgressBar.test.tsx +74 -0
  179. package/src/ProgressBar/ProgressBar.tsx +172 -0
  180. package/src/ProgressBar/progressValue.test.ts +101 -0
  181. package/src/ProgressBar/progressValue.ts +87 -0
  182. package/src/SegmentedControl/SegmentedControl.mdx +71 -0
  183. package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
  184. package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
  185. package/src/SegmentedControl/SegmentedControl.tsx +219 -0
  186. package/src/SegmentedControl/SegmentedOption.tsx +94 -0
  187. package/src/Skeleton/Skeleton.mdx +30 -0
  188. package/src/Skeleton/Skeleton.stories.tsx +130 -0
  189. package/src/Skeleton/Skeleton.test.tsx +47 -0
  190. package/src/Skeleton/Skeleton.tsx +106 -0
  191. package/src/Spinner/Spinner.mdx +36 -0
  192. package/src/Spinner/Spinner.stories.tsx +118 -0
  193. package/src/Spinner/Spinner.test.tsx +38 -0
  194. package/src/Spinner/Spinner.tsx +92 -0
  195. package/src/Tabs/TabTrigger.tsx +126 -0
  196. package/src/Tabs/Tabs.mdx +126 -0
  197. package/src/Tabs/Tabs.stories.tsx +187 -0
  198. package/src/Tabs/Tabs.test.tsx +283 -0
  199. package/src/Tabs/Tabs.tsx +333 -0
  200. package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
  201. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
  202. package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  203. package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
  204. package/src/argTypes.storyHelpers.ts +115 -0
  205. package/src/board.storyHelpers.tsx +150 -0
  206. package/src/controlStyles.ts +79 -0
  207. package/src/expectNoAxeViolations.testHelpers.ts +41 -0
  208. package/src/icons.storyHelpers.tsx +116 -0
  209. package/src/index.ts +132 -0
  210. package/src/intentStyles.ts +207 -0
  211. package/src/mdxReferences.test.ts +95 -0
  212. package/src/mountStory.testHelpers.ts +41 -0
  213. package/src/sourceRules.test.ts +303 -0
  214. package/src/statusIntent.test.ts +136 -0
  215. package/src/statusIntent.ts +137 -0
  216. package/src/storyControls.test.ts +128 -0
  217. package/src/styles.css +130 -0
  218. package/src/tailwindCandidates.test.ts +377 -0
  219. package/src/testing/expectAgentDrivable.test.tsx +71 -0
  220. package/src/testing/expectAgentDrivable.ts +273 -0
  221. package/src/testing/index.ts +17 -0
  222. package/src/toClassName.ts +13 -0
  223. package/src/tokens.ts +16 -0
@@ -0,0 +1,187 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { toStoryChoice } from "../argTypes.storyHelpers.ts"
4
+ import { Badge } from "../Badge/Badge.tsx"
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
11
+ import type { TabItem } from "./Tabs.tsx"
12
+ import { Tabs } from "./Tabs.tsx"
13
+
14
+ const BAY_TABS: TabItem[] = [
15
+ {
16
+ content: (
17
+ <ProgressBar
18
+ isValueShown
19
+ label="Ripping title 4 of 9"
20
+ value={47}
21
+ />
22
+ ),
23
+ key: "progress",
24
+ label: "Progress",
25
+ },
26
+ {
27
+ content: (
28
+ <p className="font-mono text-content-secondary text-xs">
29
+ 14:02:11 AACS handshake complete, reading…
30
+ </p>
31
+ ),
32
+ key: "log",
33
+ label: "Log",
34
+ },
35
+ {
36
+ content: (
37
+ <p className="text-content-secondary text-sm">
38
+ Blade Runner (1982) · 9 titles · MakeMKV 1.17.8
39
+ </p>
40
+ ),
41
+ key: "disc",
42
+ label: "Disc",
43
+ },
44
+ ]
45
+
46
+ const meta = {
47
+ title: "Components/Tabs",
48
+ component: Tabs,
49
+ parameters: { layout: "padded" },
50
+ argTypes: {
51
+ activation: toStoryChoice([
52
+ "automatic",
53
+ "manual",
54
+ ] as const),
55
+ orientation: toStoryChoice([
56
+ "horizontal",
57
+ "vertical",
58
+ ] as const),
59
+ },
60
+ args: {
61
+ activation: "automatic",
62
+ orientation: "horizontal",
63
+ },
64
+ } satisfies Meta<typeof Tabs>
65
+
66
+ export default meta
67
+
68
+ type Story = StoryObj<typeof meta>
69
+
70
+ export const Default: Story = {
71
+ args: { label: "Bay 3", tabs: BAY_TABS },
72
+ }
73
+
74
+ export const AllVariants: Story = {
75
+ args: { label: "Bay 3", tabs: BAY_TABS },
76
+ render: () => (
77
+ <StoryGrid columns={2}>
78
+ <StoryCell label="horizontal · automatic">
79
+ <Tabs label="Bay 3 horizontal" tabs={BAY_TABS} />
80
+ </StoryCell>
81
+
82
+ <StoryCell label="vertical · automatic">
83
+ <Tabs
84
+ label="Bay 3 vertical"
85
+ orientation="vertical"
86
+ tabs={BAY_TABS}
87
+ />
88
+ </StoryCell>
89
+
90
+ <StoryCell label="horizontal · manual">
91
+ <Tabs
92
+ activation="manual"
93
+ label="Bay 3 manual"
94
+ tabs={BAY_TABS}
95
+ />
96
+ </StoryCell>
97
+
98
+ <StoryCell label="starts on the second tab">
99
+ <Tabs
100
+ activeKey="log"
101
+ label="Bay 3 starting on log"
102
+ tabs={BAY_TABS}
103
+ />
104
+ </StoryCell>
105
+ </StoryGrid>
106
+ ),
107
+ }
108
+
109
+ const STATE_TABS: TabItem[] = [
110
+ ...BAY_TABS,
111
+ {
112
+ content: (
113
+ <p className="text-content-secondary text-sm">
114
+ Unreachable — this bay has no verdict yet.
115
+ </p>
116
+ ),
117
+ isDisabled: true,
118
+ key: "verdict",
119
+ label: "Verdict",
120
+ },
121
+ {
122
+ content: (
123
+ <div className="flex gap-2">
124
+ <Badge intent="warning" size="sm">
125
+ 1 retry
126
+ </Badge>
127
+
128
+ <Badge intent="info" size="sm">
129
+ AACS
130
+ </Badge>
131
+ </div>
132
+ ),
133
+ key: "flags",
134
+ label: "Flags",
135
+ },
136
+ ]
137
+
138
+ /**
139
+ * A disabled tab is out of the *focus* group and still one of the
140
+ * *options* — it owns a panel and an id either way. Registration is
141
+ * membership, so the arrow keys skip it with nothing in
142
+ * `RovingFocus` knowing the word "disabled".
143
+ */
144
+ export const AllStates: Story = {
145
+ args: { label: "Bay 3", tabs: STATE_TABS },
146
+ }
147
+
148
+ /**
149
+ * A tab bar **scrolls**, it does not wrap. A wrapped tablist puts
150
+ * tabs on two rows, which breaks both the visual row and the
151
+ * mental model the arrow keys give — "right" stops meaning right.
152
+ *
153
+ * `ContainerBoard` is used here as a width harness rather than as
154
+ * a container-query claim: `Tabs` declares no container, because
155
+ * nothing about it needs to *know* its width. It just has to
156
+ * survive being narrow.
157
+ */
158
+ export const Responsive: Story = {
159
+ args: { label: "Bay 3", tabs: STATE_TABS },
160
+ render: () => (
161
+ <ContainerBoard>
162
+ {(width) => (
163
+ <Tabs
164
+ label={`Bay 3 at ${width}`}
165
+ tabs={STATE_TABS}
166
+ />
167
+ )}
168
+ </ContainerBoard>
169
+ ),
170
+ }
171
+
172
+ /**
173
+ * The falsification point, and the reason `Tabs` is P0.
174
+ *
175
+ * In `manual`, an arrow key moves focus to a tab that is *not*
176
+ * chosen — a state a three-kind model cannot represent at all,
177
+ * because it had one notion of "the current one" doing both jobs.
178
+ * Arrow through this one and watch the underline stay put until
179
+ * Enter.
180
+ */
181
+ export const Manual: Story = {
182
+ args: {
183
+ activation: "manual",
184
+ label: "Bay 3 manual",
185
+ tabs: BAY_TABS,
186
+ },
187
+ }
@@ -0,0 +1,283 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, 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 "./Tabs.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Default,
14
+ Manual,
15
+ Responsive,
16
+ } = composeStories(stories)
17
+
18
+ test("selecting a tab wires the panel back to it", async () => {
19
+ const { canvas, canvasElement } =
20
+ await mountStory(Default)
21
+
22
+ const tablist = expectAgentDrivable(canvas, {
23
+ name: "Bay 3",
24
+ role: "tablist",
25
+ })
26
+
27
+ await expect(tablist).toHaveAttribute(
28
+ "aria-orientation",
29
+ "horizontal",
30
+ )
31
+
32
+ const log = expectAgentDrivable(canvas, {
33
+ name: "Log",
34
+ role: "tab",
35
+ })
36
+
37
+ await userEvent.click(log)
38
+
39
+ await waitFor(() => {
40
+ expect(log).toHaveAttribute("aria-selected", "true")
41
+ })
42
+
43
+ // `aria-controls` really reaches the panel, and the panel names
44
+ // itself back through `aria-labelledby`. Both ids come from one
45
+ // `useUniqueId`, which is why they cannot drift.
46
+ const panel = expectAgentDrivable(canvas, {
47
+ name: "Log",
48
+ role: "tabpanel",
49
+ })
50
+
51
+ await expect(log).toHaveAttribute(
52
+ "aria-controls",
53
+ panel.id,
54
+ )
55
+
56
+ await expectNoAxeViolations(canvasElement)
57
+ })
58
+
59
+ test("activeKey decides the first render and nothing after", async () => {
60
+ const { canvas } = await mountStory(AllVariants)
61
+
62
+ // A controlled prop is the thing this library refuses to have.
63
+ const tablist = expectAgentDrivable(canvas, {
64
+ name: "Bay 3 starting on log",
65
+ role: "tablist",
66
+ })
67
+
68
+ const selected = tablist.querySelector(
69
+ '[aria-selected="true"]',
70
+ )
71
+
72
+ await expect(selected).toHaveTextContent("Log")
73
+ })
74
+
75
+ test("a vertical tablist says so", async () => {
76
+ const { canvas } = await mountStory(AllVariants)
77
+
78
+ await expect(
79
+ expectAgentDrivable(canvas, {
80
+ name: "Bay 3 vertical",
81
+ role: "tablist",
82
+ }),
83
+ ).toHaveAttribute("aria-orientation", "vertical")
84
+ })
85
+
86
+ test("the arrow keys skip a disabled tab", async () => {
87
+ const { canvas } = await mountStory(AllStates)
88
+
89
+ const verdict = expectAgentDrivable(canvas, {
90
+ name: "Verdict",
91
+ role: "tab",
92
+ })
93
+
94
+ await expect(verdict).toBeDisabled()
95
+
96
+ // The disabled tab is out of the *focus* group and still one of
97
+ // the *options* — it owns a panel and an id either way.
98
+ // Registration is membership, so the arrow keys skip it with
99
+ // nothing in `RovingFocus` knowing the word "disabled".
100
+ const flags = expectAgentDrivable(canvas, {
101
+ name: "Flags",
102
+ role: "tab",
103
+ })
104
+
105
+ await userEvent.click(
106
+ expectAgentDrivable(canvas, {
107
+ name: "Disc",
108
+ role: "tab",
109
+ }),
110
+ )
111
+
112
+ await userEvent.keyboard("{ArrowRight}")
113
+
114
+ await waitFor(() => {
115
+ expect(flags).toHaveFocus()
116
+ })
117
+
118
+ await expect(verdict).not.toHaveFocus()
119
+ })
120
+
121
+ /**
122
+ * A tab bar **scrolls**, it does not wrap. A wrapped tablist puts
123
+ * tabs on two rows, which breaks both the visual row and the mental
124
+ * model the arrow keys give — "right" stops meaning right.
125
+ */
126
+ test("a narrow tab bar scrolls rather than wrapping", async () => {
127
+ const { canvas } = await mountStory(Responsive)
128
+
129
+ const narrow = expectAgentDrivable(canvas, {
130
+ name: "Bay 3 at 15rem",
131
+ role: "tablist",
132
+ })
133
+
134
+ await expect(narrow.scrollWidth).toBeGreaterThan(
135
+ narrow.clientWidth,
136
+ )
137
+
138
+ // **And it clips what it cannot fit.** The line above says only
139
+ // that the content is wider than the box, which is equally true of
140
+ // a bar painting straight across the panel beside it — which is
141
+ // what this component actually did until 2026-07-30, in this exact
142
+ // story, with this exact assertion passing. Overflowing and
143
+ // scrolling are different things and the test has to say which.
144
+ await expect(
145
+ globalThis.getComputedStyle(narrow).overflowX,
146
+ ).not.toBe("visible")
147
+
148
+ // One row, still. `clientHeight` growing past a single tab's
149
+ // height is what wrapping would look like — and, now that this is
150
+ // a scroll container, what a *classic* scrollbar taking layout
151
+ // space would look like too. Both are caught here.
152
+ const wide = expectAgentDrivable(canvas, {
153
+ name: "Bay 3 at 34rem",
154
+ role: "tablist",
155
+ })
156
+
157
+ await expect(narrow.clientHeight).toBe(wide.clientHeight)
158
+ })
159
+
160
+ test("the focus ring survives the scroll container", async () => {
161
+ const { canvas } = await mountStory(Responsive)
162
+
163
+ const narrow = expectAgentDrivable(canvas, {
164
+ name: "Bay 3 at 15rem",
165
+ role: "tablist",
166
+ })
167
+
168
+ // Tabbed to, not `.focus()`ed: the ring is a `:focus-visible`
169
+ // rule, so a programmatic focus leaves `outline-offset` at its
170
+ // `0px` initial value and *any* assertion about it passes. That is
171
+ // how the first version of this test passed against the bug it was
172
+ // written for.
173
+ await userEvent.tab()
174
+
175
+ const tab = document.activeElement as HTMLElement
176
+
177
+ await expect(narrow).toContainElement(tab)
178
+
179
+ // An `overflow` other than `visible` clips its descendants'
180
+ // outlines, and the focus ring *is* an outline. The tab fills the
181
+ // bar's content box, so a positive offset would put the whole ring
182
+ // in the clipped region — visible nowhere, on the one component in
183
+ // the library that scrolls. Drawing it inward is what keeps it on
184
+ // screen, and this is the assertion that fails if the shared
185
+ // `--focus-ring-offset` is ever restored here.
186
+ const { outlineOffset, outlineWidth } =
187
+ globalThis.getComputedStyle(tab)
188
+
189
+ await expect(
190
+ Number.parseFloat(outlineWidth),
191
+ ).toBeGreaterThan(0)
192
+
193
+ await expect(
194
+ Number.parseFloat(outlineOffset),
195
+ ).toBeLessThanOrEqual(0)
196
+ })
197
+
198
+ /**
199
+ * The falsification point that M4 was built to run: `automatic` and
200
+ * `manual` differ only in whether moving focus also *chooses*, and
201
+ * that difference is one line because focus and selection are
202
+ * separate kinds.
203
+ */
204
+ test("manual activation moves focus without selecting", async () => {
205
+ const { canvas } = await mountStory(Manual)
206
+
207
+ const tablist = expectAgentDrivable(canvas, {
208
+ name: "Bay 3 manual",
209
+ role: "tablist",
210
+ })
211
+
212
+ const tabs = Array.from(
213
+ tablist.querySelectorAll<HTMLButtonElement>(
214
+ '[role="tab"]',
215
+ ),
216
+ )
217
+
218
+ const [progress, log] = tabs as [
219
+ HTMLButtonElement,
220
+ HTMLButtonElement,
221
+ ]
222
+
223
+ // Exactly one tab stop for the whole bar — the roving-tabindex
224
+ // rule, straight from `selectTabIndex`.
225
+ await expect(
226
+ tabs.filter((one) => one.tabIndex === 0),
227
+ ).toHaveLength(1)
228
+
229
+ progress.focus()
230
+
231
+ await userEvent.keyboard("{ArrowRight}")
232
+
233
+ await waitFor(() => {
234
+ expect(log).toHaveFocus()
235
+ })
236
+
237
+ // The whole point: focus moved, selection did not.
238
+ await expect(log).toHaveAttribute(
239
+ "aria-selected",
240
+ "false",
241
+ )
242
+
243
+ await expect(progress).toHaveAttribute(
244
+ "aria-selected",
245
+ "true",
246
+ )
247
+
248
+ await userEvent.keyboard("{Enter}")
249
+
250
+ await waitFor(() => {
251
+ expect(log).toHaveAttribute("aria-selected", "true")
252
+ })
253
+ })
254
+
255
+ test("automatic activation selects as focus moves, and wraps", async () => {
256
+ const { canvas } = await mountStory(Default)
257
+
258
+ const tablist = expectAgentDrivable(canvas, {
259
+ name: "Bay 3",
260
+ role: "tablist",
261
+ })
262
+
263
+ const tabs =
264
+ tablist.querySelectorAll<HTMLButtonElement>(
265
+ '[role="tab"]',
266
+ )
267
+
268
+ tabs[0]?.focus()
269
+
270
+ await userEvent.keyboard("{ArrowRight}")
271
+
272
+ await waitFor(() => {
273
+ expect(tabs[1]).toHaveAttribute("aria-selected", "true")
274
+ })
275
+
276
+ // Wrapping is on, because a tab list is one of the patterns ARIA
277
+ // says wraps.
278
+ await userEvent.keyboard("{ArrowRight}{ArrowRight}")
279
+
280
+ await waitFor(() => {
281
+ expect(tabs[0]).toHaveAttribute("aria-selected", "true")
282
+ })
283
+ })