@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,47 @@
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 { expectHiddenFromAgents } from "../testing/index.ts"
7
+ import * as stories from "./Skeleton.stories.tsx"
8
+
9
+ const { Default, Interactive } = composeStories(stories)
10
+
11
+ test("a skeleton is hidden from assistive technology", async () => {
12
+ const { canvasElement } = await mountStory(Default)
13
+
14
+ const skeleton = canvasElement.querySelector(
15
+ "div[aria-hidden]",
16
+ )
17
+
18
+ if (!skeleton) {
19
+ throw new Error("Skeleton did not render")
20
+ }
21
+
22
+ // The inverse of every other component's gate: this one asserts
23
+ // the component is *not* reachable. A placeholder standing in for
24
+ // content that does not exist yet must not be read out as three
25
+ // empty bars.
26
+ expectHiddenFromAgents(skeleton)
27
+ })
28
+
29
+ test("it contributes no roles at all", async () => {
30
+ const { canvas, canvasElement } =
31
+ await mountStory(Interactive)
32
+
33
+ await expect(
34
+ canvas.queryAllByRole("status"),
35
+ ).toHaveLength(0)
36
+
37
+ await expect(
38
+ canvas.queryAllByRole("progressbar"),
39
+ ).toHaveLength(0)
40
+
41
+ // `lineCount` really produces that many bars — the fidelity
42
+ // obligation, since a placeholder that occupies the wrong box
43
+ // reflows the grid when the data lands.
44
+ await expect(
45
+ canvasElement.querySelectorAll(".charcuterie-shimmer"),
46
+ ).toHaveLength(3)
47
+ })
@@ -0,0 +1,106 @@
1
+ import type {
2
+ ComponentPropsWithRef,
3
+ ReactNode,
4
+ } from "react"
5
+
6
+ import { toClassName } from "../toClassName.ts"
7
+
8
+ export type SkeletonShape = "block" | "circle" | "text"
9
+
10
+ export type SkeletonProps = Omit<
11
+ ComponentPropsWithRef<"div">,
12
+ "children"
13
+ > & {
14
+ blockSize?: string
15
+ inlineSize?: string
16
+ /**
17
+ * Text skeletons render this many bars, the last one short —
18
+ * because a paragraph placeholder whose every line is full width
19
+ * reads as a table, not as prose.
20
+ */
21
+ lineCount?: number
22
+ shape?: SkeletonShape
23
+ }
24
+
25
+ const SHAPE_CLASS: Record<SkeletonShape, string> = {
26
+ block: "rounded-md",
27
+ circle: "rounded-full",
28
+ text: "rounded-sm",
29
+ }
30
+
31
+ /**
32
+ * The bars of a multi-line text skeleton, described rather than
33
+ * indexed.
34
+ *
35
+ * The last line is **60%**, fixed. Not random: a placeholder that
36
+ * differs between two renders of the same list makes every
37
+ * screenshot diff useless, and visual regression over P0 x scheme x
38
+ * density is a planned addition once tokens stop moving.
39
+ */
40
+ const buildLines = (lineCount: number) =>
41
+ Array.from({ length: lineCount }, (_, index) => ({
42
+ id: `charcuterie-skeleton-line-${index + 1}`,
43
+ inlineSize: index === lineCount - 1 ? "60%" : undefined,
44
+ }))
45
+
46
+ /**
47
+ * **Also zero of these fleet-wide.** The counterpart to `Spinner`
48
+ * and the better answer wherever the shape of the incoming content
49
+ * is already known: a poster grid that skeletons does not reflow
50
+ * when the images land, and layout shift is the thing users
51
+ * actually notice.
52
+ *
53
+ * `aria-hidden` is not an optimisation, it is the contract. A
54
+ * skeleton is decoration standing in for content that does not
55
+ * exist yet; announcing it means a screen reader reading three
56
+ * empty bars. **The load has to be announced by the region that
57
+ * owns it** — a `Spinner`, or an `aria-busy` on the container — and
58
+ * a skeleton that AT can see is a bug, which is why the story
59
+ * asserts it cannot be found by role.
60
+ */
61
+ export const Skeleton = ({
62
+ blockSize,
63
+ className,
64
+ inlineSize,
65
+ lineCount = 1,
66
+ shape = "block",
67
+ style,
68
+ ...divProps
69
+ }: SkeletonProps): ReactNode => {
70
+ const barClassName = toClassName(
71
+ "charcuterie-shimmer block bg-surface-sunken",
72
+ SHAPE_CLASS[shape],
73
+ shape === "text" && "h-[1em]",
74
+ )
75
+
76
+ if (shape === "text" && lineCount > 1) {
77
+ return (
78
+ <div
79
+ {...divProps}
80
+ aria-hidden="true"
81
+ className={toClassName(
82
+ "flex flex-col gap-2",
83
+ className,
84
+ )}
85
+ style={{ inlineSize, ...style }}
86
+ >
87
+ {buildLines(lineCount).map((line) => (
88
+ <span
89
+ className={barClassName}
90
+ key={line.id}
91
+ style={{ inlineSize: line.inlineSize }}
92
+ />
93
+ ))}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ return (
99
+ <div
100
+ {...divProps}
101
+ aria-hidden="true"
102
+ className={toClassName(barClassName, className)}
103
+ style={{ blockSize, inlineSize, ...style }}
104
+ />
105
+ )
106
+ }
@@ -0,0 +1,36 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SpinnerStories from "./Spinner.stories";
4
+
5
+ <Meta of={SpinnerStories} />
6
+
7
+ # Spinner
8
+
9
+ **Zero of these exist anywhere in the fleet.** Not one repo has a spinner; every app
10
+ renders bare `"loading…"` text. Best effort-to-payoff component in M3.
11
+
12
+ <Canvas of={SpinnerStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## Three things a hand-rolled `animate-spin` does not do
19
+
20
+ 1. **It is a live region.** `role="status"` with the label inside, so the load is
21
+ announced. A rotating `<div>` announces nothing.
22
+ 2. **It stops.** The animation runs at `--duration-loop-fast`, which
23
+ `prefers-reduced-motion` zeroes and `@charcuterie/ui/styles.css` then switches off
24
+ outright. Tailwind's `animate-spin` hardcodes 1s straight past the media query.
25
+ 3. **It is findable.** `role="status"` takes **no accessible name from its content** — it
26
+ is a live region, not a widget — so the component sets `aria-label` as well. Without
27
+ that, `getByRole("status", { name: "Loading…" })` matches nothing, and axe never
28
+ mentions it.
29
+
30
+ ## Reduced motion
31
+
32
+ There is deliberately no "reduced motion" story: it is a browser state, not a prop. Toggle
33
+ it (DevTools → Rendering → Emulate `prefers-reduced-motion`) against `AllStates` — the
34
+ ring must stop, stay a ring, and keep its label.
35
+
36
+ <Canvas of={SpinnerStories.AllStates} />
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import { Button } from "../Button/Button.tsx"
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ StorySection,
10
+ } from "../board.storyHelpers.tsx"
11
+ import { Spinner } from "./Spinner.tsx"
12
+
13
+ const meta = {
14
+ title: "Components/Spinner",
15
+ component: Spinner,
16
+ parameters: { layout: "padded" },
17
+ argTypes: { size: controlSizeArgType },
18
+ args: {
19
+ isLabelVisible: false,
20
+ label: "Loading…",
21
+ size: "md",
22
+ },
23
+ } satisfies Meta<typeof Spinner>
24
+
25
+ export default meta
26
+
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ /**
30
+ * The name comes from the *hidden* label, which is the whole reason
31
+ * this beats a rotating `<div>`: the state is announced even though
32
+ * nothing is printed.
33
+ */
34
+ export const Default: Story = {
35
+ args: {},
36
+ }
37
+
38
+ export const AllVariants: Story = {
39
+ args: {},
40
+ render: (spinnerProps) => (
41
+ <StorySection title="Three sizes, and inside a button — where `border-current` makes it inherit the button's own text colour with nothing passed in.">
42
+ <StoryGrid columns={4}>
43
+ <StoryCell label="sm">
44
+ <Spinner {...spinnerProps} size="sm" />
45
+ </StoryCell>
46
+
47
+ <StoryCell label="md">
48
+ <Spinner {...spinnerProps} size="md" />
49
+ </StoryCell>
50
+
51
+ <StoryCell label="lg">
52
+ <Spinner {...spinnerProps} size="lg" />
53
+ </StoryCell>
54
+
55
+ <StoryCell label="inside a solid button">
56
+ <Button isLoading>Ripping</Button>
57
+ </StoryCell>
58
+ </StoryGrid>
59
+ </StorySection>
60
+ ),
61
+ }
62
+
63
+ export const AllStates: Story = {
64
+ args: {},
65
+ render: (spinnerProps) => (
66
+ <StoryGrid columns={3}>
67
+ <StoryCell label="label hidden (default)">
68
+ <Spinner {...spinnerProps} />
69
+ </StoryCell>
70
+
71
+ <StoryCell label="label visible">
72
+ <Spinner {...spinnerProps} isLabelVisible />
73
+ </StoryCell>
74
+
75
+ <StoryCell label="custom label">
76
+ <Spinner
77
+ {...spinnerProps}
78
+ isLabelVisible
79
+ label="Reading disc structure…"
80
+ />
81
+ </StoryCell>
82
+
83
+ <StoryCell label="on a tinted surface">
84
+ <span className="inline-flex rounded-md bg-intent-info-surface p-2 text-intent-info-content">
85
+ <Spinner {...spinnerProps} />
86
+ </span>
87
+ </StoryCell>
88
+ </StoryGrid>
89
+ ),
90
+ }
91
+
92
+ /**
93
+ * There is no "reduced motion" story, deliberately — it is a
94
+ * *browser* state, not a prop, so faking it here would prove
95
+ * nothing. Toggle the OS setting (or DevTools → Rendering → Emulate
96
+ * `prefers-reduced-motion`) against this board: the ring must stop
97
+ * and still read as a ring, and the label must still be announced.
98
+ */
99
+ export const Responsive: Story = {
100
+ args: { isLabelVisible: true },
101
+ render: (spinnerProps) => (
102
+ <ContainerBoard>
103
+ <Spinner {...spinnerProps} />
104
+ </ContainerBoard>
105
+ ),
106
+ }
107
+
108
+ /**
109
+ * A named region with a visible label, and a ring that stays *out*
110
+ * of the accessibility tree — otherwise a screen reader reads the
111
+ * label twice, once as the region and once as an unnamed child.
112
+ */
113
+ export const Interactive: Story = {
114
+ args: {
115
+ isLabelVisible: true,
116
+ label: "Reading disc structure…",
117
+ },
118
+ }
@@ -0,0 +1,38 @@
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 { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./Spinner.stories.tsx"
8
+
9
+ const { Default, Interactive } = composeStories(stories)
10
+
11
+ test("the hidden label is the accessible name", async () => {
12
+ const { canvas } = await mountStory(Default)
13
+
14
+ // A live region takes **no** accessible name from its content, so
15
+ // `Spinner` sets both — text for the announcement, `aria-label`
16
+ // for the name. Without that this query matches nothing:
17
+ // announced, but unfindable, and axe stays silent about it.
18
+ expectAgentDrivable(canvas, {
19
+ name: "Loading…",
20
+ role: "status",
21
+ })
22
+ })
23
+
24
+ test("the ring is decoration and stays out of the tree", async () => {
25
+ const { canvas } = await mountStory(Interactive)
26
+
27
+ const status = expectAgentDrivable(canvas, {
28
+ name: "Reading disc structure…",
29
+ role: "status",
30
+ })
31
+
32
+ // Otherwise a screen reader reads the label twice — once as the
33
+ // region, once as an unnamed child.
34
+ const ring = status.querySelector("[aria-hidden='true']")
35
+
36
+ await expect(ring).toBeInTheDocument()
37
+ await expect(ring).toHaveClass("charcuterie-spin")
38
+ })
@@ -0,0 +1,92 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+
7
+ import { SPINNER_SIZE_CLASS } from "../controlStyles.ts"
8
+ import { toClassName } from "../toClassName.ts"
9
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
10
+
11
+ export type SpinnerProps = Omit<
12
+ ComponentPropsWithRef<"span">,
13
+ "children"
14
+ > & {
15
+ /**
16
+ * Required by omission of a default that could be wrong.
17
+ * "Loading…" is right for a fetch and wrong for "Ripping disc 3",
18
+ * and the label is the *only* thing a screen-reader user gets —
19
+ * the animation says nothing.
20
+ */
21
+ label?: string
22
+ isLabelVisible?: boolean
23
+ size?: ControlSize
24
+ }
25
+
26
+ /**
27
+ * **Zero of these exist anywhere in the fleet.** Not one repo has a
28
+ * spinner; every app renders bare `"loading…"` text, which is why
29
+ * this is the best effort-to-payoff component in the M3 list.
30
+ *
31
+ * Three things it does that a hand-rolled `animate-spin` does not:
32
+ *
33
+ * - **It is a live region.** `role="status"` with the label inside
34
+ * means a screen reader announces the load starting. A bare
35
+ * rotating `<div>` announces nothing, which is the state of the
36
+ * art in every repo that renders `"loading…"` into a `<span>`.
37
+ * - **It stops.** The animation is `--duration-loop-fast`, which
38
+ * `prefers-reduced-motion` zeroes and
39
+ * `@charcuterie/ui/styles.css` then switches off outright —
40
+ * vestibular-triggering motion is the single most common a11y
41
+ * complaint about spinners, and Tailwind's `animate-spin`
42
+ * hardcodes 1s past the media query.
43
+ * - **It leaves a static fallback that still reads.** With motion
44
+ * off, the ring stays a ring and the label stays a label, so the
45
+ * component degrades to "there is a thing here, and it says
46
+ * loading" rather than to nothing.
47
+ */
48
+ export const Spinner = ({
49
+ className,
50
+ isLabelVisible = false,
51
+ label = "Loading…",
52
+ size = "md",
53
+ ...spanProps
54
+ }: SpinnerProps): ReactNode => (
55
+ <span
56
+ {...spanProps}
57
+ // Both, and neither is redundant. `role="status"` takes **no
58
+ // accessible name from its content** — it is a live region, not
59
+ // a widget — so without `aria-label` the spinner is announced on
60
+ // change but cannot be *found*:
61
+ // `getByRole("status", { name: "Loading…" })` matches nothing,
62
+ // which is a gap an agent hits and axe never reports. The text
63
+ // child is what a screen reader reads when the region appears;
64
+ // the label is what names it for navigation and for Playwright.
65
+ aria-label={label}
66
+ className={toClassName(
67
+ "inline-flex items-center gap-2 align-middle text-current",
68
+ className,
69
+ )}
70
+ role="status"
71
+ >
72
+ <span
73
+ aria-hidden="true"
74
+ className={toClassName(
75
+ // `border-current` on three sides and a transparent fourth
76
+ // is the whole trick: it inherits the parent's text colour,
77
+ // so a spinner inside a solid button is legible without the
78
+ // button telling it anything.
79
+ "charcuterie-spin inline-block shrink-0 rounded-full border-current border-t-transparent",
80
+ SPINNER_SIZE_CLASS[size],
81
+ )}
82
+ />
83
+
84
+ {isLabelVisible ? (
85
+ <span className="text-content-secondary">
86
+ {label}
87
+ </span>
88
+ ) : (
89
+ <VisuallyHidden>{label}</VisuallyHidden>
90
+ )}
91
+ </span>
92
+ )
@@ -0,0 +1,126 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import type { TabItem, TabsOrientation } from "./Tabs.tsx"
7
+
8
+ /**
9
+ * The selected marker runs along the edge the tab list itself
10
+ * sits on — under a horizontal bar, down the inline-end of a
11
+ * vertical rail — and the negative margin pulls it over the
12
+ * list's own border so the two read as one line rather than two.
13
+ *
14
+ * Logical properties throughout, which is the house rule and also
15
+ * the only way a vertical rail lands on the correct side in RTL.
16
+ */
17
+ const ORIENTATION_EDGE_CLASS: Record<
18
+ TabsOrientation,
19
+ { base: string; selected: string }
20
+ > = {
21
+ horizontal: {
22
+ base: "-mb-px border-b-2",
23
+ selected: "border-b-intent-accent-solid",
24
+ },
25
+ vertical: {
26
+ base: "-me-px border-e-2 text-start",
27
+ selected: "border-e-intent-accent-solid",
28
+ },
29
+ }
30
+
31
+ export type TabTriggerProps = {
32
+ id: string
33
+ isSelected: boolean
34
+ onSelect: (key: string) => void
35
+ orientation: TabsOrientation
36
+ panelId: string
37
+ /** `RovingFocus.register` — membership of the arrow-key group. */
38
+ registerFocus: (value: string) => () => void
39
+ /** `SinglePicker.register` — membership of the choice. */
40
+ registerSelection: (value: string) => () => void
41
+ tab: TabItem
42
+ tabIndex: number
43
+ trackElement: (
44
+ key: string,
45
+ element: HTMLButtonElement | null,
46
+ ) => void
47
+ }
48
+
49
+ /**
50
+ * Its own file for a reason that is not style: **both
51
+ * registrations are effects**, and an effect cannot run in a loop.
52
+ * Any member of a registering group ends up shaped like this, so
53
+ * it is worth seeing on its own.
54
+ *
55
+ * The two registrations are separate on purpose. A disabled tab
56
+ * joins the *choice* — it is still one of the options, still owns
57
+ * a panel and an id, and can still be the one selected when a
58
+ * consumer says so — and stays out of the *focus* group, so the
59
+ * arrow keys skip it without any command in `RovingFocus` having
60
+ * to know what "disabled" means. Registration is membership; that
61
+ * is the whole mechanism.
62
+ */
63
+ export const TabTrigger = ({
64
+ id,
65
+ isSelected,
66
+ onSelect,
67
+ orientation,
68
+ panelId,
69
+ registerFocus,
70
+ registerSelection,
71
+ tab,
72
+ tabIndex,
73
+ trackElement,
74
+ }: TabTriggerProps): ReactNode => {
75
+ const { isDisabled = false, key, label } = tab
76
+
77
+ const edge = ORIENTATION_EDGE_CLASS[orientation]
78
+
79
+ useEffect(
80
+ () => registerSelection(key),
81
+ [key, registerSelection],
82
+ )
83
+
84
+ useEffect(() => {
85
+ if (isDisabled) {
86
+ return
87
+ }
88
+
89
+ return registerFocus(key)
90
+ }, [isDisabled, key, registerFocus])
91
+
92
+ return (
93
+ <button
94
+ aria-controls={panelId}
95
+ aria-selected={isSelected}
96
+ className={toClassName(
97
+ "cursor-pointer border-transparent px-3 py-2 font-medium text-sm whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
98
+ edge.base,
99
+ // Two entries rather than one interpolated string:
100
+ // `tailwindCandidates.test.ts` rejects a template literal
101
+ // in a className outright, because Tailwind's scanner
102
+ // cannot see a class that only exists at runtime.
103
+ isSelected && edge.selected,
104
+ isSelected
105
+ ? "text-content-primary"
106
+ : "text-content-secondary hover:text-content-primary",
107
+ isDisabled &&
108
+ "cursor-not-allowed text-content-disabled hover:text-content-disabled",
109
+ FOCUS_RING_CLASS,
110
+ )}
111
+ disabled={isDisabled}
112
+ id={id}
113
+ onClick={() => {
114
+ onSelect(key)
115
+ }}
116
+ ref={(element) => {
117
+ trackElement(key, element)
118
+ }}
119
+ role="tab"
120
+ tabIndex={tabIndex}
121
+ type="button"
122
+ >
123
+ {label}
124
+ </button>
125
+ )
126
+ }
@@ -0,0 +1,126 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as TabsStories from "./Tabs.stories";
4
+
5
+ <Meta of={TabsStories} />
6
+
7
+ # Tabs
8
+
9
+ **The state layer's falsification test.** Tabs is P0 for that reason and no other — the
10
+ fifteen ad-hoc tab bars that used to justify it were in the withdrawn evidence. The plan
11
+ was explicit: if this came out ugly, stop and reconsider the state layer before building
12
+ fifteen more components.
13
+
14
+ It did not come out ugly. The composition is two hooks and one decision.
15
+
16
+ <Canvas of={TabsStories.Default} />
17
+
18
+ ## Props
19
+
20
+ <Controls />
21
+
22
+ ## The composition
23
+
24
+ ```ts
25
+ const selection = useSinglePicker({ selectedValue: initialKey }) // which tab is chosen
26
+ const focus = useRovingFocus({ activeValue: initialKey }) // which tab is tabbable
27
+
28
+ // `automatic` is this line. `manual` is its absence.
29
+ if (activation === "automatic") selection.select(focus.activeValue)
30
+ ```
31
+
32
+ That one line is the whole difference between the two ARIA activation modes, and it is
33
+ only that small because **focus and selection are separate kinds**. In `manual`, focus
34
+ lands on a tab that is *not* chosen — a state the original three-kind model could not
35
+ represent at all, because it had one notion of "the current one" doing both jobs. The
36
+ `Interactive` story asserts both halves against the same component.
37
+
38
+ ## Selection, not visibility
39
+
40
+ M4 built this on `VisibilityGroup`, and the panels come out one-at-a-time either way: the
41
+ two cores are the same shape, deliberately. What settled it is **what registers**. The
42
+ thing that calls `register` here is the *tab*, not the panel, and the question a tab bar
43
+ asks is *which one did you pick* — so the kind that answers it is `SinglePicker`, and the
44
+ panel's `hidden` is derived from the answer rather than being the answer.
45
+
46
+ That is not bookkeeping. Reading it the other way made `aria-selected` a report about a
47
+ panel's visibility, so a tab bar with **no panels at all** — a segmented filter, a view
48
+ switcher that swaps a route — had to model its choice as a group of visibilities it did not
49
+ have. `SegmentedControl` is exactly that consumer, and it shares this core rather than
50
+ hand-rolling a fourth one.
51
+ [Decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
52
+
53
+ ## What it does not use, and why that is the answer
54
+
55
+ The plan expected `createLinkedIds` here too, alongside Visibility, VisibilityGroup, and
56
+ RovingFocus. **It does not fit, and forcing it would have been wrong.**
57
+
58
+ That kind exists for a *dynamic* trigger↔target pair — a `Popover` whose panel mounts and
59
+ unmounts, where the multiset is what stops `aria-controls` pointing at a node React has
60
+ already removed. A tab bar's pairing is static and known at render, so the ids are derived
61
+ from one `useUniqueId` and cannot get out of step at all. Three of the four kinds, and a
62
+ specific reason for the fourth.
63
+
64
+ ## Registration is membership
65
+
66
+ A disabled tab joins the **choice** — it is still one of the options, still owns a panel
67
+ and an id — and stays out of the **focus** group. That is all "disabled" means here: the
68
+ arrow keys skip it because it is not a member, and nothing in `RovingFocus` has to know the
69
+ word.
70
+
71
+ `TabTrigger` is its own file for a reason that is not style: both registrations are
72
+ effects, and an effect cannot run in a loop. Any member of a registering group ends up
73
+ shaped like this.
74
+
75
+ <Canvas of={TabsStories.AllVariants} />
76
+
77
+ ## `activeKey` is initial, not controlled
78
+
79
+ Read once, on the first render. A parent that needs to push a value calls the hook's own
80
+ setter; there is no controlled mode and no prop-sync effect, which is
81
+ [the M2 decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md) and
82
+ the thing that keeps the fleet's existing echo loops out of this library.
83
+
84
+ ## Focus is moved deliberately, and only then
85
+
86
+ `RovingFocus` is state; something still has to call `.focus()`. It happens only while the
87
+ tablist already contains the focused element — otherwise mounting a tab bar, or any parent
88
+ re-render that moved `activeValue`, yanks the caret out of whatever the user was doing.
89
+
90
+ <Canvas of={TabsStories.AllStates} />
91
+
92
+ ## What Tabs found in M3's own testing helper
93
+
94
+ `expectAgentDrivable` rejected every tab outright:
95
+
96
+ > Not agent-drivable: role "tab" named "Log" has a negative tabindex, so it can be clicked
97
+ > but never reached with Tab.
98
+
99
+ Correct for a standalone button, wrong for a tab. **A roving group's whole design is that
100
+ exactly one member is in the tab order** and the arrow keys reach the rest; a tab list
101
+ where every tab had `tabindex="0"` would be the bug.
102
+
103
+ So the rejection was not skipped for composite widgets — it was **replaced by the roving
104
+ rule itself**, which is the stronger assertion: inside a `tablist` / `listbox` / `menu` /
105
+ `radiogroup` / `tree` / `grid` / `toolbar`, exactly one enabled member may be in the tab
106
+ order. Zero strands the widget; several mean the pattern was never implemented.
107
+ `AllStates` proves the failing half too, with a four-line stub standing in for the canvas —
108
+ `AgentQueries` is structural precisely so that it can.
109
+
110
+ <Canvas of={TabsStories.Responsive} />
111
+
112
+ ## A tab bar scrolls, it does not wrap
113
+
114
+ A wrapped tablist puts tabs on two rows, which breaks the visual row and the mental model
115
+ the arrow keys give — "right" stops meaning right. `ContainerBoard` appears here as a width
116
+ harness rather than a container-query claim: `Tabs` declares no container, because nothing
117
+ about it needs to *know* its width. It only has to survive being narrow.
118
+
119
+ ## Manual activation is the falsification test
120
+
121
+ `automatic` and `manual` differ only in whether moving focus also *chooses* — and that
122
+ difference is *one line*, because focus and selection are separate kinds. Arrow through the
123
+ bar below and the underline stays put until Enter: focus has moved to a tab that is not
124
+ selected, which is a state a three-kind model cannot represent at all.
125
+
126
+ <Canvas of={TabsStories.Manual} />