@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,56 @@
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 "./Card.stories.tsx"
8
+
9
+ const { AllStates, Default, Interactive } =
10
+ composeStories(stories)
11
+
12
+ test("a heading turns the card into a named landmark", async () => {
13
+ const { canvas } = await mountStory(Default)
14
+
15
+ expectAgentDrivable(canvas, {
16
+ name: "Bay 3",
17
+ role: "region",
18
+ })
19
+ })
20
+
21
+ test("a card with no heading is not a landmark", async () => {
22
+ const { canvas } = await mountStory(AllStates)
23
+
24
+ // Four cards on the board, three of them named. A `<section>` with
25
+ // no accessible name is not a region at all — which is the point:
26
+ // the component does not pretend to be one it cannot name.
27
+ await expect(canvas.getAllByRole("region")).toHaveLength(
28
+ 3,
29
+ )
30
+ })
31
+
32
+ /**
33
+ * Two identical "Start rip" buttons on one page, disambiguated only
34
+ * by the region each sits in.
35
+ */
36
+ test("region scoping disambiguates two identical buttons", async () => {
37
+ const { canvas } = await mountStory(Interactive)
38
+
39
+ const bayFour = expectAgentDrivable(canvas, {
40
+ name: "Bay 4",
41
+ role: "region",
42
+ })
43
+
44
+ const scoped = bayFour.querySelectorAll("button")
45
+
46
+ await expect(scoped).toHaveLength(1)
47
+
48
+ // And the progress bar inside that region is the one named for it,
49
+ // so an agent reading a value off a 16-bay tower reads the right
50
+ // bay's.
51
+ await expect(
52
+ canvas.getByRole("progressbar", {
53
+ name: "Bay 4 progress",
54
+ }),
55
+ ).toHaveAttribute("aria-valuenow", "40")
56
+ })
@@ -0,0 +1,142 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type {
3
+ ElevationStep,
4
+ SurfaceRole,
5
+ } from "@charcuterie/tokens"
6
+ import type {
7
+ ComponentPropsWithRef,
8
+ ReactNode,
9
+ } from "react"
10
+
11
+ import { toClassName } from "../toClassName.ts"
12
+
13
+ export type CardSurface = Extract<
14
+ SurfaceRole,
15
+ "base" | "raised" | "sunken"
16
+ >
17
+
18
+ export type CardPadding = "lg" | "md" | "none" | "sm"
19
+
20
+ export type CardProps = ComponentPropsWithRef<"section"> & {
21
+ /** Buttons or a badge, laid out beside the heading. */
22
+ actions?: ReactNode
23
+ elevation?: ElevationStep
24
+ footer?: ReactNode
25
+ heading?: string
26
+ headingLevel?: 2 | 3 | 4
27
+ padding?: CardPadding
28
+ surface?: CardSurface
29
+ }
30
+
31
+ const SURFACE_CLASS: Record<CardSurface, string> = {
32
+ base: "bg-surface-base",
33
+ raised: "bg-surface-raised",
34
+ sunken: "bg-surface-sunken",
35
+ }
36
+
37
+ /**
38
+ * Exported only so the story can derive its `elevation` control from
39
+ * the same map the component indexes — `ElevationStep` comes from
40
+ * `@charcuterie/tokens`, which react-docgen will not follow, so the
41
+ * options have to be stated and this is the one statement of them
42
+ * that cannot drift.
43
+ */
44
+ export const ELEVATION_CLASS: Record<
45
+ ElevationStep,
46
+ string
47
+ > = {
48
+ none: "shadow-none",
49
+ low: "shadow-low",
50
+ medium: "shadow-medium",
51
+ high: "shadow-high",
52
+ }
53
+
54
+ const PADDING_CLASS: Record<CardPadding, string> = {
55
+ none: "p-0",
56
+ sm: "p-3",
57
+ md: "p-4",
58
+ lg: "p-6",
59
+ }
60
+
61
+ /**
62
+ * Five repos, and the reason this is a P0 rather than a nicety is
63
+ * the `<section>`: give a card a heading and it becomes a landmark,
64
+ * so `getByRole("region", { name: "Bay 3" })` scopes every
65
+ * subsequent query to that card. Without it, an agent driving a
66
+ * 16-bay tower has sixteen identical "Start" buttons and no way to
67
+ * say which one it means.
68
+ *
69
+ * `aria-labelledby` rather than `aria-label`, because the heading is
70
+ * on screen already and stating the name twice is how the two drift
71
+ * apart.
72
+ *
73
+ * **Density is not a prop here.** mux-magic's inventory turned up a
74
+ * `CardDensityProvider` in the withdrawn bambuddy evidence, and it
75
+ * is the wrong shape for this fleet: density is a global axis on
76
+ * `<html>`, so a compact bay list is `data-density="compact"` on the
77
+ * page, not a context around the cards. `padding` remains a prop
78
+ * because a card wrapping a poster grid genuinely has none, which is
79
+ * a layout fact rather than a density one.
80
+ */
81
+ export const Card = ({
82
+ actions,
83
+ children,
84
+ className,
85
+ elevation = "low",
86
+ footer,
87
+ heading,
88
+ headingLevel = 2,
89
+ padding = "md",
90
+ surface = "raised",
91
+ ...sectionProps
92
+ }: CardProps): ReactNode => {
93
+ const headingId = useUniqueId()
94
+
95
+ const Heading = `h${headingLevel}` as const
96
+
97
+ return (
98
+ <section
99
+ {...sectionProps}
100
+ aria-labelledby={heading ? headingId : undefined}
101
+ className={toClassName(
102
+ // `@container` here, not on a wrapper, is what lets the
103
+ // header stack at narrow widths *inside a wide viewport* —
104
+ // a card in a sidebar and the same card full-bleed are the
105
+ // case a media query cannot tell apart, and the fleet's
106
+ // poster grids get it wrong at intermediate widths today.
107
+ "@container flex flex-col gap-3 rounded-lg border border-border-subtle text-content-primary",
108
+ SURFACE_CLASS[surface],
109
+ ELEVATION_CLASS[elevation],
110
+ PADDING_CLASS[padding],
111
+ className,
112
+ )}
113
+ >
114
+ {heading || actions ? (
115
+ <header className="flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between">
116
+ {heading ? (
117
+ <Heading
118
+ className="font-semibold text-md leading-tight"
119
+ id={headingId}
120
+ >
121
+ {heading}
122
+ </Heading>
123
+ ) : null}
124
+
125
+ {actions ? (
126
+ <div className="flex shrink-0 items-center gap-1">
127
+ {actions}
128
+ </div>
129
+ ) : null}
130
+ </header>
131
+ ) : null}
132
+
133
+ {children}
134
+
135
+ {footer ? (
136
+ <footer className="border-t border-border-subtle pt-3 text-content-secondary text-sm">
137
+ {footer}
138
+ </footer>
139
+ ) : null}
140
+ </section>
141
+ )
142
+ }
@@ -0,0 +1,40 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as EmptyStateStories from "./EmptyState.stories";
4
+
5
+ <Meta of={EmptyStateStories} />
6
+
7
+ # EmptyState
8
+
9
+ **Six repos, eleven-plus copies** — plex-channels alone has five `li.empty`s and castkit
10
+ six `.idle`s. Every copy is a bare string in a `<div>`, so "no results", "not started yet",
11
+ and "the socket died" all look identical and none of them is findable by role.
12
+
13
+ Designed fresh rather than ported: the two repos that had a real `EmptyState` with typed
14
+ variants were bambuddy and spoolbuddy, which are upstream projects rather than Kevin's
15
+ code, so that evidence was withdrawn from the plan.
16
+
17
+ <Canvas of={EmptyStateStories.Default} />
18
+
19
+ ## Props
20
+
21
+ <Controls />
22
+
23
+ ## Why there is no `variant`
24
+
25
+ "Empty" and "failed" differ in wording and in whether there is a retry button — both props
26
+ already. A `variant="error"` that only changes a colour invites callers to encode the
27
+ meaning in the colour alone, which is the one thing a colour cannot carry.
28
+
29
+ <Canvas of={EmptyStateStories.AllVariants} />
30
+
31
+ ## The heading is the handle
32
+
33
+ `headingLevel` is the caller's, because only the caller knows what this sits under. A real
34
+ heading makes `getByRole("heading", { name: "No discs queued" })` work, where `getByText`
35
+ against eleven copies of "Nothing here" does not.
36
+
37
+ The `md` size gains air past `--cq-md`, so the same component is tight in a 15rem sidebar
38
+ and generous filling a dashboard — decided by the panel, not the window.
39
+
40
+ <Canvas of={EmptyStateStories.Responsive} />
@@ -0,0 +1,183 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { Button } from "../Button/Button.tsx"
3
+ import {
4
+ ContainerBoard,
5
+ StoryCell,
6
+ StoryGrid,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Card } from "../Card/Card.tsx"
10
+ import {
11
+ AlertIcon,
12
+ InboxIcon,
13
+ PlayIcon,
14
+ SearchIcon,
15
+ } from "../icons.storyHelpers.tsx"
16
+ import { EmptyState } from "./EmptyState.tsx"
17
+
18
+ const meta = {
19
+ title: "Components/EmptyState",
20
+ component: EmptyState,
21
+ parameters: { layout: "padded" },
22
+ args: { headingLevel: 2, size: "md" },
23
+ } satisfies Meta<typeof EmptyState>
24
+
25
+ export default meta
26
+
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ /**
30
+ * The heading is the handle. Eleven copies of "Nothing here" in a
31
+ * `<div>` are indistinguishable to an agent; a named heading is not.
32
+ */
33
+ export const Default: Story = {
34
+ args: {
35
+ description:
36
+ "Insert a disc and rip-deck will pick it up automatically.",
37
+ heading: "No discs queued",
38
+ },
39
+ }
40
+
41
+ /**
42
+ * There is no `variant` prop, on purpose: "empty" and "failed"
43
+ * differ in wording and in whether there is a retry button, both of
44
+ * which are props already. A `variant="error"` that only changes a
45
+ * colour invites callers to encode the meaning in the colour alone,
46
+ * which is the one thing a colour cannot carry.
47
+ */
48
+ export const AllVariants: Story = {
49
+ args: { heading: "No discs queued" },
50
+ render: () => (
51
+ <StorySection title="The four real cases from the inventory — plex-channels' empty queue, castkit's idle panel, a filtered search, and a dead socket.">
52
+ <StoryGrid columns={2}>
53
+ <StoryCell align="stretch" label="empty">
54
+ <EmptyState
55
+ description="Insert a disc and rip-deck will pick it up automatically."
56
+ heading="No discs queued"
57
+ icon={<InboxIcon />}
58
+ />
59
+ </StoryCell>
60
+
61
+ <StoryCell
62
+ align="stretch"
63
+ label="idle — nothing has happened yet"
64
+ >
65
+ <EmptyState
66
+ description="The channel is connected and waiting for its first playback."
67
+ heading="Nothing playing"
68
+ icon={<PlayIcon />}
69
+ />
70
+ </StoryCell>
71
+
72
+ <StoryCell
73
+ align="stretch"
74
+ label="filtered to nothing"
75
+ >
76
+ <EmptyState
77
+ action={
78
+ <Button appearance="soft" size="sm">
79
+ Clear filters
80
+ </Button>
81
+ }
82
+ description="No titles match “blade” with the 4K filter on."
83
+ heading="No matches"
84
+ icon={<SearchIcon />}
85
+ />
86
+ </StoryCell>
87
+
88
+ <StoryCell align="stretch" label="failed">
89
+ <EmptyState
90
+ action={
91
+ <Button intent="danger" size="sm">
92
+ Reconnect
93
+ </Button>
94
+ }
95
+ description="The websocket closed after 3 retries. Nothing has been lost — the tower keeps ripping."
96
+ heading="Lost the connection"
97
+ icon={<AlertIcon />}
98
+ />
99
+ </StoryCell>
100
+ </StoryGrid>
101
+ </StorySection>
102
+ ),
103
+ }
104
+
105
+ export const AllStates: Story = {
106
+ args: { heading: "No discs queued" },
107
+ render: () => (
108
+ <StoryGrid columns={2}>
109
+ <StoryCell
110
+ align="stretch"
111
+ label="sm — inside a panel"
112
+ >
113
+ <Card heading="Tower alerts" padding="sm">
114
+ <EmptyState heading="No alerts" size="sm" />
115
+ </Card>
116
+ </StoryCell>
117
+
118
+ <StoryCell align="stretch" label="md — heading only">
119
+ <EmptyState heading="No discs queued" />
120
+ </StoryCell>
121
+
122
+ <StoryCell
123
+ align="stretch"
124
+ label="md — with description"
125
+ >
126
+ <EmptyState
127
+ description="Insert a disc and rip-deck will pick it up automatically."
128
+ heading="No discs queued"
129
+ />
130
+ </StoryCell>
131
+
132
+ <StoryCell
133
+ align="stretch"
134
+ label="md — icon, description, action"
135
+ >
136
+ <EmptyState
137
+ action={<Button size="sm">Scan drives</Button>}
138
+ description="Insert a disc and rip-deck will pick it up automatically."
139
+ heading="No discs queued"
140
+ icon={<InboxIcon />}
141
+ />
142
+ </StoryCell>
143
+ </StoryGrid>
144
+ ),
145
+ }
146
+
147
+ /**
148
+ * The container-query story that actually changes something: the
149
+ * `md` size gains air past `--cq-md`, so the same component is
150
+ * appropriately tight in a 15rem sidebar and appropriately generous
151
+ * filling a dashboard — decided by the panel, not by the window.
152
+ */
153
+ export const Responsive: Story = {
154
+ args: { heading: "No discs queued" },
155
+ render: () => (
156
+ <ContainerBoard>
157
+ <Card padding="none">
158
+ <EmptyState
159
+ description="Insert a disc and rip-deck will pick it up automatically."
160
+ heading="No discs queued"
161
+ icon={<InboxIcon />}
162
+ />
163
+ </Card>
164
+ </ContainerBoard>
165
+ ),
166
+ }
167
+
168
+ /**
169
+ * Two things an agent needs from an empty state: read *why* it is
170
+ * empty, and reach the way out. So the heading carries the reason
171
+ * and Tab lands on the action.
172
+ */
173
+ export const Interactive: Story = {
174
+ args: { heading: "No discs queued" },
175
+ render: (emptyStateProps) => (
176
+ <EmptyState
177
+ {...emptyStateProps}
178
+ action={<Button size="sm">Scan drives</Button>}
179
+ description="Insert a disc and rip-deck will pick it up automatically."
180
+ headingLevel={3}
181
+ />
182
+ ),
183
+ }
@@ -0,0 +1,41 @@
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 "./EmptyState.stories.tsx"
8
+
9
+ const { Default, Interactive } = composeStories(stories)
10
+
11
+ test("the heading is the handle", async () => {
12
+ const { canvas } = await mountStory(Default)
13
+
14
+ expectAgentDrivable(canvas, {
15
+ name: "No discs queued",
16
+ role: "heading",
17
+ })
18
+ })
19
+
20
+ test("the heading level is the caller's, and Tab reaches the way out", async () => {
21
+ const { canvas } = await mountStory(Interactive)
22
+
23
+ // `headingLevel` is a prop rather than a guess, because an empty
24
+ // state nested in a card is not an `<h2>` — and a document whose
25
+ // heading levels skip is one a screen-reader user cannot skim.
26
+ await expect(
27
+ canvas.getByRole("heading", {
28
+ level: 3,
29
+ name: "No discs queued",
30
+ }),
31
+ ).toBeVisible()
32
+
33
+ const action = expectAgentDrivable(canvas, {
34
+ name: "Scan drives",
35
+ role: "button",
36
+ })
37
+
38
+ await userEvent.tab()
39
+
40
+ await expect(action).toHaveFocus()
41
+ })
@@ -0,0 +1,114 @@
1
+ import type {
2
+ ComponentPropsWithRef,
3
+ ReactNode,
4
+ } from "react"
5
+
6
+ import { toClassName } from "../toClassName.ts"
7
+
8
+ export type EmptyStateSize = "md" | "sm"
9
+
10
+ export type EmptyStateProps = Omit<
11
+ ComponentPropsWithRef<"div">,
12
+ "children"
13
+ > & {
14
+ /** Buttons, usually. Rendered under the description. */
15
+ action?: ReactNode
16
+ description?: ReactNode
17
+ /**
18
+ * A real heading element, and the level is the caller's because
19
+ * only the caller knows what it sits under. Getting this wrong
20
+ * breaks a screen reader's document outline, which is how the
21
+ * fleet's `<h2>`-with-a-`<button>`-inside pattern happened.
22
+ */
23
+ headingLevel?: 2 | 3 | 4
24
+ heading: string
25
+ icon?: ReactNode
26
+ size?: EmptyStateSize
27
+ }
28
+
29
+ const SIZE_CLASS: Record<EmptyStateSize, string> = {
30
+ sm: "gap-2 px-4 py-6 text-sm",
31
+ // An empty state in a 300px panel and the same one filling a
32
+ // dashboard want different air. Container-relative, so it is the
33
+ // *panel* that decides rather than the window.
34
+ md: "gap-3 px-6 py-10 text-md cq-md:gap-4 cq-md:px-8 cq-md:py-14",
35
+ }
36
+
37
+ /**
38
+ * **Six repos, eleven-plus copies** — plex-channels alone has five
39
+ * `li.empty`s and castkit six `.idle`s — and not one of them is a
40
+ * component. Every copy is a bare string in a `<div>`, so "no
41
+ * results" and "not started yet" and "the socket died" all look
42
+ * identical, and none of them is findable by role.
43
+ *
44
+ * Designed fresh rather than ported: the two repos that had a real
45
+ * `EmptyState` with typed variants were bambuddy and spoolbuddy,
46
+ * which are upstream projects rather than Kevin's code, so that
47
+ * evidence was withdrawn from the plan.
48
+ *
49
+ * The shape is deliberately flat — icon, heading, description,
50
+ * action — with **no variant enum**. An "empty" and an "error" empty
51
+ * state differ in wording and in whether there is a retry button,
52
+ * both of which are props already; a `variant="error"` that only
53
+ * changes a colour invites callers to encode meaning in the colour
54
+ * alone, which is the one thing a colour cannot carry.
55
+ *
56
+ * The heading is what makes it agent-drivable:
57
+ * `getByRole("heading", { name: "No discs queued" })` works, where
58
+ * `getByText` against eleven copies of "Nothing here" does not.
59
+ */
60
+ export const EmptyState = ({
61
+ action,
62
+ className,
63
+ description,
64
+ heading,
65
+ headingLevel = 2,
66
+ icon,
67
+ size = "md",
68
+ ...divProps
69
+ }: EmptyStateProps): ReactNode => {
70
+ const Heading = `h${headingLevel}` as const
71
+
72
+ return (
73
+ <div
74
+ {...divProps}
75
+ className={toClassName("@container", className)}
76
+ >
77
+ {/* The container and the thing querying it **cannot be the same
78
+ element** — a container query only matches descendants, so
79
+ `@container cq-md:px-8` on one `<div>` silently never fires.
80
+ Nothing errors, the padding just stays small forever. Hence
81
+ the wrapper: `className` stays on the outer element where a
82
+ consumer expects it, and the size classes move inside. */}
83
+ <div
84
+ className={toClassName(
85
+ "flex flex-col items-center justify-center text-center",
86
+ SIZE_CLASS[size],
87
+ )}
88
+ >
89
+ {icon ? (
90
+ <span
91
+ aria-hidden="true"
92
+ className="text-content-muted"
93
+ >
94
+ {icon}
95
+ </span>
96
+ ) : null}
97
+
98
+ <Heading className="font-semibold text-content-primary">
99
+ {heading}
100
+ </Heading>
101
+
102
+ {description ? (
103
+ <p className="max-w-prose text-content-secondary">
104
+ {description}
105
+ </p>
106
+ ) : null}
107
+
108
+ {action ? (
109
+ <div className="mt-1">{action}</div>
110
+ ) : null}
111
+ </div>
112
+ </div>
113
+ )
114
+ }
@@ -0,0 +1,34 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as IconButtonStories from "./IconButton.stories";
4
+
5
+ <Meta of={IconButtonStories} />
6
+
7
+ # IconButton
8
+
9
+ Three repos, three strategies, **no names**: mux-magic hand-writes five SVG components,
10
+ castkit keeps an `ICON_PATHS` map, and plex-channels renders raw glyphs (`↶`, `↷`, `▶`,
11
+ `⚙`, `≡`) straight into a `<button>`.
12
+
13
+ A screen reader announces the last of those as "↶", and
14
+ `getByRole("button", { name: "Undo" })` finds nothing at all. That is the concrete reason
15
+ the fleet is not agent-drivable today, and the reason this component exists as something
16
+ other than `<Button sizing="icon">`.
17
+
18
+ <Canvas of={IconButtonStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## The one rule
25
+
26
+ `label` is **required**, is a `string`, and becomes `aria-label`. There is no way to render
27
+ this component without a name, which is the point: the type system asks the question that
28
+ three hand-rolled versions forgot.
29
+
30
+ The library ships **no icons**. `children` is whatever the app already has — lucide (ISC)
31
+ is the fleet recommendation — and it is hidden from assistive technology, because the label
32
+ already names the control.
33
+
34
+ <Canvas of={IconButtonStories.AllVariants} />