@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,229 @@
1
+ import {
2
+ asyncTransitions,
3
+ useStatus,
4
+ } from "@charcuterie/logic"
5
+ import type { Meta, StoryObj } from "@storybook/react"
6
+ import { intentArgType } from "../argTypes.storyHelpers.ts"
7
+ import { Button } from "../Button/Button.tsx"
8
+ import {
9
+ ContainerBoard,
10
+ StoryCell,
11
+ StoryGrid,
12
+ StoryRow,
13
+ StorySection,
14
+ } from "../board.storyHelpers.tsx"
15
+ import { DotIcon } from "../icons.storyHelpers.tsx"
16
+ import {
17
+ getAsyncIntent,
18
+ getAsyncLabel,
19
+ } from "../statusIntent.ts"
20
+ import { Badge } from "./Badge.tsx"
21
+
22
+ const INTENTS = [
23
+ "neutral",
24
+ "accent",
25
+ "success",
26
+ "warning",
27
+ "danger",
28
+ "info",
29
+ ] as const
30
+
31
+ const meta = {
32
+ title: "Components/Badge",
33
+ component: Badge,
34
+ parameters: { layout: "padded" },
35
+ argTypes: { intent: intentArgType },
36
+ // The component's own defaults, restated — Storybook does not
37
+ // seed `args` from docgen, so an unstated default shows in the
38
+ // props table with nothing selected in its control.
39
+ args: {
40
+ appearance: "soft",
41
+ intent: "neutral",
42
+ overflow: "truncate",
43
+ size: "md",
44
+ },
45
+ } satisfies Meta<typeof Badge>
46
+
47
+ export default meta
48
+
49
+ type Story = StoryObj<typeof meta>
50
+
51
+ /**
52
+ * A badge has no role by design — it is a word about something
53
+ * else, not a live region — so the handle an agent reads off the row
54
+ * is its text.
55
+ */
56
+ export const Default: Story = {
57
+ args: { children: "running", intent: "info" },
58
+ }
59
+
60
+ export const AllVariants: Story = {
61
+ args: { children: "running" },
62
+ render: (badgeProps) => (
63
+ <StorySection title="Six intents x three appearances. rip-deck declares this map twice, in two files, with hardcoded hexes.">
64
+ <StoryGrid columns={6}>
65
+ {(["soft", "solid", "outline"] as const).flatMap(
66
+ (appearance) =>
67
+ INTENTS.map((intent) => (
68
+ <StoryCell
69
+ key={`${appearance}-${intent}`}
70
+ label={`${appearance} · ${intent}`}
71
+ >
72
+ <Badge
73
+ {...badgeProps}
74
+ appearance={appearance}
75
+ intent={intent}
76
+ >
77
+ {intent}
78
+ </Badge>
79
+ </StoryCell>
80
+ )),
81
+ )}
82
+ </StoryGrid>
83
+ </StorySection>
84
+ ),
85
+ }
86
+
87
+ export const AllStates: Story = {
88
+ args: { children: "running" },
89
+ render: (badgeProps) => (
90
+ <StorySection title="A badge is static — no hover, no focus, no disabled. What varies is size, an optional glyph, and how it sits in a real row.">
91
+ <StoryGrid columns={2}>
92
+ <StoryCell label="sm">
93
+ <Badge {...badgeProps} size="sm" />
94
+ </StoryCell>
95
+
96
+ <StoryCell label="md">
97
+ <Badge {...badgeProps} size="md" />
98
+ </StoryCell>
99
+
100
+ <StoryCell label="with a glyph">
101
+ <Badge
102
+ {...badgeProps}
103
+ icon={<DotIcon />}
104
+ intent="success"
105
+ >
106
+ verified
107
+ </Badge>
108
+ </StoryCell>
109
+
110
+ <StoryCell label="in a job row">
111
+ <div className="flex items-center gap-2 rounded-md border border-border-subtle bg-surface-raised p-2">
112
+ <span className="text-content-primary text-sm">
113
+ Blade Runner (1982)
114
+ </span>
115
+
116
+ <Badge intent="success" size="sm">
117
+ completed
118
+ </Badge>
119
+ </div>
120
+ </StoryCell>
121
+ </StoryGrid>
122
+ </StorySection>
123
+ ),
124
+ }
125
+
126
+ /**
127
+ * A label longer than the space it is given, which is the case that
128
+ * shipped broken: `shrink-0` plus `whitespace-nowrap` and no cap let
129
+ * the pill paint straight across the next column.
130
+ *
131
+ * `truncate` keeps the row height fixed and paints an ellipsis —
132
+ * and because that ellipsis is painted rather than inserted, a
133
+ * triple-click still selects the whole string and a screen reader
134
+ * still reads it. Hovering a clipped pill shows the rest.
135
+ *
136
+ * `wrap` is the answer where hover does not exist. The kiosk is a
137
+ * touch context, so a pill whose full text matters wraps there
138
+ * rather than hiding half of itself behind an affordance nobody can
139
+ * reach.
140
+ */
141
+ export const Responsive: Story = {
142
+ args: {
143
+ children: "quarantined — checksum mismatch on title 4",
144
+ intent: "warning",
145
+ },
146
+ render: (badgeProps) => (
147
+ <div className="flex flex-col gap-8">
148
+ <StorySection title="overflow: truncate (default) — one line, capped, hover for the rest">
149
+ <ContainerBoard>
150
+ <Badge {...badgeProps} />
151
+ </ContainerBoard>
152
+ </StorySection>
153
+
154
+ <StorySection title="overflow: wrap — the pill grows instead">
155
+ <ContainerBoard>
156
+ <Badge {...badgeProps} overflow="wrap" />
157
+ </ContainerBoard>
158
+ </StorySection>
159
+ </div>
160
+ ),
161
+ }
162
+
163
+ const AsyncStatusBadge = () => {
164
+ const { reset, status, transitionTo } = useStatus({
165
+ initialState: "idle",
166
+ transitions: asyncTransitions,
167
+ })
168
+
169
+ return (
170
+ <StoryRow>
171
+ <Badge intent={getAsyncIntent(status)}>
172
+ {getAsyncLabel(status)}
173
+ </Badge>
174
+
175
+ <Button
176
+ appearance="soft"
177
+ onClick={() => {
178
+ transitionTo("loading")
179
+ }}
180
+ size="sm"
181
+ >
182
+ Start
183
+ </Button>
184
+
185
+ <Button
186
+ appearance="soft"
187
+ intent="success"
188
+ onClick={() => {
189
+ transitionTo("success")
190
+ }}
191
+ size="sm"
192
+ >
193
+ Succeed
194
+ </Button>
195
+
196
+ <Button
197
+ appearance="soft"
198
+ intent="danger"
199
+ onClick={() => {
200
+ transitionTo("error")
201
+ }}
202
+ size="sm"
203
+ >
204
+ Fail
205
+ </Button>
206
+
207
+ <Button appearance="ghost" onClick={reset} size="sm">
208
+ Reset
209
+ </Button>
210
+ </StoryRow>
211
+ )
212
+ }
213
+
214
+ /**
215
+ * The M2 join, live: one `asyncTransitions` machine, one
216
+ * `getAsyncIntent`, and a badge that cannot render a state the
217
+ * machine does not have.
218
+ *
219
+ * Press *Succeed* before *Start* and the badge does not move:
220
+ * `idle → success` is not a legal edge, so the core throws (loudly,
221
+ * in the console) rather than painting a green pill over a job that
222
+ * never ran. That is the difference between this and mux-magic's
223
+ * `Record<string, string>`, which renders whatever string it is
224
+ * handed and colours an unknown one as nothing at all.
225
+ */
226
+ export const Interactive: Story = {
227
+ args: { children: "running" },
228
+ render: () => <AsyncStatusBadge />,
229
+ }
@@ -0,0 +1,153 @@
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import { expect, userEvent, waitFor } from "storybook/test"
6
+ import { test } from "vitest"
7
+
8
+ import { mountStory } from "../mountStory.testHelpers.ts"
9
+ import meta, * as stories from "./Badge.stories.tsx"
10
+
11
+ const { Default, Interactive, Responsive } =
12
+ composeStories(stories)
13
+
14
+ test("the text is queryable, which is what an agent matches on", async () => {
15
+ const { canvas } = await mountStory(Default)
16
+
17
+ // No role, deliberately — a badge is a word about something else,
18
+ // not a live region. So there is nothing to `getByRole` and the
19
+ // text is the whole contract.
20
+ await expect(canvas.getByText("running")).toBeVisible()
21
+ })
22
+
23
+ /**
24
+ * The M2 join, driven: one `asyncTransitions` machine, one
25
+ * `getAsyncIntent`, and a badge that cannot render a state the
26
+ * machine does not have.
27
+ */
28
+ test("the badge follows the status machine through a full cycle", async () => {
29
+ const { canvas } = await mountStory(Interactive)
30
+
31
+ await expect(canvas.getByText("Idle")).toBeVisible()
32
+
33
+ await userEvent.click(
34
+ canvas.getByRole("button", { name: "Start" }),
35
+ )
36
+
37
+ await expect(canvas.getByText("Loading…")).toBeVisible()
38
+
39
+ await userEvent.click(
40
+ canvas.getByRole("button", { name: "Succeed" }),
41
+ )
42
+
43
+ await expect(canvas.getByText("Done")).toBeVisible()
44
+
45
+ await userEvent.click(
46
+ canvas.getByRole("button", { name: "Reset" }),
47
+ )
48
+
49
+ await expect(canvas.getByText("Idle")).toBeVisible()
50
+ })
51
+
52
+ const LONG_LABEL =
53
+ "quarantined — checksum mismatch on title 4"
54
+
55
+ /**
56
+ * A container narrower than the label needs, which is the whole
57
+ * point. The width goes on the canvas rather than on the badge —
58
+ * constraining the badge itself would prove nothing about
59
+ * `max-inline-size: 100%`, which is the fix under test.
60
+ */
61
+ const mountInPanel = async (
62
+ overflow: "truncate" | "wrap",
63
+ ) => {
64
+ const { canvasElement } = await mountStory(
65
+ composeStory(
66
+ { args: { children: LONG_LABEL, overflow } },
67
+ meta,
68
+ ),
69
+ )
70
+
71
+ canvasElement.style.inlineSize = "10rem"
72
+
73
+ return {
74
+ badge: canvasElement.firstElementChild as HTMLElement,
75
+ container: canvasElement,
76
+ }
77
+ }
78
+
79
+ test("a truncated badge never paints outside its container", async () => {
80
+ const { badge, container } =
81
+ await mountInPanel("truncate")
82
+
83
+ const label = badge.firstElementChild as HTMLElement
84
+
85
+ await waitFor(() => {
86
+ expect(
87
+ badge.getBoundingClientRect().width,
88
+ ).toBeLessThanOrEqual(
89
+ container.getBoundingClientRect().width,
90
+ )
91
+ })
92
+
93
+ // Clipped — so an ellipsis is being painted.
94
+ await expect(label.scrollWidth).toBeGreaterThan(
95
+ label.clientWidth,
96
+ )
97
+
98
+ // …and yet the whole string is still there. `text-overflow` paints
99
+ // the ellipsis, it does not insert one, so selection, copy, and
100
+ // every screen reader still get all of it. This is the assertion
101
+ // that would fail if anyone ever "fixed" truncation by slicing the
102
+ // string in JavaScript.
103
+ await expect(badge.textContent).toBe(LONG_LABEL)
104
+
105
+ // And the visual half of the readout, for the pointer users who
106
+ // cannot select it to find out.
107
+ await waitFor(() => {
108
+ expect(badge).toHaveAttribute("title", LONG_LABEL)
109
+ })
110
+ })
111
+
112
+ test("a short badge gets no tooltip", async () => {
113
+ const { canvasElement } = await mountStory(Default)
114
+
115
+ // The measurement has to be able to say *no*, or `title` is just
116
+ // unconditional and every pill in a bay list grows a tooltip.
117
+ await waitFor(() => {
118
+ expect(
119
+ canvasElement.querySelector("[title]"),
120
+ ).toBeNull()
121
+ })
122
+ })
123
+
124
+ test("a wrapping badge grows taller rather than wider", async () => {
125
+ const truncated = await mountInPanel("truncate")
126
+
127
+ const truncatedHeight = truncated.badge.clientHeight
128
+
129
+ const wrapped = await mountInPanel("wrap")
130
+
131
+ await expect(wrapped.badge.clientHeight).toBeGreaterThan(
132
+ truncatedHeight,
133
+ )
134
+
135
+ await expect(
136
+ wrapped.badge.getBoundingClientRect().width,
137
+ ).toBeLessThanOrEqual(
138
+ wrapped.container.getBoundingClientRect().width,
139
+ )
140
+
141
+ // Nothing is hidden in this mode, so nothing needs a tooltip.
142
+ await waitFor(() => {
143
+ expect(wrapped.badge).not.toHaveAttribute("title")
144
+ })
145
+ })
146
+
147
+ test("the responsive board shows both modes at three widths", async () => {
148
+ const { canvas } = await mountStory(Responsive)
149
+
150
+ await expect(
151
+ canvas.getAllByText(LONG_LABEL),
152
+ ).toHaveLength(6)
153
+ })
@@ -0,0 +1,130 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+
7
+ import type { BadgeSize } from "../controlStyles.ts"
8
+ import { BADGE_SIZE_CLASS } from "../controlStyles.ts"
9
+ import type { IntentAppearance } from "../intentStyles.ts"
10
+ import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
11
+ import { toClassName } from "../toClassName.ts"
12
+ import { useIsTextClipped } from "./useIsTextClipped.ts"
13
+
14
+ export type BadgeProps = ComponentPropsWithRef<"span"> & {
15
+ appearance?: Exclude<IntentAppearance, "ghost">
16
+ children: ReactNode
17
+ icon?: ReactNode
18
+ intent?: IntentName
19
+ /**
20
+ * What happens when the label is wider than the space it is given.
21
+ *
22
+ * - `truncate` — one line, capped at the container, ellipsis. The
23
+ * default, because a status pill that changes its row's height
24
+ * when a message gets longer breaks the table it sits in.
25
+ * - `wrap` — the pill grows taller and shows everything. For the
26
+ * kiosk and any touch context, where the hover readout that
27
+ * `truncate` relies on does not exist.
28
+ */
29
+ overflow?: "truncate" | "wrap"
30
+ size?: BadgeSize
31
+ }
32
+
33
+ /**
34
+ * Four repos, and rip-deck declares the **identical** `TONE_CLASS`
35
+ * map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
36
+ * hardcoded hexes, no relationship to each other beyond
37
+ * copy-paste. mux-magic's `statusClassMap` is the same idea spelled
38
+ * a third way, keyed by a bare `string` so an unrecognised status
39
+ * renders with no colour at all and no error.
40
+ *
41
+ * `intent` replaces all of it. The **status** half of the problem —
42
+ * which state maps to which intent, and what it is called in
43
+ * English — lives in `statusIntent.ts` as exhaustive switches over
44
+ * `@charcuterie/logic`'s shared machines, so
45
+ * `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
46
+ * is the whole of mux-magic's `StatusBadge`, and adding a state to
47
+ * the machine is a compile error rather than a blank pill.
48
+ *
49
+ * **No implicit role.** A badge is a word about something else; it
50
+ * is not a live region, and giving it `role="status"` would make
51
+ * every re-render announce itself. Where a status genuinely needs
52
+ * announcing that is `LiveStatusIndicator`'s job, and where a badge
53
+ * needs a name beyond its text, `aria-label` still works. The story
54
+ * asserts the text is queryable, which is what an agent actually
55
+ * matches on.
56
+ *
57
+ * **It is capped at its container.** `shrink-0` and
58
+ * `whitespace-nowrap` alone let a long label paint straight through
59
+ * the neighbouring column — no clipping, no error, just a pill lying
60
+ * across the next cell. `max-inline-size: 100%` is what stops it, and
61
+ * `overflow` is what happens next.
62
+ */
63
+ export const Badge = ({
64
+ appearance = "soft",
65
+ children,
66
+ className,
67
+ icon,
68
+ intent = "neutral",
69
+ overflow = "truncate",
70
+ size = "md",
71
+ title,
72
+ ...spanProps
73
+ }: BadgeProps): ReactNode => {
74
+ const [labelRef, clippedText] = useIsTextClipped()
75
+
76
+ return (
77
+ <span
78
+ {...spanProps}
79
+ className={toClassName(
80
+ "inline-flex shrink-0 items-center rounded-full border font-medium",
81
+ // The cap. Without it every other rule here is decoration.
82
+ "max-w-full",
83
+ overflow === "wrap"
84
+ ? // A stadium end-cap on a three-line box reads as a
85
+ // rendering fault rather than a badge, so the wrapping
86
+ // pill relaxes to a rounded rectangle.
87
+ "rounded-2xl"
88
+ : "rounded-full",
89
+ BADGE_SIZE_CLASS[size],
90
+ INTENT_APPEARANCE_CLASS[intent][appearance],
91
+ className,
92
+ )}
93
+ // Only when something is actually hidden. A tooltip on every
94
+ // short pill in a bay list is noise, and a `title` that
95
+ // duplicates fully-visible text is a screen-reader duplicate
96
+ // for no gain.
97
+ title={title ?? clippedText}
98
+ >
99
+ {icon ? (
100
+ <span
101
+ aria-hidden="true"
102
+ className="contents shrink-0"
103
+ >
104
+ {icon}
105
+ </span>
106
+ ) : null}
107
+
108
+ {/*
109
+ * The truncation happens here rather than on the pill so the
110
+ * border and the rounded end-cap stay outside the clip — an
111
+ * `overflow: hidden` on the pill itself squares off the end
112
+ * the ellipsis is nearest to.
113
+ *
114
+ * `min-w-0` because a flex item's automatic minimum size is
115
+ * its content, which would win against `max-w-full` on the
116
+ * parent and put the overflow back.
117
+ */}
118
+ <span
119
+ className={
120
+ overflow === "wrap"
121
+ ? "min-w-0"
122
+ : "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
123
+ }
124
+ ref={labelRef}
125
+ >
126
+ {children}
127
+ </span>
128
+ </span>
129
+ )
130
+ }
@@ -0,0 +1,77 @@
1
+ import type { RefObject } from "react"
2
+ import { useEffect, useRef, useState } from "react"
3
+
4
+ /**
5
+ * The element's full text, but only while that text is wider than
6
+ * the box it was given — i.e. only while an ellipsis is being
7
+ * painted. `undefined` the rest of the time.
8
+ *
9
+ * `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
10
+ * painted by the layout engine and the whole string stays in
11
+ * `textContent`. That is the good news — selection, copy, and every
12
+ * screen reader still get all of it — and the reason this hook has
13
+ * to exist, because nothing in the DOM says "this one is cut off",
14
+ * so a hover affordance cannot be attached declaratively.
15
+ *
16
+ * Measured rather than assumed, since the alternative — always
17
+ * setting a `title` — puts a tooltip on every short status pill in a
18
+ * bay list, which is worse than the problem it solves.
19
+ *
20
+ * The text is returned rather than a boolean so the caller never
21
+ * reads `ref.current` during render.
22
+ */
23
+ export const useIsTextClipped = (): [
24
+ RefObject<HTMLSpanElement | null>,
25
+ string | undefined,
26
+ ] => {
27
+ const elementRef = useRef<HTMLSpanElement>(null)
28
+
29
+ const [clippedText, setClippedText] = useState<
30
+ string | undefined
31
+ >(undefined)
32
+
33
+ // No dependency array: `children` is a `ReactNode`, so there is
34
+ // nothing stable to compare against, and a re-render is exactly
35
+ // when the text may have changed. `setState` bails out on an equal
36
+ // value, so the common case costs one `scrollWidth` read.
37
+ useEffect(() => {
38
+ const element = elementRef.current
39
+
40
+ if (!element) {
41
+ return
42
+ }
43
+
44
+ setClippedText(
45
+ element.scrollWidth > element.clientWidth
46
+ ? (element.textContent ?? undefined)
47
+ : undefined,
48
+ )
49
+ })
50
+
51
+ // And a resize of the *container* changes the answer without
52
+ // re-rendering anything — which is the container-query case this
53
+ // whole library is built around.
54
+ useEffect(() => {
55
+ const element = elementRef.current
56
+
57
+ if (!element) {
58
+ return
59
+ }
60
+
61
+ const observer = new ResizeObserver(() => {
62
+ setClippedText(
63
+ element.scrollWidth > element.clientWidth
64
+ ? (element.textContent ?? undefined)
65
+ : undefined,
66
+ )
67
+ })
68
+
69
+ observer.observe(element)
70
+
71
+ return () => {
72
+ observer.disconnect()
73
+ }
74
+ }, [])
75
+
76
+ return [elementRef, clippedText]
77
+ }
@@ -0,0 +1,44 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ButtonStories from "./Button.stories";
4
+
5
+ <Meta of={ButtonStories} />
6
+
7
+ # Button
8
+
9
+ Hand-rolled in **seven repos**, each with its own primary/secondary/danger/ghost pair and
10
+ its own idea of what "disabled" looks like. This is the single largest duplication in the
11
+ fleet.
12
+
13
+ Six intents x four appearances x three sizes, all of it from tier-2 tokens — so a variant
14
+ swap or a density flip repaints every button on the page with no prop change and no
15
+ re-render.
16
+
17
+ <Canvas of={ButtonStories.Default} />
18
+
19
+ ## Props
20
+
21
+ <Controls />
22
+
23
+ ## What it fixes
24
+
25
+ | Fleet behaviour today | Here |
26
+ | --- | --- |
27
+ | `<button>` with no `type`, submitting the form it happens to sit in | `type="button"` by default |
28
+ | `outline-none` with nothing put back, or a ring on mouse click | `focus-visible` only, at the variant's own ring width |
29
+ | A spinner that replaces the label, so you cannot tell what you pressed | Spinner replaces `iconStart`; the label stays, `aria-busy` goes on |
30
+ | A `size="kiosk"` prop, or a second component for touch | `data-density="kiosk"` on `<html>` |
31
+
32
+ ## Accessibility contract
33
+
34
+ - The accessible name is the children. A glyph is not a name — use `IconButton`, which
35
+ requires one.
36
+ - `isLoading` disables the button and announces through the `Spinner`'s `role="status"`.
37
+ - `isDisabled` renders a real `disabled` attribute: skipped by Tab, ignored by click, and
38
+ reported to assistive technology. The `DisabledDoesNotFire` story asserts all three.
39
+ - Contrast on the disabled treatment leans on WCAG 1.4.3's exemption for *inactive*
40
+ controls, which is honest only on something actually disabled — see `DISABLED_CLASS`.
41
+
42
+ <Canvas of={ButtonStories.AllVariants} />
43
+
44
+ <Canvas of={ButtonStories.AllStates} />