@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,191 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+
5
+ import {
6
+ ContainerBoard,
7
+ StoryCell,
8
+ StoryGrid,
9
+ StoryRow,
10
+ StorySection,
11
+ } from "../board.storyHelpers.tsx"
12
+ import {
13
+ RedoIcon,
14
+ SettingsIcon,
15
+ UndoIcon,
16
+ } from "../icons.storyHelpers.tsx"
17
+ import { IconButton } from "./IconButton.tsx"
18
+
19
+ /**
20
+ * The library ships **no icons** — lucide is the fleet recommendation
21
+ * and `children` is whatever the app already has, so these are
22
+ * story-only SVGs.
23
+ *
24
+ * `RawGlyph` below keeps plex-channels' actual `↶` for comparison:
25
+ * the glyph is unchanged, the *name* is what this component adds.
26
+ */
27
+ const UNDO_ICON = <UndoIcon />
28
+
29
+ const meta = {
30
+ title: "Components/IconButton",
31
+ component: IconButton,
32
+ parameters: { layout: "padded" },
33
+ argTypes: { size: controlSizeArgType },
34
+ args: {
35
+ appearance: "ghost",
36
+ isDisabled: false,
37
+ isLoading: false,
38
+ size: "md",
39
+ },
40
+ } satisfies Meta<typeof IconButton>
41
+
42
+ export default meta
43
+
44
+ type Story = StoryObj<typeof meta>
45
+
46
+ /**
47
+ * The name is the whole component. plex-channels renders `↶` into a
48
+ * bare `<button>` today, so a screen reader announces "↶" and
49
+ * `getByRole("button", { name: "Undo" })` finds nothing.
50
+ */
51
+ export const Default: Story = {
52
+ args: { children: UNDO_ICON, label: "Undo" },
53
+ }
54
+
55
+ export const AllVariants: Story = {
56
+ args: { children: UNDO_ICON, label: "Undo" },
57
+ render: (iconButtonProps) => (
58
+ <StorySection title="Same four appearances as Button — ghost is the toolbar default, because a row of six solid squares is a wall.">
59
+ <StoryGrid columns={4}>
60
+ <StoryCell label="solid">
61
+ <IconButton
62
+ {...iconButtonProps}
63
+ appearance="solid"
64
+ />
65
+ </StoryCell>
66
+
67
+ <StoryCell label="soft">
68
+ <IconButton
69
+ {...iconButtonProps}
70
+ appearance="soft"
71
+ />
72
+ </StoryCell>
73
+
74
+ <StoryCell label="outline">
75
+ <IconButton
76
+ {...iconButtonProps}
77
+ appearance="outline"
78
+ />
79
+ </StoryCell>
80
+
81
+ <StoryCell label="ghost">
82
+ <IconButton
83
+ {...iconButtonProps}
84
+ appearance="ghost"
85
+ />
86
+ </StoryCell>
87
+ </StoryGrid>
88
+ </StorySection>
89
+ ),
90
+ }
91
+
92
+ export const AllStates: Story = {
93
+ args: { children: UNDO_ICON, label: "Undo" },
94
+ parameters: {
95
+ pseudo: {
96
+ active: ["#active"],
97
+ focusVisible: ["#focus-target"],
98
+ hover: ["#hover"],
99
+ },
100
+ },
101
+ render: (iconButtonProps) => (
102
+ <StoryGrid columns={4}>
103
+ <StoryCell label="default">
104
+ <IconButton {...iconButtonProps} />
105
+ </StoryCell>
106
+
107
+ <StoryCell label="hover (forced)">
108
+ <IconButton {...iconButtonProps} id="hover" />
109
+ </StoryCell>
110
+
111
+ <StoryCell label="active (forced)">
112
+ <IconButton {...iconButtonProps} id="active" />
113
+ </StoryCell>
114
+
115
+ <StoryCell label="focus-visible (forced)">
116
+ <IconButton
117
+ {...iconButtonProps}
118
+ id="focus-target"
119
+ />
120
+ </StoryCell>
121
+
122
+ <StoryCell label="disabled">
123
+ <IconButton {...iconButtonProps} isDisabled />
124
+ </StoryCell>
125
+
126
+ <StoryCell label="loading">
127
+ <IconButton {...iconButtonProps} isLoading />
128
+ </StoryCell>
129
+ </StoryGrid>
130
+ ),
131
+ }
132
+
133
+ export const Sizes: Story = {
134
+ args: { children: UNDO_ICON, label: "Undo" },
135
+ render: (iconButtonProps) => (
136
+ <StorySection title="Square on the control height, so an icon button lines up with the text button beside it.">
137
+ <StoryRow>
138
+ <IconButton {...iconButtonProps} size="sm" />
139
+
140
+ <IconButton {...iconButtonProps} size="md" />
141
+
142
+ <IconButton {...iconButtonProps} size="lg" />
143
+ </StoryRow>
144
+ </StorySection>
145
+ ),
146
+ }
147
+
148
+ export const Responsive: Story = {
149
+ args: { children: UNDO_ICON, label: "Undo" },
150
+ render: (iconButtonProps) => (
151
+ <ContainerBoard>
152
+ <StoryRow>
153
+ <IconButton
154
+ {...iconButtonProps}
155
+ appearance="ghost"
156
+ />
157
+
158
+ <IconButton
159
+ {...iconButtonProps}
160
+ appearance="ghost"
161
+ label="Redo"
162
+ >
163
+ <RedoIcon />
164
+ </IconButton>
165
+
166
+ <IconButton
167
+ {...iconButtonProps}
168
+ appearance="ghost"
169
+ label="Settings"
170
+ >
171
+ <SettingsIcon />
172
+ </IconButton>
173
+ </StoryRow>
174
+ </ContainerBoard>
175
+ ),
176
+ }
177
+
178
+ /**
179
+ * plex-channels' `↶`, rendered exactly as it is today — and named,
180
+ * which it is not today. `getByRole("button", { name: "Undo" })`
181
+ * resolves here and finds nothing there.
182
+ *
183
+ * A glyph depends on a font having that code point: this repo's
184
+ * headless Chromium has none for `↶`, so in a CI screenshot the
185
+ * button is empty while the *name* is still correct. That is the
186
+ * argument for an SVG icon set, made by the story rather than in a
187
+ * comment.
188
+ */
189
+ export const RawGlyph: Story = {
190
+ args: { children: "↶", label: "Undo" },
191
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import { expect, fn, userEvent } from "storybook/test"
6
+ import { test } from "vitest"
7
+
8
+ import { UndoIcon } from "../icons.storyHelpers.tsx"
9
+ import { mountStory } from "../mountStory.testHelpers.ts"
10
+ import { expectAgentDrivable } from "../testing/index.ts"
11
+ import meta, * as stories from "./IconButton.stories.tsx"
12
+
13
+ const { Default, RawGlyph } = composeStories(stories)
14
+
15
+ /**
16
+ * The assertion the fleet fails today: plex-channels renders `↶`
17
+ * into a bare `<button>`, so a screen reader announces "↶" and this
18
+ * query finds nothing.
19
+ */
20
+ test("a glyph gets a name", async () => {
21
+ const { canvas } = await mountStory(Default)
22
+
23
+ expectAgentDrivable(canvas, {
24
+ name: "Undo",
25
+ role: "button",
26
+ })
27
+ })
28
+
29
+ test("the same is true of a raw text glyph", async () => {
30
+ const { canvas } = await mountStory(RawGlyph)
31
+
32
+ // The glyph is unchanged from what plex-channels ships; the *name*
33
+ // is what this component adds. A font without the code point
34
+ // renders nothing at all — as this repo's headless chromium does —
35
+ // and the name still resolves.
36
+ expectAgentDrivable(canvas, {
37
+ name: "Undo",
38
+ role: "button",
39
+ })
40
+ })
41
+
42
+ test("Tab reaches it and Enter activates", async () => {
43
+ const onClick = fn()
44
+
45
+ const { canvas } = await mountStory(
46
+ composeStory(
47
+ {
48
+ args: {
49
+ children: <UndoIcon />,
50
+ label: "Undo",
51
+ onClick,
52
+ },
53
+ },
54
+ meta,
55
+ ),
56
+ )
57
+
58
+ const button = expectAgentDrivable(canvas, {
59
+ name: "Undo",
60
+ role: "button",
61
+ })
62
+
63
+ await userEvent.tab()
64
+
65
+ await expect(button).toHaveFocus()
66
+
67
+ await userEvent.keyboard("{Enter}")
68
+
69
+ await expect(onClick).toHaveBeenCalledTimes(1)
70
+ })
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import type { ButtonProps } from "../Button/Button.tsx"
4
+ import { Button } from "../Button/Button.tsx"
5
+
6
+ export type IconButtonProps = Omit<
7
+ ButtonProps,
8
+ | "aria-label"
9
+ | "children"
10
+ | "iconEnd"
11
+ | "iconStart"
12
+ | "sizing"
13
+ > & {
14
+ /**
15
+ * Required, and this single line is most of the component's
16
+ * reason to exist. `label` cannot be defaulted, cannot be
17
+ * omitted, and is not a `ReactNode` — it becomes `aria-label`,
18
+ * which is a string by specification.
19
+ */
20
+ label: string
21
+ /** The glyph. Anything — lucide, an inline SVG, a character. */
22
+ children: ReactNode
23
+ }
24
+
25
+ /**
26
+ * Three repos, three strategies, and **none of them names the
27
+ * button**: mux-magic hand-writes five SVG components, castkit
28
+ * keeps an `ICON_PATHS` map, and plex-channels renders raw glyphs
29
+ * (`↶`, `↷`, `▶`, `⚙`, `≡`) straight into a `<button>`. A screen
30
+ * reader reads the last of those as "↶", and
31
+ * `getByRole("button", { name: "Undo" })` finds nothing at all —
32
+ * which is the concrete reason the fleet is not agent-drivable
33
+ * today.
34
+ *
35
+ * So the type system asks for the name. There is no way to render
36
+ * this component without one, and `expectAgentDrivable` in the
37
+ * story proves the name is the one Playwright will match on.
38
+ *
39
+ * **No icons ship here.** The library does not own an icon set —
40
+ * lucide (ISC) is the fleet recommendation — so `children` is
41
+ * whatever the app already has, and the icon is `aria-hidden` by
42
+ * virtue of the `aria-label` overriding the subtree name.
43
+ */
44
+ export const IconButton = ({
45
+ children,
46
+ label,
47
+ ...buttonProps
48
+ }: IconButtonProps): ReactNode => (
49
+ <Button {...buttonProps} aria-label={label} sizing="icon">
50
+ <span aria-hidden="true" className="contents">
51
+ {children}
52
+ </span>
53
+ </Button>
54
+ )
@@ -0,0 +1,43 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as LiveStatusIndicatorStories from "./LiveStatusIndicator.stories";
4
+
5
+ <Meta of={LiveStatusIndicatorStories} />
6
+
7
+ # LiveStatusIndicator
8
+
9
+ Four repos, four implementations, and **it is the thing users read most** — a wall-mounted
10
+ panel showing stale data under a green dot is worse than one showing nothing.
11
+
12
+ <Canvas of={LiveStatusIndicatorStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## The distinction all four repos lose
19
+
20
+ `connecting` and `reconnecting` are different states and they are collapsed everywhere
21
+ today. A user who has seen data wants "lost it, getting it back" (amber, moving), not the
22
+ cold-start spinner they already sat through (blue), and not a dead link (red). That is why
23
+ `connectionTransitions` is one of only two machines `@charcuterie/logic` ships rather than
24
+ something each app defines.
25
+
26
+ Wording and colour both come from exhaustive switches over that machine, so adding a fifth
27
+ state stops this package compiling instead of rendering a grey dot.
28
+
29
+ <Canvas of={LiveStatusIndicatorStories.AllVariants} />
30
+
31
+ ## Accessibility contract
32
+
33
+ - `role="status"` + `aria-live="polite"`, so a transition is announced without interrupting
34
+ what the user is doing. `assertive` would cut off the sentence in progress, and the dot is
35
+ not going anywhere.
36
+ - `aria-label` as well, because a `status` region takes no accessible name from its content
37
+ — that is what makes `getByRole("status", { name: "Connected" })` resolve.
38
+ - Only `connecting` and `reconnecting` animate. A dot that pulses forever is the fleet's
39
+ current "is it alive?" problem in a new costume.
40
+ - `data-status` is the stable handle for a Playwright assertion — it survives translation,
41
+ where the visible wording does not.
42
+
43
+ <Canvas of={LiveStatusIndicatorStories.Interactive} />
@@ -0,0 +1,199 @@
1
+ import {
2
+ connectionTransitions,
3
+ useStatus,
4
+ } from "@charcuterie/logic"
5
+ import type { Meta, StoryObj } from "@storybook/react"
6
+
7
+ import { connectionStatusArgType } from "../argTypes.storyHelpers.ts"
8
+ import { Button } from "../Button/Button.tsx"
9
+ import {
10
+ ContainerBoard,
11
+ StoryCell,
12
+ StoryGrid,
13
+ StoryRow,
14
+ StorySection,
15
+ } from "../board.storyHelpers.tsx"
16
+ import { Card } from "../Card/Card.tsx"
17
+ import { LiveStatusIndicator } from "./LiveStatusIndicator.tsx"
18
+
19
+ const CONNECTION_STATUSES = [
20
+ "connecting",
21
+ "connected",
22
+ "reconnecting",
23
+ "disconnected",
24
+ ] as const
25
+
26
+ const meta = {
27
+ title: "Components/LiveStatusIndicator",
28
+ component: LiveStatusIndicator,
29
+ parameters: { layout: "padded" },
30
+ argTypes: { status: connectionStatusArgType },
31
+ args: { isLabelVisible: true, size: "md" },
32
+ } satisfies Meta<typeof LiveStatusIndicator>
33
+
34
+ export default meta
35
+
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ /**
39
+ * A named live region, plus a `data-status` attribute — the stable
40
+ * handle for a Playwright assertion, because it survives translation
41
+ * where the visible wording does not.
42
+ */
43
+ export const Default: Story = {
44
+ args: { status: "connected" },
45
+ }
46
+
47
+ /**
48
+ * The four states, and the distinction four repos currently lose:
49
+ * `connecting` is blue and cold-start, `reconnecting` is amber and
50
+ * means "you had data, I am getting it back". Collapsing them is why
51
+ * a flaky link reads wrong today.
52
+ */
53
+ export const AllVariants: Story = {
54
+ args: { status: "connected" },
55
+ render: (indicatorProps) => (
56
+ <StorySection title="Wording and colour both come from the shared machine — add a fifth state to `connectionTransitions` and `statusIntent.ts` stops compiling.">
57
+ <StoryGrid columns={4}>
58
+ {CONNECTION_STATUSES.map((status) => (
59
+ <StoryCell key={status} label={status}>
60
+ <LiveStatusIndicator
61
+ {...indicatorProps}
62
+ status={status}
63
+ />
64
+ </StoryCell>
65
+ ))}
66
+ </StoryGrid>
67
+ </StorySection>
68
+ ),
69
+ }
70
+
71
+ export const AllStates: Story = {
72
+ args: { status: "reconnecting" },
73
+ render: (indicatorProps) => (
74
+ <StoryGrid columns={2}>
75
+ <StoryCell label="label visible (default)">
76
+ <LiveStatusIndicator {...indicatorProps} />
77
+ </StoryCell>
78
+
79
+ <StoryCell label="dot only — label still announced">
80
+ <LiveStatusIndicator
81
+ {...indicatorProps}
82
+ isLabelVisible={false}
83
+ />
84
+ </StoryCell>
85
+
86
+ <StoryCell label="with detail">
87
+ <LiveStatusIndicator
88
+ {...indicatorProps}
89
+ detail="3 of 4 bays reporting"
90
+ />
91
+ </StoryCell>
92
+
93
+ <StoryCell label="sizes">
94
+ <StoryRow>
95
+ <LiveStatusIndicator
96
+ {...indicatorProps}
97
+ size="sm"
98
+ />
99
+
100
+ <LiveStatusIndicator
101
+ {...indicatorProps}
102
+ size="md"
103
+ />
104
+
105
+ <LiveStatusIndicator
106
+ {...indicatorProps}
107
+ size="lg"
108
+ />
109
+ </StoryRow>
110
+ </StoryCell>
111
+
112
+ {/*
113
+ `stretch`, because `Card` declares a container query. A
114
+ default `start` cell is shrink-to-fit, and
115
+ `container-type: inline-size` forbids the element from
116
+ being sized by its own contents — so the card collapses to
117
+ min-content and "16 bays · 4 ripping" wraps after every
118
+ word. `storyRules.test.ts` now fails on this.
119
+ */}
120
+ <StoryCell
121
+ align="stretch"
122
+ label="in a card header — the real placement"
123
+ >
124
+ <Card
125
+ actions={
126
+ <LiveStatusIndicator
127
+ status="connected"
128
+ size="sm"
129
+ />
130
+ }
131
+ heading="Tower"
132
+ >
133
+ <p className="text-content-secondary text-sm">
134
+ 16 bays · 4 ripping
135
+ </p>
136
+ </Card>
137
+ </StoryCell>
138
+ </StoryGrid>
139
+ ),
140
+ }
141
+
142
+ export const Responsive: Story = {
143
+ args: {
144
+ detail: "3 of 4 bays reporting",
145
+ status: "reconnecting",
146
+ },
147
+ render: (indicatorProps) => (
148
+ <ContainerBoard>
149
+ <LiveStatusIndicator {...indicatorProps} />
150
+ </ContainerBoard>
151
+ ),
152
+ }
153
+
154
+ const ConnectionLifecycle = () => {
155
+ const { can, status, transitionTo } = useStatus({
156
+ initialState: "connecting",
157
+ transitions: connectionTransitions,
158
+ })
159
+
160
+ return (
161
+ <div className="flex flex-col gap-3">
162
+ <LiveStatusIndicator status={status} />
163
+
164
+ <StoryRow>
165
+ {CONNECTION_STATUSES.map((next) => (
166
+ <Button
167
+ appearance="soft"
168
+ isDisabled={!can(next) && status !== next}
169
+ key={next}
170
+ onClick={() => {
171
+ transitionTo(next)
172
+ }}
173
+ size="sm"
174
+ >
175
+ {next}
176
+ </Button>
177
+ ))}
178
+ </StoryRow>
179
+
180
+ <p className="text-content-muted text-xs">
181
+ Buttons disable themselves on illegal edges —
182
+ `connected → connecting` is not one, because a live
183
+ link that wants to reconnect goes through
184
+ `reconnecting`.
185
+ </p>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /**
191
+ * The live region, driven. Each click is a real transition through
192
+ * `connectionTransitions`, and the indicator's wording, colour, and
193
+ * whether the dot moves all follow from the state rather than from
194
+ * four independent booleans.
195
+ */
196
+ export const Interactive: Story = {
197
+ args: { status: "connecting" },
198
+ render: () => <ConnectionLifecycle />,
199
+ }
@@ -0,0 +1,63 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } 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 "./LiveStatusIndicator.stories.tsx"
8
+
9
+ const { Default, Interactive } = composeStories(stories)
10
+
11
+ test("it is a named live region with a stable status handle", async () => {
12
+ const { canvas } = await mountStory(Default)
13
+
14
+ const indicator = expectAgentDrivable(canvas, {
15
+ name: "Connected",
16
+ role: "status",
17
+ })
18
+
19
+ // `data-status` survives translation; the visible wording does
20
+ // not. A Playwright assertion in a consumer app wants the former.
21
+ await expect(indicator).toHaveAttribute(
22
+ "data-status",
23
+ "connected",
24
+ )
25
+ })
26
+
27
+ /**
28
+ * The live region, driven. Each click is a real transition through
29
+ * `connectionTransitions`, so the wording, the colour, and whether
30
+ * the dot moves all follow from one state rather than from four
31
+ * independent booleans.
32
+ */
33
+ test("the indicator follows the connection machine", async () => {
34
+ const { canvas } = await mountStory(Interactive)
35
+
36
+ const indicator = canvas.getByRole("status")
37
+
38
+ await expect(indicator).toHaveAttribute(
39
+ "data-status",
40
+ "connecting",
41
+ )
42
+
43
+ await userEvent.click(
44
+ canvas.getByRole("button", { name: "connected" }),
45
+ )
46
+
47
+ await expect(canvas.getByText("Connected")).toBeVisible()
48
+
49
+ await userEvent.click(
50
+ canvas.getByRole("button", { name: "reconnecting" }),
51
+ )
52
+
53
+ await expect(
54
+ canvas.getByText("Reconnecting…"),
55
+ ).toBeVisible()
56
+
57
+ // The distinction four repos currently lose: `reconnecting` is
58
+ // warning — you had data and I am getting it back — not the
59
+ // `connecting` blue and not the `disconnected` red.
60
+ await expect(indicator).toHaveClass(
61
+ "text-intent-warning-content",
62
+ )
63
+ })