@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,333 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useSinglePicker,
5
+ useUniqueId,
6
+ } from "@charcuterie/logic"
7
+ import type { ReactNode } from "react"
8
+ import { useCallback, useEffect, useRef } from "react"
9
+
10
+ import { toClassName } from "../toClassName.ts"
11
+ import { TabTrigger } from "./TabTrigger.tsx"
12
+
13
+ export type TabsActivation = "automatic" | "manual"
14
+
15
+ export type TabsOrientation = "horizontal" | "vertical"
16
+
17
+ export type TabItem = {
18
+ content: ReactNode
19
+ /**
20
+ * A disabled tab is simply **not registered** with the roving
21
+ * group. Registration is membership, so the arrow keys skip it
22
+ * without any command in `RovingFocus` having to know what
23
+ * "disabled" means.
24
+ */
25
+ isDisabled?: boolean
26
+ key: string
27
+ label: ReactNode
28
+ }
29
+
30
+ export type TabsProps = {
31
+ /** **Initial** only. Charcuterie owns it from then on. */
32
+ activeKey?: string
33
+ /**
34
+ * `automatic` — the ARIA Authoring Practices default — shows a
35
+ * panel as soon as the arrow keys reach its tab. `manual` moves
36
+ * focus only, and Enter or Space commits.
37
+ *
38
+ * The distinction exists because **focus is not selection**, and
39
+ * it is the entire reason `RovingFocus` is its own state kind. A
40
+ * tab whose panel costs a network request wants `manual`, and
41
+ * the two modes differ by one line below — because two kinds are
42
+ * composed rather than one kind doing both jobs badly.
43
+ */
44
+ activation?: TabsActivation
45
+ className?: string
46
+ /** The tablist's accessible name. Required. */
47
+ label: string
48
+ onChange?: (activeKey: string | null) => void
49
+ orientation?: TabsOrientation
50
+ tabs: TabItem[]
51
+ }
52
+
53
+ const ORIENTATION_KEYS: Record<
54
+ TabsOrientation,
55
+ { next: string; previous: string }
56
+ > = {
57
+ horizontal: {
58
+ next: "ArrowRight",
59
+ previous: "ArrowLeft",
60
+ },
61
+ vertical: { next: "ArrowDown", previous: "ArrowUp" },
62
+ }
63
+
64
+ /**
65
+ * Kept at P0 as the **state layer's falsification test**, not on
66
+ * duplication grounds — the fifteen ad-hoc tab bars that used to
67
+ * justify it were in the withdrawn evidence. What it has to prove
68
+ * is that two kinds compose: one says which tab is **chosen**, the
69
+ * other says which tab is **tabbable**, and a tab bar needs both
70
+ * at once *and* needs them to disagree on purpose while a user
71
+ * arrows around in `manual` mode.
72
+ *
73
+ * The composition is two hooks and one decision:
74
+ *
75
+ * ```ts
76
+ * const selection = useSinglePicker({ selectedValue: initialKey })
77
+ * const focus = useRovingFocus({ activeValue: initialKey })
78
+ *
79
+ * // `automatic` is this line, and `manual` is its absence.
80
+ * if (activation === "automatic") selection.select(focus.activeValue)
81
+ * ```
82
+ *
83
+ * ### Selection, not visibility — and the difference is not cosmetic
84
+ *
85
+ * M4 built this on `VisibilityGroup`, and the panels do end up
86
+ * one-at-a-time either way, because the two cores are the same
87
+ * shape. But the thing that **registers** here is the tab, not the
88
+ * panel, and what a tab bar is asking is *which one did you pick*.
89
+ * `SinglePicker` is the kind that answers that; the panel's
90
+ * `hidden` is derived from the answer.
91
+ *
92
+ * Reading it the other way round cost something concrete: it made
93
+ * `aria-selected` a report about a *panel's* visibility, so a
94
+ * consumer wanting a tab bar with no panels at all — a segmented
95
+ * filter, a view switcher that swaps a route — had to model its
96
+ * choice as a group of visibilities it did not have. `Alert`'s
97
+ * sibling `SegmentedControl` is exactly that consumer, and it now
98
+ * shares this component's core rather than hand-rolling a fourth
99
+ * one.
100
+ * [Decision](../../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
101
+ *
102
+ * ### What it does not use, and why that is the right answer
103
+ *
104
+ * The plan expected `createLinkedIds` here as well. It does not
105
+ * fit, and forcing it would have been the wrong call: that kind
106
+ * exists for a **dynamic** trigger↔target pair — a `Popover` whose
107
+ * panel mounts and unmounts, where the multiset is what stops
108
+ * `aria-controls` pointing at a node React has already removed. A
109
+ * tab bar's pairing is static and known at render, so the ids come
110
+ * from one `useUniqueId` and cannot get out of step at all.
111
+ *
112
+ * ### Focus is moved deliberately, and only then
113
+ *
114
+ * `RovingFocus` is state; something still has to call `.focus()`.
115
+ * That happens only while the tablist already contains the focused
116
+ * element — otherwise mounting a tab bar, or any parent re-render
117
+ * that moved `activeValue`, yanks the caret out of whatever the
118
+ * user was actually doing.
119
+ */
120
+ export const Tabs = ({
121
+ activation = "automatic",
122
+ activeKey,
123
+ className,
124
+ label,
125
+ onChange,
126
+ orientation = "horizontal",
127
+ tabs,
128
+ }: TabsProps): ReactNode => {
129
+ const baseId = useUniqueId()
130
+
131
+ const tablistRef = useRef<HTMLDivElement>(null)
132
+
133
+ const tabElements = useRef(
134
+ new Map<string, HTMLButtonElement>(),
135
+ )
136
+
137
+ const initialKey =
138
+ activeKey ??
139
+ tabs.find((one) => !one.isDisabled)?.key ??
140
+ null
141
+
142
+ const selection = useSinglePicker<string>({
143
+ onChange,
144
+ selectedValue: initialKey,
145
+ })
146
+
147
+ const focus = useRovingFocus<string>({
148
+ activeValue: initialKey,
149
+ })
150
+
151
+ const { select } = selection
152
+
153
+ const { activeValue, setActiveValue } = focus
154
+
155
+ /**
156
+ * Selected **or** pending, and the fallback is load-bearing.
157
+ *
158
+ * Members register from an effect, so on the first paint
159
+ * `selectedValue` is still null and the intent lives in
160
+ * `pendingValue` — a tab bar reading only the former renders with
161
+ * no tab selected and no panel shown, then corrects itself a
162
+ * frame later. It is a flash on a fast machine and a visible
163
+ * blank on a kiosk Pi.
164
+ *
165
+ * The core distinguishes the two on purpose, which is why this
166
+ * belongs here rather than in the selector — unlike
167
+ * `RovingFocus`, where the equivalent first-paint hole was in
168
+ * `selectTabIndex` itself and got fixed there.
169
+ */
170
+ const shownKey =
171
+ selection.selectedValue ?? selection.pendingValue
172
+
173
+ const selectTab = useCallback(
174
+ (key: string) => {
175
+ // Both, because a pointer press moves focus *and* chooses.
176
+ // Only selecting would leave `automatic`'s effect below to
177
+ // put the old panel straight back.
178
+ setActiveValue(key)
179
+
180
+ select(key)
181
+ },
182
+ [select, setActiveValue],
183
+ )
184
+
185
+ useEffect(() => {
186
+ // Automatic activation, and the only behaviour that differs
187
+ // between the two modes. `select` is idempotent, so this is a
188
+ // no-op on every render where focus did not actually move.
189
+ if (
190
+ activation === "automatic" &&
191
+ activeValue !== null
192
+ ) {
193
+ select(activeValue)
194
+ }
195
+ }, [activation, activeValue, select])
196
+
197
+ useEffect(() => {
198
+ const tablist = tablistRef.current
199
+
200
+ if (
201
+ !tablist ||
202
+ activeValue === null ||
203
+ !tablist.contains(document.activeElement)
204
+ ) {
205
+ return
206
+ }
207
+
208
+ tabElements.current.get(activeValue)?.focus()
209
+ }, [activeValue])
210
+
211
+ const keys = ORIENTATION_KEYS[orientation]
212
+
213
+ return (
214
+ <div
215
+ className={toClassName(
216
+ "flex flex-col gap-3",
217
+ className,
218
+ )}
219
+ >
220
+ <div
221
+ aria-label={label}
222
+ aria-orientation={orientation}
223
+ className={toClassName(
224
+ "flex gap-1 border-border-subtle",
225
+ // A tab bar **scrolls**; it does not wrap and it does not
226
+ // paint outside its container. Without an `overflow`, a
227
+ // bar narrower than its tabs simply spilled across
228
+ // whatever sat beside it — visible in the `Responsive`
229
+ // board since M4, and invisible to a test asserting only
230
+ // that `scrollWidth > clientWidth`, which is true of a
231
+ // *spilling* bar too.
232
+ //
233
+ // The scrollbar is hidden rather than thin: a classic
234
+ // scrollbar takes layout space inside the scroll
235
+ // container, so the narrow bar would grow ~15px taller
236
+ // than the wide one and the underline would stop lining
237
+ // up across a board. Keyboard users never need it —
238
+ // `RovingFocus` calls `.focus()`, and the browser scrolls
239
+ // a focused tab into view.
240
+ "[scrollbar-width:none]",
241
+ // An `overflow` other than `visible` clips its
242
+ // descendants' **outlines**, and this component's focus
243
+ // ring is an outline sitting 2px *outside* the tab. The
244
+ // tab fills the bar's content box, so the ring would be
245
+ // clipped away on exactly the axis that now scrolls.
246
+ // Re-pointing the shared token inward keeps one ring
247
+ // definition — `FOCUS_RING_CLASS` already reads this
248
+ // variable — rather than appending a competing
249
+ // `outline-offset` utility and letting Tailwind's
250
+ // ordering decide the winner.
251
+ "[--focus-ring-offset:calc(var(--focus-ring-width)*-1)]",
252
+ orientation === "horizontal"
253
+ ? "flex-row overflow-x-auto border-b"
254
+ : "flex-col items-stretch overflow-y-auto border-e",
255
+ )}
256
+ onKeyDown={(keyEvent) => {
257
+ const commands: Record<string, () => void> = {
258
+ [keys.next]: focus.next,
259
+ [keys.previous]: focus.previous,
260
+ End: focus.last,
261
+ Home: focus.first,
262
+ }
263
+
264
+ const command = commands[keyEvent.key]
265
+
266
+ if (command) {
267
+ // Arrow keys scroll the page by default, and a tab bar
268
+ // that scrolls the window while it moves focus is the
269
+ // hand-rolled version of this component.
270
+ keyEvent.preventDefault()
271
+
272
+ command()
273
+
274
+ return
275
+ }
276
+
277
+ // `manual`'s commit. In `automatic` the panel is showing
278
+ // already, so this is the no-op that makes Enter behave
279
+ // identically in both modes.
280
+ if (
281
+ (keyEvent.key === "Enter" ||
282
+ keyEvent.key === " ") &&
283
+ activeValue !== null
284
+ ) {
285
+ keyEvent.preventDefault()
286
+
287
+ select(activeValue)
288
+ }
289
+ }}
290
+ ref={tablistRef}
291
+ role="tablist"
292
+ >
293
+ {tabs.map((tab, index) => (
294
+ <TabTrigger
295
+ id={`${baseId}-tab-${index}`}
296
+ isSelected={shownKey === tab.key}
297
+ key={tab.key}
298
+ onSelect={selectTab}
299
+ panelId={`${baseId}-panel-${index}`}
300
+ registerFocus={focus.register}
301
+ orientation={orientation}
302
+ registerSelection={selection.register}
303
+ tab={tab}
304
+ // The roving-tabindex rule read from the core rather
305
+ // than restated here: exactly one member is in the tab
306
+ // order, so Tab enters and leaves the bar once while
307
+ // the arrow keys move inside it.
308
+ tabIndex={selectTabIndex(focus, tab.key)}
309
+ trackElement={(key, element) => {
310
+ if (element) {
311
+ tabElements.current.set(key, element)
312
+ } else {
313
+ tabElements.current.delete(key)
314
+ }
315
+ }}
316
+ />
317
+ ))}
318
+ </div>
319
+
320
+ {tabs.map((tab, index) => (
321
+ <div
322
+ aria-labelledby={`${baseId}-tab-${index}`}
323
+ hidden={shownKey !== tab.key}
324
+ id={`${baseId}-panel-${index}`}
325
+ key={tab.key}
326
+ role="tabpanel"
327
+ >
328
+ {tab.content}
329
+ </div>
330
+ ))}
331
+ </div>
332
+ )
333
+ }
@@ -0,0 +1,29 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as VisuallyHiddenStories from "./VisuallyHidden.stories";
4
+
5
+ <Meta of={VisuallyHiddenStories} />
6
+
7
+ # VisuallyHidden
8
+
9
+ Text for assistive technology and for nobody else. A Layer-0 primitive, and the one every
10
+ other component in M3 leans on: it is how a `Spinner` says "Loading…" without printing it,
11
+ how a `ProgressBar`'s label stays discoverable when the design does not show it, and how an
12
+ `IconButton`'s glyph gets a name.
13
+
14
+ <Canvas of={VisuallyHiddenStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## Nothing to own
21
+
22
+ `sr-only` is Tailwind's own — the clip-rect technique, unchanged since it was
23
+ `.visuallyhidden` in HTML5 Boilerplate. Never `display: none` and never
24
+ `visibility: hidden`: both remove the node from the accessibility tree, which is the exact
25
+ opposite of the requirement. The `Interactive` story asserts both halves — present to a
26
+ query, one pixel to the layout.
27
+
28
+ This component's story set is deliberately short. It has no variants, no states, and no
29
+ container-query behaviour; the mandated five would be four empty boards.
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ StoryCell,
5
+ StoryGrid,
6
+ } from "../board.storyHelpers.tsx"
7
+ import { IconButton } from "../IconButton/IconButton.tsx"
8
+ import { VisuallyHidden } from "./VisuallyHidden.tsx"
9
+
10
+ /**
11
+ * A Layer-0 primitive with a deliberately short story set: it has no
12
+ * variants, no states, and no container-query behaviour — the whole
13
+ * component is "in the accessibility tree, out of the layout". The
14
+ * mandated five-story set would be four empty boards.
15
+ *
16
+ * It ships in M3 because `Spinner`, `ProgressBar`, and
17
+ * `LiveStatusIndicator` all need it and the alternative is
18
+ * `aria-label` everywhere, which cannot hold markup and silently
19
+ * loses translation.
20
+ */
21
+ const meta = {
22
+ title: "Foundation/VisuallyHidden",
23
+ component: VisuallyHidden,
24
+ parameters: { layout: "padded" },
25
+ } satisfies Meta<typeof VisuallyHidden>
26
+
27
+ export default meta
28
+
29
+ type Story = StoryObj<typeof meta>
30
+
31
+ export const Default: Story = {
32
+ args: { children: "Announced, never printed." },
33
+ render: (visuallyHiddenProps) => (
34
+ <StoryGrid columns={2}>
35
+ <StoryCell label="hidden text, next to visible text">
36
+ <p className="text-content-primary text-sm">
37
+ Ripping title 4
38
+ <VisuallyHidden {...visuallyHiddenProps} />
39
+ </p>
40
+ </StoryCell>
41
+
42
+ <StoryCell label="an icon button's name comes from one">
43
+ <IconButton label="Undo" size="sm">
44
+ ↶
45
+ </IconButton>
46
+ </StoryCell>
47
+ </StoryGrid>
48
+ ),
49
+ }
50
+
51
+ /**
52
+ * Present to a query, absent from the layout — the whole component.
53
+ * There is nothing to see here on purpose; the assertions are in
54
+ * `VisuallyHidden.test.tsx`.
55
+ */
56
+ export const Interactive: Story = {
57
+ args: { children: "Announced, never printed." },
58
+ }
@@ -0,0 +1,31 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import * as stories from "./VisuallyHidden.stories.tsx"
7
+
8
+ const { Interactive } = composeStories(stories)
9
+
10
+ test("it is in the accessibility tree and out of the layout", async () => {
11
+ const { canvas, canvasElement } =
12
+ await mountStory(Interactive)
13
+
14
+ // `display: none` would fail the first assertion; a plain `<span>`
15
+ // would fail the second. Both halves, or the component is either
16
+ // useless or visible.
17
+ const hidden = canvas.getByText(
18
+ "Announced, never printed.",
19
+ )
20
+
21
+ await expect(hidden).toBeInTheDocument()
22
+
23
+ const { height, width } = hidden.getBoundingClientRect()
24
+
25
+ await expect(width).toBeLessThanOrEqual(1)
26
+ await expect(height).toBeLessThanOrEqual(1)
27
+
28
+ await expect(canvasElement.textContent).toContain(
29
+ "Announced, never printed.",
30
+ )
31
+ })
@@ -0,0 +1,43 @@
1
+ import type {
2
+ ComponentPropsWithRef,
3
+ ReactNode,
4
+ } from "react"
5
+
6
+ import { toClassName } from "../toClassName.ts"
7
+
8
+ export type VisuallyHiddenProps =
9
+ ComponentPropsWithRef<"span"> & {
10
+ children: ReactNode
11
+ }
12
+
13
+ /**
14
+ * Text for assistive technology and for nobody else.
15
+ *
16
+ * A Layer-0 primitive, and the one every other component in M3
17
+ * leans on: it is how a `Spinner` says "Loading…" without printing
18
+ * it, how an `IconButton`'s glyph gets a name, and how a
19
+ * `ProgressBar`'s label stays discoverable when the design does not
20
+ * show it. Without it the alternative is `aria-label` everywhere,
21
+ * which silently loses translation and cannot hold markup.
22
+ *
23
+ * `sr-only` is Tailwind's own — the clip-rect technique, unchanged
24
+ * since it was `.visuallyhidden` in HTML5 Boilerplate. There is
25
+ * nothing to own here, and owning it would mean maintaining a
26
+ * clip-rect against browsers that have already agreed.
27
+ *
28
+ * Never `display: none` and never `visibility: hidden`: both remove
29
+ * the node from the accessibility tree, which is the exact opposite
30
+ * of the requirement.
31
+ */
32
+ export const VisuallyHidden = ({
33
+ children,
34
+ className,
35
+ ...spanProps
36
+ }: VisuallyHiddenProps): ReactNode => (
37
+ <span
38
+ {...spanProps}
39
+ className={toClassName("sr-only", className)}
40
+ >
41
+ {children}
42
+ </span>
43
+ )
@@ -0,0 +1,115 @@
1
+ import { connectionTransitions } from "@charcuterie/logic"
2
+ import type { InputType } from "storybook/internal/types"
3
+
4
+ import { CONTROL_SIZE_CLASS } from "./controlStyles.ts"
5
+ import { INTENT_APPEARANCE_CLASS } from "./intentStyles.ts"
6
+
7
+ /**
8
+ * Controls for the props Storybook's docgen cannot see through.
9
+ *
10
+ * `react-docgen` follows **relative** imports and stops at bare
11
+ * package specifiers. So `appearance?: IntentAppearance` — imported
12
+ * from `../intentStyles.ts` — arrives as a resolved union and gets a
13
+ * radio for free, while `size?: ControlSize` and `intent?: IntentName`
14
+ * — imported from `@charcuterie/tokens` — arrive as the bare names
15
+ * `ControlSize` and `IntentName`, which Storybook cannot enumerate.
16
+ * Its fallback for an unknown type is the **object control**: a JSON
17
+ * textarea containing `{}`, on a prop whose only legal values are
18
+ * three strings. Typing in it renders the component with an object
19
+ * where a string belongs.
20
+ *
21
+ * The other half of the same problem is that Storybook has not seeded
22
+ * `args` from a docgen `defaultValue` since v7 — the props table
23
+ * prints `"control"` in the Default column while the radio beside it
24
+ * has nothing selected. Only an explicit `args` fixes that, so every
25
+ * meta states the component's own defaults.
26
+ *
27
+ * Switching the whole project to `react-docgen-typescript` would
28
+ * resolve the imports and is the wrong trade: it expands
29
+ * `ComponentPropsWithRef<"button">` too, turning a nine-row props
30
+ * table into every HTML attribute React knows.
31
+ *
32
+ * The option lists are derived from the `Record<Union, …>` maps the
33
+ * components already index, never retyped — so a new intent or a new
34
+ * size is a compile error in the map and a longer radio here, rather
35
+ * than a control that silently omits it.
36
+ */
37
+
38
+ const toOptions = <Key extends string>(
39
+ map: Record<Key, unknown>,
40
+ ): Key[] => Object.keys(map) as Key[]
41
+
42
+ /**
43
+ * A radio under five options, a select at five or more — past four
44
+ * the radio column is taller than the props row it belongs to.
45
+ */
46
+ const toChoiceArgType = <Key extends string>(
47
+ options: readonly Key[],
48
+ ): InputType => ({
49
+ control: {
50
+ type: options.length < 5 ? "radio" : "select",
51
+ },
52
+ options: [...options],
53
+ table: {
54
+ type: {
55
+ summary: options
56
+ .map((option) => `"${option}"`)
57
+ .join(" | "),
58
+ },
59
+ },
60
+ })
61
+
62
+ export const INTENT_OPTIONS = toOptions(
63
+ INTENT_APPEARANCE_CLASS,
64
+ )
65
+
66
+ export const CONTROL_SIZE_OPTIONS = toOptions(
67
+ CONTROL_SIZE_CLASS,
68
+ )
69
+
70
+ export const CONNECTION_STATUS_OPTIONS = toOptions(
71
+ connectionTransitions,
72
+ )
73
+
74
+ /**
75
+ * floating-ui's own union, which is not a `Record` anywhere we own —
76
+ * so this is the one list written out by hand, and the one a
77
+ * dependency bump could silently outdate.
78
+ * `storyControls.test.ts` pins it against the placements `Popover`
79
+ * actually accepts.
80
+ */
81
+ export const PLACEMENT_OPTIONS = [
82
+ "top",
83
+ "top-start",
84
+ "top-end",
85
+ "right",
86
+ "right-start",
87
+ "right-end",
88
+ "bottom",
89
+ "bottom-start",
90
+ "bottom-end",
91
+ "left",
92
+ "left-start",
93
+ "left-end",
94
+ ] as const
95
+
96
+ export const intentArgType = toChoiceArgType(INTENT_OPTIONS)
97
+
98
+ export const controlSizeArgType = toChoiceArgType(
99
+ CONTROL_SIZE_OPTIONS,
100
+ )
101
+
102
+ export const connectionStatusArgType = toChoiceArgType(
103
+ CONNECTION_STATUS_OPTIONS,
104
+ )
105
+
106
+ export const placementArgType = toChoiceArgType(
107
+ PLACEMENT_OPTIONS,
108
+ )
109
+
110
+ /**
111
+ * Exported for the one control whose map is private to its own
112
+ * component — `Card`'s `ELEVATION_CLASS`. Its story derives the
113
+ * options there rather than this file importing a component.
114
+ */
115
+ export const toStoryChoice = toChoiceArgType