@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,172 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { IntentName } from "@charcuterie/tokens"
3
+ import type {
4
+ ComponentPropsWithRef,
5
+ ReactNode,
6
+ } from "react"
7
+
8
+ import { INTENT_SOLID_FILL_CLASS } from "../intentStyles.ts"
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
11
+ import type { ProgressThreshold } from "./progressValue.ts"
12
+ import {
13
+ getProgressIntent,
14
+ toProgressPercent,
15
+ toProgressValue,
16
+ } from "./progressValue.ts"
17
+
18
+ export type ProgressBarSize = "lg" | "md" | "sm"
19
+
20
+ export type ProgressBarProps = Omit<
21
+ ComponentPropsWithRef<"div">,
22
+ "children"
23
+ > & {
24
+ intent?: IntentName
25
+ /**
26
+ * Indeterminate means "working, no measurable progress yet" — not
27
+ * zero and not unknown-therefore-full. rip-deck's is the
28
+ * AACS/BD+ preamble: ~25 s of a real Blu-ray emitting nothing, in
29
+ * which a full bar reads as a finished rip and an empty one as a
30
+ * wedged drive.
31
+ */
32
+ isIndeterminate?: boolean
33
+ isLabelVisible?: boolean
34
+ isValueShown?: boolean
35
+ /** Required. A progressbar with no name is a grey rectangle. */
36
+ label: string
37
+ max?: number
38
+ size?: ProgressBarSize
39
+ thresholds?: readonly ProgressThreshold[]
40
+ value?: number
41
+ }
42
+
43
+ const TRACK_SIZE_CLASS: Record<ProgressBarSize, string> = {
44
+ sm: "h-1",
45
+ md: "h-2",
46
+ lg: "h-3",
47
+ }
48
+
49
+ /**
50
+ * The union of what the fleet has: rip-deck's ARIA and its
51
+ * indeterminate sweep, plus threshold colours nobody has yet.
52
+ *
53
+ * Five repos hand-roll a progress bar and **only rip-deck's has
54
+ * `role="progressbar"`** — the other four are a `<div>` with a
55
+ * percentage width, invisible to assistive technology and to
56
+ * `getByRole("progressbar")`.
57
+ *
58
+ * Three corrections to rip-deck's version, which is otherwise the
59
+ * best in the fleet:
60
+ *
61
+ * - **The role goes on the track, not the fill.** rip-deck puts
62
+ * `role="progressbar"` on the inner filled element, so the
63
+ * widget's box is the *filled portion* — a 4% rip is a
64
+ * progressbar 4px wide as far as AT and a Playwright bounding
65
+ * box are concerned.
66
+ * - **The name comes from a real label**, wired with
67
+ * `aria-labelledby` when it is visible and `VisuallyHidden` when
68
+ * it is not, rather than a hardcoded `aria-label` string that
69
+ * cannot be translated or reused.
70
+ * - **`aria-valuenow` is omitted while indeterminate**, which is
71
+ * what tells AT "unknown" rather than "zero". rip-deck gets this
72
+ * right by rendering a separate element; stating it on one
73
+ * element is the same answer without the duplication.
74
+ */
75
+ export const ProgressBar = ({
76
+ className,
77
+ intent = "accent",
78
+ isIndeterminate = false,
79
+ isLabelVisible = false,
80
+ isValueShown = false,
81
+ label,
82
+ max = 100,
83
+ size = "md",
84
+ thresholds,
85
+ value = 0,
86
+ ...divProps
87
+ }: ProgressBarProps): ReactNode => {
88
+ const labelId = useUniqueId()
89
+
90
+ const percent = toProgressPercent(value, max)
91
+
92
+ const clampedValue = toProgressValue(value, max)
93
+
94
+ const fillIntent = getProgressIntent(
95
+ percent,
96
+ intent,
97
+ thresholds,
98
+ )
99
+
100
+ return (
101
+ <div
102
+ {...divProps}
103
+ className={toClassName(
104
+ "flex flex-col gap-1",
105
+ className,
106
+ )}
107
+ >
108
+ {isLabelVisible || isValueShown ? (
109
+ <div className="flex items-baseline justify-between gap-2 text-sm">
110
+ {isLabelVisible ? (
111
+ <span
112
+ className="text-content-secondary"
113
+ id={labelId}
114
+ >
115
+ {label}
116
+ </span>
117
+ ) : (
118
+ <VisuallyHidden id={labelId}>
119
+ {label}
120
+ </VisuallyHidden>
121
+ )}
122
+
123
+ {isValueShown && !isIndeterminate ? (
124
+ <span className="text-content-muted tabular-nums">
125
+ {percent}%
126
+ </span>
127
+ ) : null}
128
+ </div>
129
+ ) : (
130
+ <VisuallyHidden id={labelId}>
131
+ {label}
132
+ </VisuallyHidden>
133
+ )}
134
+
135
+ <div
136
+ aria-busy={isIndeterminate || undefined}
137
+ aria-labelledby={labelId}
138
+ aria-valuemax={max}
139
+ aria-valuemin={0}
140
+ aria-valuenow={
141
+ isIndeterminate ? undefined : clampedValue
142
+ }
143
+ className={toClassName(
144
+ "relative w-full overflow-hidden rounded-full bg-surface-sunken",
145
+ TRACK_SIZE_CLASS[size],
146
+ )}
147
+ role="progressbar"
148
+ >
149
+ {isIndeterminate ? (
150
+ <span
151
+ className={toClassName(
152
+ // The sweep animates `inset-inline-start`, so it runs
153
+ // the other way in RTL for free — and its
154
+ // reduced-motion fallback hatches the whole track
155
+ // rather than parking at one end.
156
+ "charcuterie-sweep absolute inset-y-0 w-2/5 rounded-full",
157
+ INTENT_SOLID_FILL_CLASS[fillIntent],
158
+ )}
159
+ />
160
+ ) : (
161
+ <span
162
+ className={toClassName(
163
+ "block h-full rounded-full transition-[inline-size] duration-(--duration-normal) ease-standard",
164
+ INTENT_SOLID_FILL_CLASS[fillIntent],
165
+ )}
166
+ style={{ inlineSize: `${percent}%` }}
167
+ />
168
+ )}
169
+ </div>
170
+ </div>
171
+ )
172
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The arithmetic a progress bar gets handed by a log parser.
3
+ *
4
+ * None of these inputs is hypothetical: rip-deck's `fillPercent`
5
+ * comes out of a ripper's stdout, and `-1`, `101`, `NaN`, and
6
+ * `0 of 0` all arrive in normal operation. Today each of them
7
+ * renders as a bar that overflows its track, vanishes, or reads as
8
+ * finished.
9
+ */
10
+
11
+ import { expect, test } from "vitest"
12
+
13
+ import type { ProgressThreshold } from "./progressValue.ts"
14
+ import {
15
+ getProgressIntent,
16
+ toProgressPercent,
17
+ toProgressValue,
18
+ } from "./progressValue.ts"
19
+
20
+ test("a normal value becomes a rounded percentage", () => {
21
+ expect(toProgressPercent(38, 100)).toBe(38)
22
+
23
+ expect(toProgressPercent(4, 9)).toBe(44)
24
+
25
+ expect(toProgressPercent(1, 3)).toBe(33)
26
+ })
27
+
28
+ test("out-of-range values clamp rather than overflow", () => {
29
+ expect(toProgressPercent(-5, 100)).toBe(0)
30
+
31
+ expect(toProgressPercent(150, 100)).toBe(100)
32
+ })
33
+
34
+ test("an empty queue is 0%, not NaN%", () => {
35
+ // `0 of 0 files done` — every empty queue's first render.
36
+ expect(toProgressPercent(0, 0)).toBe(0)
37
+
38
+ expect(toProgressPercent(1, 0)).toBe(0)
39
+
40
+ expect(toProgressPercent(Number.NaN, 100)).toBe(0)
41
+
42
+ expect(
43
+ toProgressPercent(Number.POSITIVE_INFINITY, 100),
44
+ ).toBe(0)
45
+ })
46
+
47
+ test("aria-valuenow never leaves its own min/max", () => {
48
+ // A `valuenow` outside `valuemin`/`valuemax` is read as a nonsense
49
+ // percentage by some screen readers and dropped by others, and
50
+ // either way it disagrees with the bar on screen.
51
+ expect(toProgressValue(38, 100)).toBe(38)
52
+
53
+ expect(toProgressValue(-5, 100)).toBe(0)
54
+
55
+ expect(toProgressValue(150, 100)).toBe(100)
56
+
57
+ expect(toProgressValue(5, 0)).toBe(0)
58
+
59
+ expect(toProgressValue(Number.NaN, 100)).toBe(0)
60
+ })
61
+
62
+ const THRESHOLDS: ProgressThreshold[] = [
63
+ { from: 100, intent: "success" },
64
+ { from: 90, intent: "warning" },
65
+ ]
66
+
67
+ test("with no thresholds the component's intent wins", () => {
68
+ expect(getProgressIntent(50, "accent")).toBe("accent")
69
+
70
+ expect(getProgressIntent(50, "accent", [])).toBe("accent")
71
+ })
72
+
73
+ test("the highest matching threshold wins, whatever the array order", () => {
74
+ // Order-independence is the point: a caller appending to a config
75
+ // array should not have to know it was meant to be sorted.
76
+ expect(getProgressIntent(94, "accent", THRESHOLDS)).toBe(
77
+ "warning",
78
+ )
79
+
80
+ expect(
81
+ getProgressIntent(
82
+ 94,
83
+ "accent",
84
+ [...THRESHOLDS].reverse(),
85
+ ),
86
+ ).toBe("warning")
87
+
88
+ expect(getProgressIntent(100, "accent", THRESHOLDS)).toBe(
89
+ "success",
90
+ )
91
+
92
+ expect(getProgressIntent(89, "accent", THRESHOLDS)).toBe(
93
+ "accent",
94
+ )
95
+ })
96
+
97
+ test("a threshold at the boundary is inclusive", () => {
98
+ expect(getProgressIntent(90, "accent", THRESHOLDS)).toBe(
99
+ "warning",
100
+ )
101
+ })
@@ -0,0 +1,87 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+
3
+ /**
4
+ * A threshold is "from this percentage upward, use this intent".
5
+ * Order does not matter — the highest matching entry wins — because
6
+ * a caller sorting a config array by hand is a bug waiting for the
7
+ * day someone appends to it.
8
+ */
9
+ export type ProgressThreshold = {
10
+ from: number
11
+ intent: IntentName
12
+ }
13
+
14
+ /**
15
+ * Clamped, rounded, and NaN-proof, in a file a Node test can reach
16
+ * without a browser.
17
+ *
18
+ * All three cases are real. rip-deck's `fillPercent` comes from a
19
+ * ripper's stdout, and a progress value that arrives as `-1`,
20
+ * `101`, or `NaN` from a log parse currently renders as a bar that
21
+ * overflows its track or vanishes. `max: 0` is the one that looks
22
+ * hypothetical and is not: it is "0 of 0 files done", which every
23
+ * empty queue produces on its first render.
24
+ */
25
+ export const toProgressPercent = (
26
+ value: number,
27
+ max: number,
28
+ ) => {
29
+ if (!Number.isFinite(value) || !Number.isFinite(max)) {
30
+ return 0
31
+ }
32
+
33
+ if (max <= 0) {
34
+ return 0
35
+ }
36
+
37
+ return Math.min(
38
+ 100,
39
+ Math.max(0, Math.round((value / max) * 100)),
40
+ )
41
+ }
42
+
43
+ /**
44
+ * The same clamp in the value's own units, for `aria-valuenow`.
45
+ *
46
+ * Reporting the raw prop there would hand assistive technology a
47
+ * `valuenow` outside its own `valuemin`/`valuemax` — which some
48
+ * screen readers render as a nonsense percentage and others drop
49
+ * entirely — while the bar on screen showed the clamped one. The
50
+ * two must not disagree.
51
+ */
52
+ export const toProgressValue = (
53
+ value: number,
54
+ max: number,
55
+ ) => {
56
+ if (!Number.isFinite(value) || !Number.isFinite(max)) {
57
+ return 0
58
+ }
59
+
60
+ return Math.min(Math.max(0, max), Math.max(0, value))
61
+ }
62
+
63
+ /**
64
+ * The threshold colour, or the component's own intent when no
65
+ * threshold applies.
66
+ *
67
+ * This is the piece rip-deck hardcodes as `FILL_CLASS` keyed by a
68
+ * rip state, and mux-magic does not have at all. Generalising it to
69
+ * "percentage → intent" covers both the "green when done" case and
70
+ * the "amber past 80% of the disk" case with one mechanism.
71
+ */
72
+ export const getProgressIntent = (
73
+ percent: number,
74
+ intent: IntentName,
75
+ thresholds?: readonly ProgressThreshold[],
76
+ ): IntentName => {
77
+ if (!thresholds || thresholds.length === 0) {
78
+ return intent
79
+ }
80
+
81
+ const matched = thresholds
82
+ .filter((threshold) => percent >= threshold.from)
83
+ .sort((first, second) => second.from - first.from)
84
+ .at(0)
85
+
86
+ return matched?.intent ?? intent
87
+ }
@@ -0,0 +1,71 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SegmentedControlStories from "./SegmentedControl.stories";
4
+
5
+ <Meta of={SegmentedControlStories} />
6
+
7
+ # SegmentedControl
8
+
9
+ One choice out of a handful, all of them on screen at once.
10
+
11
+ This is `Tabs`' composition — `SinglePicker` + `RovingFocus` — **with the panels taken
12
+ away**, and it is here because taking the panels away used to be impossible. Under M4's
13
+ model a tab bar's selection *was* a group of visibilities, so a control that chooses
14
+ something without revealing anything had nothing to reuse and hand-rolled its own state.
15
+ rip-deck's `ColumnPicker` is that hand-roll
16
+ ([decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md)).
17
+
18
+ <Canvas of={SegmentedControlStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## `radiogroup`, not a row of pressed buttons
25
+
26
+ rip-deck's version is five `<button aria-pressed>` inside a `<fieldset>` — the **toolbar of
27
+ independent toggles** pattern. Nothing in it says the five are mutually exclusive, so a
28
+ screen reader announces *"auto, pressed"* with no "1 of 5" and no clue that pressing
29
+ another one un-presses this one.
30
+
31
+ A single choice from a named set is a radio group, and ARIA has a pattern for it: a
32
+ `radiogroup` with an accessible name, `role="radio"` children carrying `aria-checked`, and
33
+ a roving tabindex so Tab enters and leaves the group once.
34
+
35
+ <Canvas of={SegmentedControlStories.AllVariants} />
36
+
37
+ ## Selection follows focus, and here that is correct
38
+
39
+ `Tabs` has two activation modes because a tab panel can cost a network request. A radio
40
+ group has one: APG says the arrow keys both move focus **and** check, and there is nothing
41
+ behind an option here to be expensive. So there is no `activation` prop — a `manual`
42
+ segmented control would be a control the arrow keys appear to do nothing in.
43
+
44
+ The two kinds are still separate underneath, which is what makes Space trivial rather than
45
+ a special case: it checks whatever focus is on, whether or not focus moved there by arrow.
46
+ `select` is idempotent, so it is one command and not a second code path.
47
+
48
+ <Canvas of={SegmentedControlStories.AllStates} />
49
+
50
+ ## Registration is membership
51
+
52
+ A disabled option is out of the **focus** group and still one of the **options** — it can
53
+ even be the one a consumer names as the initial `selectedValue`, which is what a saved
54
+ preference pointing at a layout the window no longer fits looks like.
55
+
56
+ `SegmentedOption` is its own file for the same reason `TabTrigger` is: both registrations
57
+ are effects, and an effect cannot run in a loop.
58
+
59
+ <Canvas of={SegmentedControlStories.Responsive} />
60
+
61
+ ## `selectedValue` is initial, not controlled
62
+
63
+ Read once, on the first render. There is no controlled mode and no prop-sync effect, which
64
+ is [the M2 decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md).
65
+
66
+ The first paint reads `pendingValue` as well as `selectedValue`, and that fallback is
67
+ load-bearing: members register from an effect, so `selectedValue` is still `null` when the
68
+ group first paints. Reading only the former renders a group with nothing checked that
69
+ corrects itself a frame later — a flash on a desktop, a visible blank on a kiosk Pi.
70
+
71
+ <Canvas of={SegmentedControlStories.Interactive} />
@@ -0,0 +1,150 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import type { SegmentedItem } from "./SegmentedControl.tsx"
11
+ import { SegmentedControl } from "./SegmentedControl.tsx"
12
+
13
+ /**
14
+ * rip-deck's column picker, which is where this component came
15
+ * from. `auto` sits first and always, because a control that only
16
+ * offers numbers once a number has been picked is a one-way door.
17
+ */
18
+ const COLUMN_ITEMS: SegmentedItem[] = [
19
+ { label: "auto · 3", value: "auto" },
20
+ { label: "1", value: "1" },
21
+ { label: "2", value: "2" },
22
+ { label: "3", value: "3" },
23
+ { label: "4", value: "4" },
24
+ ]
25
+
26
+ const meta = {
27
+ title: "Components/SegmentedControl",
28
+ component: SegmentedControl,
29
+ parameters: { layout: "padded" },
30
+ argTypes: { size: controlSizeArgType },
31
+ // The component's own defaults, restated — Storybook does not seed
32
+ // `args` from docgen, so an unstated default shows in the props
33
+ // table with nothing selected in its control.
34
+ args: { size: "md" },
35
+ } satisfies Meta<typeof SegmentedControl>
36
+
37
+ export default meta
38
+
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ export const Default: Story = {
42
+ args: { items: COLUMN_ITEMS, label: "Columns" },
43
+ }
44
+
45
+ export const AllVariants: Story = {
46
+ args: { items: COLUMN_ITEMS, label: "Columns" },
47
+ render: (controlProps) => (
48
+ <StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
49
+ <StoryGrid columns={3}>
50
+ {(["sm", "md", "lg"] as const).map((size) => (
51
+ <StoryCell key={size} label={size}>
52
+ <SegmentedControl
53
+ {...controlProps}
54
+ label={`Columns at ${size}`}
55
+ size={size}
56
+ />
57
+ </StoryCell>
58
+ ))}
59
+ </StoryGrid>
60
+ </StorySection>
61
+ ),
62
+ }
63
+
64
+ const STATE_ITEMS: SegmentedItem[] = [
65
+ { label: "auto · 3", value: "auto" },
66
+ { label: "1", value: "1" },
67
+ { isDisabled: true, label: "2", value: "2" },
68
+ { label: "3", value: "3" },
69
+ ]
70
+
71
+ /**
72
+ * A disabled option is out of the *focus* group and still one of the
73
+ * *options* — it can even be the one a consumer names as the initial
74
+ * `selectedValue`, which is what a saved preference pointing at a
75
+ * layout the window no longer fits looks like.
76
+ */
77
+ export const AllStates: Story = {
78
+ args: { items: STATE_ITEMS, label: "Columns" },
79
+ render: (controlProps) => (
80
+ <StorySection title="What varies is which option is checked and which are reachable.">
81
+ <StoryGrid columns={2}>
82
+ <StoryCell label="first option checked">
83
+ <SegmentedControl
84
+ {...controlProps}
85
+ label="Columns starting on auto"
86
+ />
87
+ </StoryCell>
88
+
89
+ <StoryCell label="starts on the third option">
90
+ <SegmentedControl
91
+ {...controlProps}
92
+ label="Columns starting on 3"
93
+ selectedValue="3"
94
+ />
95
+ </StoryCell>
96
+
97
+ <StoryCell label="a disabled option">
98
+ <SegmentedControl
99
+ {...controlProps}
100
+ label="Columns with 2 unavailable"
101
+ />
102
+ </StoryCell>
103
+
104
+ <StoryCell label="two options">
105
+ <SegmentedControl
106
+ {...controlProps}
107
+ items={[
108
+ { label: "Grid", value: "grid" },
109
+ { label: "List", value: "list" },
110
+ ]}
111
+ label="Layout"
112
+ />
113
+ </StoryCell>
114
+ </StoryGrid>
115
+ </StorySection>
116
+ ),
117
+ }
118
+
119
+ /**
120
+ * It is `inline-flex`, so it takes the width of its options and no
121
+ * more — a segmented control that stretched to its container would
122
+ * put four numbers across a whole dashboard header.
123
+ *
124
+ * `ContainerBoard` is a width harness here rather than a
125
+ * container-query claim: `SegmentedControl` declares no container,
126
+ * because nothing about it needs to *know* its width.
127
+ */
128
+ export const Responsive: Story = {
129
+ args: { items: COLUMN_ITEMS, label: "Columns" },
130
+ render: (controlProps) => (
131
+ <ContainerBoard>
132
+ {(width) => (
133
+ <SegmentedControl
134
+ {...controlProps}
135
+ label={`Columns at ${width}`}
136
+ />
137
+ )}
138
+ </ContainerBoard>
139
+ ),
140
+ }
141
+
142
+ /**
143
+ * The complete keyboard path. Tab enters the group **once** — the
144
+ * roving-tabindex rule — then the arrow keys move and check
145
+ * together, which is what a radio group does and what `Tabs`'
146
+ * `manual` mode deliberately does not.
147
+ */
148
+ export const Interactive: Story = {
149
+ args: { items: COLUMN_ITEMS, label: "Columns" },
150
+ }