@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,175 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ controlSizeArgType,
5
+ intentArgType,
6
+ } from "../argTypes.storyHelpers.ts"
7
+ import {
8
+ ContainerBoard,
9
+ StoryCell,
10
+ StoryGrid,
11
+ StoryRow,
12
+ StorySection,
13
+ } from "../board.storyHelpers.tsx"
14
+ import type { IntentAppearance } from "../intentStyles.ts"
15
+ import { Button } from "./Button.tsx"
16
+
17
+ const INTENTS = [
18
+ "neutral",
19
+ "accent",
20
+ "success",
21
+ "warning",
22
+ "danger",
23
+ "info",
24
+ ] as const
25
+
26
+ const APPEARANCES: IntentAppearance[] = [
27
+ "solid",
28
+ "soft",
29
+ "outline",
30
+ "ghost",
31
+ ]
32
+
33
+ const meta = {
34
+ title: "Components/Button",
35
+ component: Button,
36
+ parameters: { layout: "padded" },
37
+ argTypes: {
38
+ intent: intentArgType,
39
+ size: controlSizeArgType,
40
+ },
41
+ // The component's own defaults, restated. Storybook has not
42
+ // seeded `args` from a docgen `defaultValue` since v7, so without
43
+ // this the props table prints `"solid"` in the Default column
44
+ // while the radio beside it has nothing selected.
45
+ args: {
46
+ appearance: "solid",
47
+ intent: "accent",
48
+ isDisabled: false,
49
+ isFullWidth: false,
50
+ isLoading: false,
51
+ size: "md",
52
+ sizing: "control",
53
+ },
54
+ } satisfies Meta<typeof Button>
55
+
56
+ export default meta
57
+
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ export const Default: Story = {
61
+ args: { children: "Start rip" },
62
+ }
63
+
64
+ export const AllVariants: Story = {
65
+ args: { children: "Start rip" },
66
+ render: (buttonProps) => (
67
+ <StorySection title="Six intents x four appearances. The whole of seven repos' worth of primary/secondary/danger/ghost.">
68
+ <StoryGrid columns={4}>
69
+ {APPEARANCES.flatMap((appearance) =>
70
+ INTENTS.map((intent) => (
71
+ <StoryCell
72
+ key={`${appearance}-${intent}`}
73
+ label={`${appearance} · ${intent}`}
74
+ >
75
+ <Button
76
+ {...buttonProps}
77
+ appearance={appearance}
78
+ intent={intent}
79
+ >
80
+ {intent}
81
+ </Button>
82
+ </StoryCell>
83
+ )),
84
+ )}
85
+ </StoryGrid>
86
+ </StorySection>
87
+ ),
88
+ }
89
+
90
+ export const AllSizes: Story = {
91
+ args: { children: "Start rip" },
92
+ render: (buttonProps) => (
93
+ <StorySection title="Sizes come from the density axis — flip Density in the toolbar and every one of these changes with no prop change and no re-render.">
94
+ <StoryRow>
95
+ <Button {...buttonProps} size="sm">
96
+ Small
97
+ </Button>
98
+
99
+ <Button {...buttonProps} size="md">
100
+ Medium
101
+ </Button>
102
+
103
+ <Button {...buttonProps} size="lg">
104
+ Large
105
+ </Button>
106
+ </StoryRow>
107
+ </StorySection>
108
+ ),
109
+ }
110
+
111
+ /**
112
+ * Hover, active, and focus are real here, not simulated: the
113
+ * pseudo-states addon forces them, which is the only way to review
114
+ * a hover colour in a static board.
115
+ *
116
+ * `focusVisible` is forced the same way rather than tabbed to. A
117
+ * board that has to be *driven* to show its states is a test
118
+ * wearing a demo's clothes — and the tab count silently depended on
119
+ * how many focusable cells sat above it.
120
+ */
121
+ export const AllStates: Story = {
122
+ args: { children: "Start rip" },
123
+ parameters: {
124
+ pseudo: {
125
+ active: ["#active"],
126
+ focusVisible: ["#focus-target"],
127
+ hover: ["#hover"],
128
+ },
129
+ },
130
+ render: (buttonProps) => (
131
+ <StoryGrid columns={3}>
132
+ <StoryCell label="default">
133
+ <Button {...buttonProps} />
134
+ </StoryCell>
135
+
136
+ <StoryCell label="hover (forced)">
137
+ <Button {...buttonProps} id="hover" />
138
+ </StoryCell>
139
+
140
+ <StoryCell label="active (forced)">
141
+ <Button {...buttonProps} id="active" />
142
+ </StoryCell>
143
+
144
+ <StoryCell label="focus-visible (forced)">
145
+ <Button {...buttonProps} id="focus-target" />
146
+ </StoryCell>
147
+
148
+ <StoryCell label="disabled">
149
+ <Button {...buttonProps} isDisabled />
150
+ </StoryCell>
151
+
152
+ <StoryCell label="loading">
153
+ <Button {...buttonProps} isLoading />
154
+ </StoryCell>
155
+ </StoryGrid>
156
+ ),
157
+ }
158
+
159
+ /**
160
+ * Disabled *and* announced. The spinner's label lives in a
161
+ * `role="status"`, so a screen reader hears the work start rather
162
+ * than only seeing it.
163
+ */
164
+ export const Loading: Story = {
165
+ args: { children: "Ripping disc 3", isLoading: true },
166
+ }
167
+
168
+ export const Responsive: Story = {
169
+ args: { children: "Start rip", isFullWidth: true },
170
+ render: (buttonProps) => (
171
+ <ContainerBoard>
172
+ <Button {...buttonProps} />
173
+ </ContainerBoard>
174
+ ),
175
+ }
@@ -0,0 +1,114 @@
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 { mountStory } from "../mountStory.testHelpers.ts"
9
+ import { expectAgentDrivable } from "../testing/index.ts"
10
+ import meta, * as stories from "./Button.stories.tsx"
11
+
12
+ const { AllStates, Default, Loading } =
13
+ composeStories(stories)
14
+
15
+ test("is drivable by role and name", async () => {
16
+ const { canvas } = await mountStory(Default)
17
+
18
+ expectAgentDrivable(canvas, {
19
+ name: "Start rip",
20
+ role: "button",
21
+ })
22
+ })
23
+
24
+ /**
25
+ * The keyboard contract, asserted rather than assumed: Tab reaches
26
+ * it, Enter activates, Space activates. Every hand-rolled
27
+ * `<div onClick>` in the fleet fails all three.
28
+ */
29
+ test("Tab reaches it, and Enter and Space both activate", async () => {
30
+ const onClick = fn()
31
+
32
+ const { canvas } = await mountStory(
33
+ composeStory(
34
+ { args: { children: "Start rip", onClick } },
35
+ meta,
36
+ ),
37
+ )
38
+
39
+ const button = expectAgentDrivable(canvas, {
40
+ name: "Start rip",
41
+ role: "button",
42
+ })
43
+
44
+ await userEvent.tab()
45
+
46
+ await expect(button).toHaveFocus()
47
+
48
+ await userEvent.keyboard("{Enter}")
49
+
50
+ await expect(onClick).toHaveBeenCalledTimes(1)
51
+
52
+ await userEvent.keyboard(" ")
53
+
54
+ await expect(onClick).toHaveBeenCalledTimes(2)
55
+ })
56
+
57
+ test("a disabled button is skipped by Tab and ignores a click", async () => {
58
+ const onClick = fn()
59
+
60
+ const { canvas } = await mountStory(
61
+ composeStory(
62
+ {
63
+ args: {
64
+ children: "Start rip",
65
+ isDisabled: true,
66
+ onClick,
67
+ },
68
+ },
69
+ meta,
70
+ ),
71
+ )
72
+
73
+ const button = canvas.getByRole("button", {
74
+ name: "Start rip",
75
+ })
76
+
77
+ await userEvent.click(button)
78
+
79
+ // A `<div role="button">` with a guard clause looks the same and
80
+ // is not the same: a real `disabled` is skipped by Tab, ignored
81
+ // by click, and reported to AT.
82
+ await expect(onClick).not.toHaveBeenCalled()
83
+ await expect(button).toBeDisabled()
84
+
85
+ await userEvent.tab()
86
+
87
+ await expect(button).not.toHaveFocus()
88
+ })
89
+
90
+ test("loading disables the button and announces the work", async () => {
91
+ const { canvas } = await mountStory(Loading)
92
+
93
+ const button = expectAgentDrivable(canvas, {
94
+ name: /Ripping disc 3/,
95
+ role: "button",
96
+ })
97
+
98
+ await expect(button).toBeDisabled()
99
+ await expect(button).toHaveAttribute("aria-busy", "true")
100
+
101
+ // The spinner's label lives in a `role="status"`, so a screen
102
+ // reader hears the work start rather than only seeing it.
103
+ await expect(
104
+ canvas.getByRole("status"),
105
+ ).toBeInTheDocument()
106
+ })
107
+
108
+ test("the states board really renders a busy button", async () => {
109
+ const { canvas } = await mountStory(AllStates)
110
+
111
+ await expect(
112
+ canvas.getByRole("button", { name: /loading/i }),
113
+ ).toHaveAttribute("aria-busy", "true")
114
+ })
@@ -0,0 +1,125 @@
1
+ import type {
2
+ ControlSize,
3
+ IntentName,
4
+ } from "@charcuterie/tokens"
5
+ import type {
6
+ ComponentPropsWithRef,
7
+ ReactNode,
8
+ } from "react"
9
+
10
+ import {
11
+ CONTROL_SIZE_CLASS,
12
+ ICON_CONTROL_SIZE_CLASS,
13
+ } from "../controlStyles.ts"
14
+ import type { IntentAppearance } from "../intentStyles.ts"
15
+ import {
16
+ DISABLED_CLASS,
17
+ FOCUS_RING_CLASS,
18
+ INTENT_APPEARANCE_CLASS,
19
+ INTENT_HOVER_CLASS,
20
+ } from "../intentStyles.ts"
21
+ import { Spinner } from "../Spinner/Spinner.tsx"
22
+ import { toClassName } from "../toClassName.ts"
23
+
24
+ export type ButtonProps = Omit<
25
+ ComponentPropsWithRef<"button">,
26
+ "disabled"
27
+ > & {
28
+ appearance?: IntentAppearance
29
+ iconEnd?: ReactNode
30
+ iconStart?: ReactNode
31
+ intent?: IntentName
32
+ isDisabled?: boolean
33
+ isFullWidth?: boolean
34
+ isLoading?: boolean
35
+ /** What the spinner announces. Not shown. */
36
+ loadingLabel?: string
37
+ size?: ControlSize
38
+ /**
39
+ * Two sizing modes rather than a second component's worth of
40
+ * markup. `icon` is square on the control height with no inline
41
+ * padding, which is what lines an `IconButton` up with the text
42
+ * button beside it in a toolbar.
43
+ *
44
+ * It lives here rather than in `IconButton` because appending a
45
+ * competing `size-*`/`px-0` to this component's `className` would
46
+ * be a specificity coin-flip: two utilities setting the same
47
+ * property, and the winner decided by Tailwind's internal
48
+ * ordering rather than by the caller.
49
+ */
50
+ sizing?: "control" | "icon"
51
+ }
52
+
53
+ /**
54
+ * Hand-rolled in **seven repos**, each with its own
55
+ * primary/secondary/danger/ghost, and this is the component that
56
+ * erases the most duplication in the fleet.
57
+ *
58
+ * Four decisions worth knowing, all of them fixing something an
59
+ * inventoried repo gets wrong:
60
+ *
61
+ * - **`type="button"` by default.** A `<button>` inside a form
62
+ * submits it unless told otherwise, and "clicking the copy icon
63
+ * reloaded the page" is a bug every app in the fleet has shipped
64
+ * at least once.
65
+ * - **`isLoading` disables and announces.** The button goes
66
+ * `aria-busy`, the spinner replaces `iconStart` rather than
67
+ * being appended (so the label does not jump), and the label
68
+ * itself stays — a spinner that swallows the text leaves the
69
+ * user unsure what they pressed.
70
+ * - **`focus-visible`, never `focus`.** See `FOCUS_RING_CLASS`.
71
+ * - **Sizing comes from the density axis**, not from a prop table:
72
+ * `size="md"` is 2.25rem on a desktop and 3.25rem on the kiosk,
73
+ * with no prop change. `MIN_TOUCH_TARGET_CLASS` is available for
74
+ * a control that must clear 44px regardless.
75
+ *
76
+ * The accessible name is the children. `IconButton` exists because
77
+ * a glyph is not a name, and enforcing that in *this* component's
78
+ * types would mean rejecting `<Button>{icon} Copy</Button>`, which
79
+ * is fine.
80
+ */
81
+ export const Button = ({
82
+ appearance = "solid",
83
+ children,
84
+ className,
85
+ iconEnd,
86
+ iconStart,
87
+ intent = "accent",
88
+ isDisabled = false,
89
+ isFullWidth = false,
90
+ isLoading = false,
91
+ loadingLabel = "Loading…",
92
+ size = "md",
93
+ sizing = "control",
94
+ type = "button",
95
+ ...buttonProps
96
+ }: ButtonProps): ReactNode => (
97
+ <button
98
+ {...buttonProps}
99
+ aria-busy={isLoading || undefined}
100
+ className={toClassName(
101
+ "inline-flex cursor-pointer items-center justify-center rounded-md border font-medium whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard select-none",
102
+ sizing === "icon"
103
+ ? ICON_CONTROL_SIZE_CLASS[size]
104
+ : CONTROL_SIZE_CLASS[size],
105
+ INTENT_APPEARANCE_CLASS[intent][appearance],
106
+ INTENT_HOVER_CLASS[intent][appearance],
107
+ FOCUS_RING_CLASS,
108
+ DISABLED_CLASS,
109
+ isFullWidth && "w-full",
110
+ className,
111
+ )}
112
+ disabled={isDisabled || isLoading}
113
+ type={type}
114
+ >
115
+ {isLoading ? (
116
+ <Spinner label={loadingLabel} size={size} />
117
+ ) : (
118
+ iconStart
119
+ )}
120
+
121
+ {children}
122
+
123
+ {iconEnd}
124
+ </button>
125
+ )
@@ -0,0 +1,53 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as CardStories from "./Card.stories";
4
+
5
+ <Meta of={CardStories} />
6
+
7
+ # Card
8
+
9
+ Five repos, and the reason it is P0 is the `<section>`: give a card a heading and it becomes
10
+ a **landmark**, so `getByRole("region", { name: "Bay 3" })` scopes every query inside it.
11
+ Without that, an agent driving a 16-bay tower sees sixteen identical "Start" buttons and no
12
+ way to say which one it means. The `Interactive` story asserts exactly that.
13
+
14
+ <Canvas of={CardStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## Density is not a prop
21
+
22
+ The withdrawn bambuddy evidence included a `CardDensityProvider`, and it is the wrong shape
23
+ for this fleet: density is a global axis on `<html>`, so a compact bay list is
24
+ `data-density="compact"` on the page rather than a context around the cards. `padding`
25
+ stays a prop because a card wrapping a poster grid genuinely has none — a layout fact, not
26
+ a density one.
27
+
28
+ ## Names must be unique
29
+
30
+ Two `<section>`s sharing an accessible name is axe's `landmark-unique`, and it is a real
31
+ finding rather than a technicality: a screen-reader user navigating by landmark gets two
32
+ "Bay 3" regions and no way to tell them apart. A card with no heading is not a landmark at
33
+ all, deliberately — it has nothing to be named.
34
+
35
+ `raised` means *more separated from base*, not *lighter*, which is why light mode's base is
36
+ `#F5F7FA` and its raised is white.
37
+
38
+ <Canvas of={CardStories.AllVariants} />
39
+
40
+ ## Container queries, not media queries
41
+
42
+ > **One constraint that comes with the territory.** `container-type: inline-size` implies
43
+ > `contain: inline-size`, so a `Card` may not be sized by its own contents. In a grid track,
44
+ > a block flow, or a `flex-1` slot it is fine — that is nearly every real placement. Drop one
45
+ > into a shrink-to-fit parent (`items-start`, an `inline-flex` row) and it collapses to
46
+ > min-content, wrapping every line after one word. Give it a definite inline size there.
47
+
48
+
49
+ The header stacks below `--cq-sm` and sits on one row above it, and the container is the
50
+ **card**. The same card in a sidebar and full-bleed on a dashboard is the case a media query
51
+ cannot tell apart, and it is where the fleet's poster grids look wrong today.
52
+
53
+ <Canvas of={CardStories.Responsive} />
@@ -0,0 +1,207 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { toStoryChoice } from "../argTypes.storyHelpers.ts"
4
+ import { Badge } from "../Badge/Badge.tsx"
5
+ import { Button } from "../Button/Button.tsx"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StorySection,
11
+ } from "../board.storyHelpers.tsx"
12
+ import { IconButton } from "../IconButton/IconButton.tsx"
13
+ import { SettingsIcon } from "../icons.storyHelpers.tsx"
14
+ import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
15
+ import { Card, ELEVATION_CLASS } from "./Card.tsx"
16
+
17
+ const meta = {
18
+ title: "Components/Card",
19
+ component: Card,
20
+ parameters: { layout: "padded" },
21
+ argTypes: {
22
+ elevation: toStoryChoice(
23
+ Object.keys(
24
+ ELEVATION_CLASS,
25
+ ) as (keyof typeof ELEVATION_CLASS)[],
26
+ ),
27
+ },
28
+ args: {
29
+ elevation: "low",
30
+ headingLevel: 2,
31
+ padding: "md",
32
+ surface: "raised",
33
+ },
34
+ } satisfies Meta<typeof Card>
35
+
36
+ export default meta
37
+
38
+ type Story = StoryObj<typeof meta>
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ children: (
43
+ <p className="text-content-secondary text-sm">
44
+ Ripping <em>Blade Runner (1982)</em> — title 4 of 9.
45
+ </p>
46
+ ),
47
+ heading: "Bay 3",
48
+ },
49
+ }
50
+
51
+ export const AllVariants: Story = {
52
+ args: { children: "Content" },
53
+ render: () => (
54
+ <StorySection title="Three surfaces x four elevations. `raised` means more separated from base, not lighter — which is why light mode's base is #F5F7FA and its raised is white.">
55
+ <StoryGrid columns={3}>
56
+ {(["base", "raised", "sunken"] as const).flatMap(
57
+ (surface) =>
58
+ (
59
+ ["none", "low", "medium", "high"] as const
60
+ ).map((elevation) => (
61
+ <StoryCell
62
+ align="stretch"
63
+ key={`${surface}-${elevation}`}
64
+ label={`${surface} · ${elevation}`}
65
+ >
66
+ <Card
67
+ elevation={elevation}
68
+ // Distinct, because two `<section>`s sharing an
69
+ // accessible name is an axe `landmark-unique`
70
+ // violation — a real one: a screen-reader user
71
+ // navigating by landmark gets two "raised"
72
+ // regions and no way to tell them apart.
73
+ heading={`${surface} · ${elevation}`}
74
+ surface={surface}
75
+ >
76
+ <p className="text-content-secondary text-sm">
77
+ shadow-{elevation}
78
+ </p>
79
+ </Card>
80
+ </StoryCell>
81
+ )),
82
+ )}
83
+ </StoryGrid>
84
+ </StorySection>
85
+ ),
86
+ }
87
+
88
+ export const AllStates: Story = {
89
+ args: { children: "Content" },
90
+ render: () => (
91
+ <StoryGrid columns={2}>
92
+ <StoryCell align="stretch" label="heading only">
93
+ <Card heading="Bay 1" />
94
+ </StoryCell>
95
+
96
+ <StoryCell
97
+ align="stretch"
98
+ label="no heading — not a landmark, by design"
99
+ >
100
+ <Card>
101
+ <p className="text-content-secondary text-sm">
102
+ A card with no name cannot be a region, so it is
103
+ not pretending to be one.
104
+ </p>
105
+ </Card>
106
+ </StoryCell>
107
+
108
+ <StoryCell
109
+ align="stretch"
110
+ label="heading + actions + footer"
111
+ >
112
+ <Card
113
+ actions={
114
+ <>
115
+ <Badge intent="info" size="sm">
116
+ ripping
117
+ </Badge>
118
+
119
+ <IconButton
120
+ appearance="ghost"
121
+ label="Bay 3 settings"
122
+ size="sm"
123
+ >
124
+ <SettingsIcon />
125
+ </IconButton>
126
+ </>
127
+ }
128
+ footer="Started 14:02 · MakeMKV 1.17.8"
129
+ heading="Bay 2"
130
+ >
131
+ <ProgressBar
132
+ isValueShown
133
+ label="Ripping title 4 of 9"
134
+ value={47}
135
+ />
136
+ </Card>
137
+ </StoryCell>
138
+
139
+ <StoryCell
140
+ align="stretch"
141
+ label="padding=none — for a poster grid"
142
+ >
143
+ <Card heading="Recently added" padding="none">
144
+ <div className="h-16 rounded-md bg-surface-sunken" />
145
+ </Card>
146
+ </StoryCell>
147
+ </StoryGrid>
148
+ ),
149
+ }
150
+
151
+ /**
152
+ * The header stacks below `--cq-sm` and sits on one row above it —
153
+ * and the container is the *card*, not the window, which is the case
154
+ * a media query genuinely cannot express: the same card in a sidebar
155
+ * and full-bleed on a dashboard.
156
+ */
157
+ export const Responsive: Story = {
158
+ args: { children: "Content" },
159
+ render: () => (
160
+ <ContainerBoard>
161
+ {(width) => (
162
+ <Card
163
+ actions={
164
+ <Button appearance="soft" size="sm">
165
+ Start rip
166
+ </Button>
167
+ }
168
+ heading={`Bay 3 at ${width} — Blade Runner (1982)`}
169
+ >
170
+ <ProgressBar
171
+ label={`Ripping title 4 of 9 at ${width}`}
172
+ value={47}
173
+ />
174
+ </Card>
175
+ )}
176
+ </ContainerBoard>
177
+ ),
178
+ }
179
+
180
+ /**
181
+ * Two identical "Start rip" buttons on one page.
182
+ *
183
+ * Unscoped, `getByRole("button", { name: "Start rip" })` is
184
+ * ambiguous — which is exactly what `expectAgentDrivable` refuses.
185
+ * Scoping by region is the fix, and it only exists because the cards
186
+ * are named. That is the whole a11y argument for this component: an
187
+ * agent driving a 16-bay tower can say *which* "Start" it means.
188
+ */
189
+ export const Interactive: Story = {
190
+ args: { children: "Content" },
191
+ render: () => (
192
+ <div className="flex flex-col gap-3">
193
+ {[3, 4].map((bay) => (
194
+ <Card
195
+ actions={<Button size="sm">Start rip</Button>}
196
+ heading={`Bay ${bay}`}
197
+ key={bay}
198
+ >
199
+ <ProgressBar
200
+ label={`Bay ${bay} progress`}
201
+ value={bay * 10}
202
+ />
203
+ </Card>
204
+ ))}
205
+ </div>
206
+ ),
207
+ }