@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,208 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./SegmentedControl.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ /**
14
+ * `querySelectorAll` rather than `getAllByRole("radio")`, and not
15
+ * for typing convenience: a board renders several groups, and an
16
+ * assertion about "exactly one tab stop" is only the roving rule
17
+ * when it is scoped to **one** group.
18
+ */
19
+ const getOptions = (group: HTMLElement) =>
20
+ Array.from(
21
+ group.querySelectorAll<HTMLButtonElement>(
22
+ '[role="radio"]',
23
+ ),
24
+ )
25
+
26
+ test("it is a radio group with exactly one option checked", async () => {
27
+ const { canvas, canvasElement } =
28
+ await mountStory(Default)
29
+
30
+ const group = expectAgentDrivable(canvas, {
31
+ name: "Columns",
32
+ role: "radiogroup",
33
+ })
34
+
35
+ const options = getOptions(group)
36
+
37
+ await expect(options).toHaveLength(5)
38
+
39
+ await waitFor(() => {
40
+ expect(
41
+ options.filter(
42
+ (one) =>
43
+ one.getAttribute("aria-checked") === "true",
44
+ ),
45
+ ).toHaveLength(1)
46
+ })
47
+
48
+ // The state kind's invariant, visible in the DOM: `SinglePicker`
49
+ // holds one intent field, so "at most one checked" is true by
50
+ // representation rather than by bookkeeping.
51
+ await expect(group).toContainElement(options[0] ?? null)
52
+
53
+ await expectNoAxeViolations(canvasElement)
54
+ })
55
+
56
+ test("Tab enters the group once, and the arrows move within it", async () => {
57
+ const { canvas } = await mountStory(Interactive)
58
+
59
+ const options = getOptions(
60
+ expectAgentDrivable(canvas, {
61
+ name: "Columns",
62
+ role: "radiogroup",
63
+ }),
64
+ )
65
+
66
+ // Zero tab stops strands the widget; several mean the roving
67
+ // pattern was never implemented. Exactly one is the rule, read
68
+ // straight out of `selectTabIndex`.
69
+ await expect(
70
+ options.filter((one) => one.tabIndex === 0),
71
+ ).toHaveLength(1)
72
+
73
+ await userEvent.tab()
74
+
75
+ await waitFor(() => {
76
+ expect(options[0]).toHaveFocus()
77
+ })
78
+
79
+ await userEvent.keyboard("{ArrowRight}")
80
+
81
+ await waitFor(() => {
82
+ expect(options[1]).toHaveFocus()
83
+ })
84
+ })
85
+
86
+ /**
87
+ * The radio-group rule, and the thing that makes this component a
88
+ * different composition from `Tabs` rather than a restyle of it:
89
+ * moving focus **checks**. `Tabs` has two activation modes because a
90
+ * panel can cost a network request; there is nothing behind an
91
+ * option here to be expensive.
92
+ */
93
+ test("selection follows focus, and wraps", async () => {
94
+ const { canvas } = await mountStory(Interactive)
95
+
96
+ const options = getOptions(
97
+ expectAgentDrivable(canvas, {
98
+ name: "Columns",
99
+ role: "radiogroup",
100
+ }),
101
+ )
102
+
103
+ options[0]?.focus()
104
+
105
+ await userEvent.keyboard("{ArrowRight}")
106
+
107
+ await waitFor(() => {
108
+ expect(options[1]).toHaveAttribute(
109
+ "aria-checked",
110
+ "true",
111
+ )
112
+ })
113
+
114
+ await expect(options[0]).toHaveAttribute(
115
+ "aria-checked",
116
+ "false",
117
+ )
118
+
119
+ // Home and End, then wrapping — all three from `RovingFocus`,
120
+ // none of them written here.
121
+ await userEvent.keyboard("{End}")
122
+
123
+ await waitFor(() => {
124
+ expect(options[4]).toHaveAttribute(
125
+ "aria-checked",
126
+ "true",
127
+ )
128
+ })
129
+
130
+ await userEvent.keyboard("{ArrowRight}")
131
+
132
+ await waitFor(() => {
133
+ expect(options[0]).toHaveAttribute(
134
+ "aria-checked",
135
+ "true",
136
+ )
137
+ })
138
+ })
139
+
140
+ test("a pointer press checks the option it landed on", async () => {
141
+ const { canvas } = await mountStory(Interactive)
142
+
143
+ const four = expectAgentDrivable(canvas, {
144
+ name: "4",
145
+ role: "radio",
146
+ })
147
+
148
+ await userEvent.click(four)
149
+
150
+ await waitFor(() => {
151
+ expect(four).toHaveAttribute("aria-checked", "true")
152
+ })
153
+ })
154
+
155
+ test("the arrow keys skip a disabled option", async () => {
156
+ const { canvas, canvasElement } =
157
+ await mountStory(AllStates)
158
+
159
+ const group = expectAgentDrivable(canvas, {
160
+ name: "Columns with 2 unavailable",
161
+ role: "radiogroup",
162
+ })
163
+
164
+ const options = getOptions(group)
165
+
166
+ const [first, , two, three] = options as [
167
+ HTMLButtonElement,
168
+ HTMLButtonElement,
169
+ HTMLButtonElement,
170
+ HTMLButtonElement,
171
+ ]
172
+
173
+ await expect(two).toBeDisabled()
174
+
175
+ first.focus()
176
+
177
+ await userEvent.keyboard("{ArrowRight}{ArrowRight}")
178
+
179
+ // Two presses from `auto` lands on `3`, not on the disabled `2` —
180
+ // registration is membership, and a disabled option never joined
181
+ // the focus group.
182
+ await waitFor(() => {
183
+ expect(three).toHaveFocus()
184
+ })
185
+
186
+ await expect(two).not.toHaveFocus()
187
+
188
+ await expectNoAxeViolations(canvasElement)
189
+ })
190
+
191
+ test("selectedValue decides the first render and nothing after", async () => {
192
+ const { canvas } = await mountStory(AllStates)
193
+
194
+ const group = expectAgentDrivable(canvas, {
195
+ name: "Columns starting on 3",
196
+ role: "radiogroup",
197
+ })
198
+
199
+ // A controlled prop is the thing this library refuses to have —
200
+ // and reading it on the first paint is why the component falls
201
+ // back to `pendingValue`: members register from an effect, so
202
+ // `selectedValue` is still null when the group first paints.
203
+ await waitFor(() => {
204
+ expect(
205
+ group.querySelector('[aria-checked="true"]'),
206
+ ).toHaveTextContent("3")
207
+ })
208
+ })
@@ -0,0 +1,219 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useSinglePicker,
5
+ } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
7
+ import type { ReactNode } from "react"
8
+ import { useCallback, useEffect, useRef } from "react"
9
+
10
+ import { toClassName } from "../toClassName.ts"
11
+ import { SegmentedOption } from "./SegmentedOption.tsx"
12
+
13
+ export type SegmentedItem = {
14
+ /**
15
+ * A disabled option is simply **not registered** with the roving
16
+ * group, so the arrow keys skip it without any command in
17
+ * `RovingFocus` having to know what "disabled" means. It stays
18
+ * registered with the picker: it is still one of the options.
19
+ */
20
+ isDisabled?: boolean
21
+ label: ReactNode
22
+ value: string
23
+ }
24
+
25
+ export type SegmentedControlProps = {
26
+ className?: string
27
+ items: readonly SegmentedItem[]
28
+ /** The group's accessible name. Required. */
29
+ label: string
30
+ onChange?: (selectedValue: string | null) => void
31
+ /** **Initial** only. Charcuterie owns it from then on. */
32
+ selectedValue?: string
33
+ size?: ControlSize
34
+ }
35
+
36
+ const GAP_CLASS: Record<ControlSize, string> = {
37
+ sm: "gap-0.5",
38
+ md: "gap-1",
39
+ lg: "gap-1",
40
+ }
41
+
42
+ /**
43
+ * One choice out of a handful, all of them on screen at once.
44
+ *
45
+ * This is `Tabs`' composition — `SinglePicker` + `RovingFocus` —
46
+ * with the panels taken away, and it exists because taking the
47
+ * panels away used to be impossible. Under M4's model a tab bar's
48
+ * selection *was* a group of visibilities, so a control that chooses
49
+ * something without revealing anything had nothing to reuse and
50
+ * hand-rolled its own state. rip-deck's `ColumnPicker` is that
51
+ * hand-roll
52
+ * ([decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md)).
53
+ *
54
+ * ### `radiogroup`, not a row of pressed buttons
55
+ *
56
+ * rip-deck's version is five `<button aria-pressed>` in a
57
+ * `<fieldset>`, which is the **toolbar of independent toggles**
58
+ * pattern: nothing in it says the five are mutually exclusive, so a
59
+ * screen reader announces "auto, pressed" with no "1 of 5" and no
60
+ * clue that pressing another un-presses this one. A single choice
61
+ * from a named set is a radio group, and ARIA has a pattern for it.
62
+ *
63
+ * ### Selection follows focus, and here that is correct
64
+ *
65
+ * `Tabs` has two activation modes because a tab panel can cost a
66
+ * network request. A radio group has one: APG says the arrow keys
67
+ * both move focus and check, and there is nothing behind an option
68
+ * here to be expensive. So the mode is `automatic` and there is no
69
+ * prop — a `manual` segmented control would be a control the arrow
70
+ * keys appear to do nothing in.
71
+ *
72
+ * The two kinds are still separate underneath, which is what makes
73
+ * Space's behaviour trivial rather than a special case: it checks
74
+ * whatever focus is on, whether or not focus moved there by arrow.
75
+ */
76
+ export const SegmentedControl = ({
77
+ className,
78
+ items,
79
+ label,
80
+ onChange,
81
+ selectedValue,
82
+ size = "md",
83
+ }: SegmentedControlProps): ReactNode => {
84
+ const groupRef = useRef<HTMLDivElement>(null)
85
+
86
+ const optionElements = useRef(
87
+ new Map<string, HTMLButtonElement>(),
88
+ )
89
+
90
+ const initialValue =
91
+ selectedValue ??
92
+ items.find((one) => !one.isDisabled)?.value ??
93
+ null
94
+
95
+ const selection = useSinglePicker<string>({
96
+ onChange,
97
+ selectedValue: initialValue,
98
+ })
99
+
100
+ const focus = useRovingFocus<string>({
101
+ activeValue: initialValue,
102
+ })
103
+
104
+ const { select } = selection
105
+
106
+ const { activeValue, setActiveValue } = focus
107
+
108
+ /**
109
+ * Selected **or** pending. Members register from an effect, so on
110
+ * the first paint `selectedValue` is still null and the intent
111
+ * lives in `pendingValue` — reading only the former renders a
112
+ * group with nothing checked, then corrects itself a frame later.
113
+ * A flash on a desktop; a visible blank on a kiosk Pi.
114
+ */
115
+ const checkedValue =
116
+ selection.selectedValue ?? selection.pendingValue
117
+
118
+ const chooseOption = useCallback(
119
+ (value: string) => {
120
+ setActiveValue(value)
121
+
122
+ select(value)
123
+ },
124
+ [select, setActiveValue],
125
+ )
126
+
127
+ useEffect(() => {
128
+ // Selection follows focus — the radio-group rule, and the one
129
+ // line that would be absent in a `manual` mode this component
130
+ // deliberately does not have.
131
+ if (activeValue !== null) {
132
+ select(activeValue)
133
+ }
134
+ }, [activeValue, select])
135
+
136
+ useEffect(() => {
137
+ const group = groupRef.current
138
+
139
+ if (
140
+ !group ||
141
+ activeValue === null ||
142
+ !group.contains(document.activeElement)
143
+ ) {
144
+ return
145
+ }
146
+
147
+ optionElements.current.get(activeValue)?.focus()
148
+ }, [activeValue])
149
+
150
+ return (
151
+ <div
152
+ aria-label={label}
153
+ className={toClassName(
154
+ "inline-flex items-center rounded-md border border-border-subtle bg-surface-sunken p-0.5",
155
+ GAP_CLASS[size],
156
+ className,
157
+ )}
158
+ onKeyDown={(keyEvent) => {
159
+ const commands: Record<string, () => void> = {
160
+ ArrowDown: focus.next,
161
+ ArrowLeft: focus.previous,
162
+ ArrowRight: focus.next,
163
+ ArrowUp: focus.previous,
164
+ End: focus.last,
165
+ Home: focus.first,
166
+ }
167
+
168
+ const command = commands[keyEvent.key]
169
+
170
+ if (command) {
171
+ // Arrow keys scroll the page by default, and a control
172
+ // that scrolls the window while it moves focus is the
173
+ // hand-rolled version of this component.
174
+ keyEvent.preventDefault()
175
+
176
+ command()
177
+
178
+ return
179
+ }
180
+
181
+ // Space checks the focused option. In a group where
182
+ // selection already follows focus this is almost always a
183
+ // no-op — which is the point: it is one command, not a
184
+ // second code path, because `select` is idempotent.
185
+ if (keyEvent.key === " " && activeValue !== null) {
186
+ keyEvent.preventDefault()
187
+
188
+ select(activeValue)
189
+ }
190
+ }}
191
+ ref={groupRef}
192
+ role="radiogroup"
193
+ >
194
+ {items.map((item) => (
195
+ <SegmentedOption
196
+ isChecked={checkedValue === item.value}
197
+ item={item}
198
+ key={item.value}
199
+ onChoose={chooseOption}
200
+ registerFocus={focus.register}
201
+ registerSelection={selection.register}
202
+ size={size}
203
+ // The roving-tabindex rule read from the core rather than
204
+ // restated here: exactly one option is in the tab order,
205
+ // so Tab enters and leaves the group once while the arrow
206
+ // keys move inside it.
207
+ tabIndex={selectTabIndex(focus, item.value)}
208
+ trackElement={(value, element) => {
209
+ if (element) {
210
+ optionElements.current.set(value, element)
211
+ } else {
212
+ optionElements.current.delete(value)
213
+ }
214
+ }}
215
+ />
216
+ ))}
217
+ </div>
218
+ )
219
+ }
@@ -0,0 +1,94 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useEffect } from "react"
4
+
5
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
6
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
7
+ import { toClassName } from "../toClassName.ts"
8
+ import type { SegmentedItem } from "./SegmentedControl.tsx"
9
+
10
+ export type SegmentedOptionProps = {
11
+ isChecked: boolean
12
+ item: SegmentedItem
13
+ onChoose: (value: string) => void
14
+ /** `RovingFocus.register` — membership of the arrow-key group. */
15
+ registerFocus: (value: string) => () => void
16
+ /** `SinglePicker.register` — membership of the choice. */
17
+ registerSelection: (value: string) => () => void
18
+ size: ControlSize
19
+ tabIndex: number
20
+ trackElement: (
21
+ value: string,
22
+ element: HTMLButtonElement | null,
23
+ ) => void
24
+ }
25
+
26
+ /**
27
+ * Its own file for the same reason `TabTrigger` is: **both
28
+ * registrations are effects**, and an effect cannot run in a loop.
29
+ * Any member of a registering group ends up shaped like this.
30
+ *
31
+ * The checked option is filled rather than merely tinted. A
32
+ * segmented control's whole job is to be readable at a glance from
33
+ * across a room — rip-deck's lives above a nine-bay grid on a wall
34
+ * display — and a one-shade difference does not survive that or a
35
+ * `daylight` theme in sunlight.
36
+ */
37
+ export const SegmentedOption = ({
38
+ isChecked,
39
+ item,
40
+ onChoose,
41
+ registerFocus,
42
+ registerSelection,
43
+ size,
44
+ tabIndex,
45
+ trackElement,
46
+ }: SegmentedOptionProps): ReactNode => {
47
+ const { isDisabled = false, label, value } = item
48
+
49
+ useEffect(
50
+ () => registerSelection(value),
51
+ [registerSelection, value],
52
+ )
53
+
54
+ useEffect(() => {
55
+ if (isDisabled) {
56
+ return
57
+ }
58
+
59
+ return registerFocus(value)
60
+ }, [isDisabled, registerFocus, value])
61
+
62
+ return (
63
+ // biome-ignore lint/a11y/useSemanticElements: `<input type="radio">` owns `checked` *and* owns arrow-key navigation within a `name` group. That is the self-owning-control conflict `Modal` already has with `<dialog>`, except here it collides with both state kinds at once — the browser and `SinglePicker` would each believe they hold the choice, and native roving would fight `RovingFocus`. APG's radio-group pattern exists for exactly this case.
64
+ <button
65
+ aria-checked={isChecked}
66
+ className={toClassName(
67
+ "cursor-pointer rounded-sm border border-transparent font-medium tabular-nums transition-colors duration-(--duration-fast) ease-standard",
68
+ CONTROL_SIZE_CLASS[size],
69
+ // Two entries rather than one interpolated string:
70
+ // `tailwindCandidates.test.ts` rejects a template literal in
71
+ // a className outright, because Tailwind's scanner cannot
72
+ // see a class that only exists at runtime.
73
+ isChecked
74
+ ? "bg-intent-accent-solid text-intent-accent-on-solid"
75
+ : "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
76
+ isDisabled &&
77
+ "cursor-not-allowed text-content-disabled hover:bg-transparent hover:text-content-disabled",
78
+ FOCUS_RING_CLASS,
79
+ )}
80
+ disabled={isDisabled}
81
+ onClick={() => {
82
+ onChoose(value)
83
+ }}
84
+ ref={(element) => {
85
+ trackElement(value, element)
86
+ }}
87
+ role="radio"
88
+ tabIndex={tabIndex}
89
+ type="button"
90
+ >
91
+ {label}
92
+ </button>
93
+ )
94
+ }
@@ -0,0 +1,30 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SkeletonStories from "./Skeleton.stories";
4
+
5
+ <Meta of={SkeletonStories} />
6
+
7
+ # Skeleton
8
+
9
+ **Also zero fleet-wide.** The counterpart to `Spinner`, and the better answer wherever the
10
+ shape of the incoming content is already known: a poster grid that skeletons does not
11
+ reflow when the images land, and layout shift is what users actually notice.
12
+
13
+ <Canvas of={SkeletonStories.AllVariants} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## `aria-hidden` is the contract, not an optimisation
20
+
21
+ A skeleton is decoration standing in for content that does not exist yet. Announcing it
22
+ means a screen reader reading three empty bars, and an agent finding a "loading" element it
23
+ cannot act on.
24
+
25
+ **The load has to be announced by the region that owns it** — a `Spinner`, or `aria-busy`
26
+ on the container. The `Default` story asserts the skeleton is hidden from assistive
27
+ technology, and `Interactive` asserts it contributes no roles at all. Both are assertions
28
+ axe cannot make for you: there is nothing invalid about a visible placeholder.
29
+
30
+ <Canvas of={SkeletonStories.AllStates} />
@@ -0,0 +1,130 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import {
4
+ ContainerBoard,
5
+ StoryCell,
6
+ StoryGrid,
7
+ StorySection,
8
+ } from "../board.storyHelpers.tsx"
9
+ import { Card } from "../Card/Card.tsx"
10
+ import { Skeleton } from "./Skeleton.tsx"
11
+
12
+ const meta = {
13
+ title: "Components/Skeleton",
14
+ component: Skeleton,
15
+ parameters: { layout: "padded" },
16
+ args: { lineCount: 1, shape: "block" },
17
+ } satisfies Meta<typeof Skeleton>
18
+
19
+ export default meta
20
+
21
+ type Story = StoryObj<typeof meta>
22
+
23
+ /**
24
+ * The inverse of every other component's gate. A skeleton is
25
+ * decoration standing in for content that does not exist yet, so it
26
+ * is hidden from AT entirely — otherwise a screen reader reads three
27
+ * empty bars.
28
+ */
29
+ export const Default: Story = {
30
+ args: { blockSize: "1.5rem", inlineSize: "12rem" },
31
+ }
32
+
33
+ export const AllVariants: Story = {
34
+ args: {},
35
+ render: () => (
36
+ <StorySection title="Three shapes, and the multi-line text case — last line short, so a paragraph placeholder reads as prose rather than as a table.">
37
+ <StoryGrid columns={2}>
38
+ <StoryCell align="stretch" label="block">
39
+ <Skeleton blockSize="4rem" inlineSize="12rem" />
40
+ </StoryCell>
41
+
42
+ <StoryCell align="stretch" label="circle">
43
+ <Skeleton
44
+ blockSize="3rem"
45
+ inlineSize="3rem"
46
+ shape="circle"
47
+ />
48
+ </StoryCell>
49
+
50
+ <StoryCell
51
+ align="stretch"
52
+ label="text (single line)"
53
+ >
54
+ <Skeleton inlineSize="14rem" shape="text" />
55
+ </StoryCell>
56
+
57
+ <StoryCell
58
+ align="stretch"
59
+ label="text (four lines)"
60
+ >
61
+ <Skeleton
62
+ inlineSize="14rem"
63
+ lineCount={4}
64
+ shape="text"
65
+ />
66
+ </StoryCell>
67
+ </StoryGrid>
68
+ </StorySection>
69
+ ),
70
+ }
71
+
72
+ /**
73
+ * A skeleton has no interactive states — no hover, no focus, no
74
+ * disabled. What it has instead is a *fidelity* obligation: the
75
+ * placeholder has to occupy the same box the real content will, or
76
+ * the grid reflows when the data lands and the user loses their
77
+ * place. So this story is the comparison rather than a state grid.
78
+ */
79
+ export const AllStates: Story = {
80
+ args: {},
81
+ render: () => (
82
+ <StoryGrid columns={2}>
83
+ <StoryCell align="stretch" label="loading">
84
+ <Card heading="Bay 3 — loading">
85
+ <Skeleton
86
+ inlineSize="100%"
87
+ lineCount={3}
88
+ shape="text"
89
+ />
90
+ </Card>
91
+ </StoryCell>
92
+
93
+ <StoryCell align="stretch" label="loaded — same box">
94
+ <Card heading="Bay 3 — loaded">
95
+ <p className="text-content-secondary text-sm">
96
+ Ripping <em>Blade Runner (1982)</em> — title 4
97
+ of 9, AACS handshake complete, 38 minutes
98
+ remaining on this title.
99
+ </p>
100
+ </Card>
101
+ </StoryCell>
102
+ </StoryGrid>
103
+ ),
104
+ }
105
+
106
+ export const Responsive: Story = {
107
+ args: {},
108
+ render: () => (
109
+ <ContainerBoard>
110
+ <Skeleton
111
+ inlineSize="100%"
112
+ lineCount={3}
113
+ shape="text"
114
+ />
115
+ </ContainerBoard>
116
+ ),
117
+ }
118
+
119
+ /**
120
+ * Nothing to drive, and that is the point: a skeleton contributes no
121
+ * roles at all, so an agent sweeping the page never finds a control
122
+ * it cannot act on.
123
+ */
124
+ export const Interactive: Story = {
125
+ args: {
126
+ inlineSize: "12rem",
127
+ lineCount: 3,
128
+ shape: "text",
129
+ },
130
+ }