@tangle-network/ui 1.0.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 (220) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -0
  4. package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
  5. package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
  6. package/dist/auth.d.ts +74 -0
  7. package/dist/auth.js +15 -0
  8. package/dist/button-CMQuQEW_.d.ts +17 -0
  9. package/dist/chat.d.ts +232 -0
  10. package/dist/chat.js +30 -0
  11. package/dist/chunk-2NFQRQOD.js +1009 -0
  12. package/dist/chunk-2VH6PUXD.js +186 -0
  13. package/dist/chunk-34A66VBG.js +214 -0
  14. package/dist/chunk-3OI2QKFD.js +0 -0
  15. package/dist/chunk-4CLN43XT.js +45 -0
  16. package/dist/chunk-54SQQMMM.js +156 -0
  17. package/dist/chunk-5Z5ZYMOJ.js +0 -0
  18. package/dist/chunk-66BNMOVT.js +167 -0
  19. package/dist/chunk-6BGQA4BQ.js +0 -0
  20. package/dist/chunk-7UO2ZMRQ.js +133 -0
  21. package/dist/chunk-BX6AQMUS.js +183 -0
  22. package/dist/chunk-CD53GZOM.js +59 -0
  23. package/dist/chunk-CSAIKY36.js +54 -0
  24. package/dist/chunk-EEE55AVS.js +1201 -0
  25. package/dist/chunk-GYPQXTJU.js +230 -0
  26. package/dist/chunk-HFL6R6IF.js +37 -0
  27. package/dist/chunk-HJKCSXCH.js +737 -0
  28. package/dist/chunk-LISXUB4D.js +1222 -0
  29. package/dist/chunk-LQS34IGP.js +0 -0
  30. package/dist/chunk-MKTSMWVD.js +109 -0
  31. package/dist/chunk-NKDZ7GZE.js +192 -0
  32. package/dist/chunk-OEX7NZE3.js +321 -0
  33. package/dist/chunk-Q56BYXQF.js +61 -0
  34. package/dist/chunk-Q7EIIWTC.js +0 -0
  35. package/dist/chunk-REJESC5U.js +117 -0
  36. package/dist/chunk-RQGKSCEZ.js +0 -0
  37. package/dist/chunk-RQHJBTEU.js +10 -0
  38. package/dist/chunk-TMFOPHHN.js +299 -0
  39. package/dist/chunk-XGKULLYE.js +40 -0
  40. package/dist/chunk-XIHMJ7ZQ.js +614 -0
  41. package/dist/chunk-YJ2G3XO5.js +1048 -0
  42. package/dist/chunk-YNN4O57I.js +754 -0
  43. package/dist/code-block-DjXf8eOG.d.ts +19 -0
  44. package/dist/document-editor-pane-A5LT5H4N.js +12 -0
  45. package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
  46. package/dist/editor.d.ts +120 -0
  47. package/dist/editor.js +34 -0
  48. package/dist/files.d.ts +175 -0
  49. package/dist/files.js +20 -0
  50. package/dist/hooks.d.ts +56 -0
  51. package/dist/hooks.js +41 -0
  52. package/dist/index.d.ts +43 -0
  53. package/dist/index.js +446 -0
  54. package/dist/markdown.d.ts +15 -0
  55. package/dist/markdown.js +14 -0
  56. package/dist/message-BHWbxBtT.d.ts +15 -0
  57. package/dist/openui.d.ts +115 -0
  58. package/dist/openui.js +12 -0
  59. package/dist/parts-dj7AcUg0.d.ts +36 -0
  60. package/dist/primitives.d.ts +332 -0
  61. package/dist/primitives.js +191 -0
  62. package/dist/run-PfLmDAox.d.ts +41 -0
  63. package/dist/run.d.ts +69 -0
  64. package/dist/run.js +36 -0
  65. package/dist/sdk-hooks.d.ts +285 -0
  66. package/dist/sdk-hooks.js +31 -0
  67. package/dist/stores.d.ts +17 -0
  68. package/dist/stores.js +76 -0
  69. package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
  70. package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
  71. package/dist/tool-previews.d.ts +48 -0
  72. package/dist/tool-previews.js +21 -0
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.js +1 -0
  75. package/dist/utils.d.ts +45 -0
  76. package/dist/utils.js +32 -0
  77. package/package.json +193 -0
  78. package/src/auth/auth.tsx +228 -0
  79. package/src/auth/index.ts +13 -0
  80. package/src/auth/login-layout.tsx +46 -0
  81. package/src/chat/agent-timeline.stories.tsx +429 -0
  82. package/src/chat/agent-timeline.tsx +360 -0
  83. package/src/chat/chat-container.tsx +486 -0
  84. package/src/chat/chat-input.stories.tsx +142 -0
  85. package/src/chat/chat-input.tsx +389 -0
  86. package/src/chat/chat-message.stories.tsx +237 -0
  87. package/src/chat/chat-message.tsx +129 -0
  88. package/src/chat/index.ts +18 -0
  89. package/src/chat/message-list.stories.tsx +336 -0
  90. package/src/chat/message-list.tsx +79 -0
  91. package/src/chat/thinking-indicator.stories.tsx +56 -0
  92. package/src/chat/thinking-indicator.tsx +30 -0
  93. package/src/chat/user-message.stories.tsx +92 -0
  94. package/src/chat/user-message.tsx +43 -0
  95. package/src/editor/document-editor-pane.tsx +351 -0
  96. package/src/editor/editor-provider.tsx +428 -0
  97. package/src/editor/editor-toolbar.tsx +130 -0
  98. package/src/editor/index.ts +31 -0
  99. package/src/editor/markdown-conversion.ts +21 -0
  100. package/src/editor/markdown-document-editor.tsx +137 -0
  101. package/src/editor/tiptap-editor.tsx +331 -0
  102. package/src/editor/use-editor.ts +221 -0
  103. package/src/files/file-artifact-pane.tsx +183 -0
  104. package/src/files/file-preview.tsx +342 -0
  105. package/src/files/file-tabs.tsx +71 -0
  106. package/src/files/file-tree.tsx +258 -0
  107. package/src/files/index.ts +17 -0
  108. package/src/files/rich-file-tree.stories.tsx +104 -0
  109. package/src/files/rich-file-tree.test.tsx +42 -0
  110. package/src/files/rich-file-tree.tsx +232 -0
  111. package/src/hooks/index.ts +10 -0
  112. package/src/hooks/use-auth.ts +153 -0
  113. package/src/hooks/use-auto-scroll.ts +59 -0
  114. package/src/hooks/use-dropdown-menu.ts +40 -0
  115. package/src/hooks/use-live-time.test.tsx +40 -0
  116. package/src/hooks/use-live-time.ts +27 -0
  117. package/src/hooks/use-realtime-session.ts +319 -0
  118. package/src/hooks/use-run-collapse-state.ts +25 -0
  119. package/src/hooks/use-run-groups.ts +111 -0
  120. package/src/hooks/use-sdk-session.ts +575 -0
  121. package/src/hooks/use-sse-stream.ts +475 -0
  122. package/src/hooks/use-tool-call-stream.ts +96 -0
  123. package/src/index.ts +14 -0
  124. package/src/lib/utils.ts +6 -0
  125. package/src/markdown/code-block.tsx +198 -0
  126. package/src/markdown/index.ts +2 -0
  127. package/src/markdown/markdown.stories.tsx +190 -0
  128. package/src/markdown/markdown.tsx +62 -0
  129. package/src/openui/index.ts +20 -0
  130. package/src/openui/openui-artifact-renderer.tsx +542 -0
  131. package/src/primitives/artifact-pane.tsx +91 -0
  132. package/src/primitives/avatar.stories.tsx +95 -0
  133. package/src/primitives/avatar.tsx +47 -0
  134. package/src/primitives/badge.stories.tsx +57 -0
  135. package/src/primitives/badge.tsx +97 -0
  136. package/src/primitives/button.stories.tsx +48 -0
  137. package/src/primitives/button.tsx +115 -0
  138. package/src/primitives/card.stories.tsx +53 -0
  139. package/src/primitives/card.tsx +98 -0
  140. package/src/primitives/code-block.stories.tsx +115 -0
  141. package/src/primitives/code-block.tsx +22 -0
  142. package/src/primitives/design-tokens.stories.tsx +162 -0
  143. package/src/primitives/dialog.stories.tsx +176 -0
  144. package/src/primitives/dialog.tsx +137 -0
  145. package/src/primitives/drop-zone.stories.tsx +123 -0
  146. package/src/primitives/drop-zone.tsx +131 -0
  147. package/src/primitives/dropdown-menu.stories.tsx +122 -0
  148. package/src/primitives/dropdown-menu.tsx +214 -0
  149. package/src/primitives/empty-state.stories.tsx +81 -0
  150. package/src/primitives/empty-state.tsx +40 -0
  151. package/src/primitives/index.ts +118 -0
  152. package/src/primitives/input.stories.tsx +113 -0
  153. package/src/primitives/input.tsx +136 -0
  154. package/src/primitives/label.stories.tsx +84 -0
  155. package/src/primitives/label.tsx +24 -0
  156. package/src/primitives/progress.stories.tsx +93 -0
  157. package/src/primitives/progress.tsx +50 -0
  158. package/src/primitives/segmented-control.test.tsx +328 -0
  159. package/src/primitives/segmented-control.tsx +154 -0
  160. package/src/primitives/select.stories.tsx +164 -0
  161. package/src/primitives/select.tsx +158 -0
  162. package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
  163. package/src/primitives/sidebar-drop-zone.tsx +149 -0
  164. package/src/primitives/skeleton.stories.tsx +79 -0
  165. package/src/primitives/skeleton.tsx +55 -0
  166. package/src/primitives/stat-card.stories.tsx +137 -0
  167. package/src/primitives/stat-card.tsx +97 -0
  168. package/src/primitives/switch.stories.tsx +85 -0
  169. package/src/primitives/switch.tsx +28 -0
  170. package/src/primitives/table.stories.tsx +170 -0
  171. package/src/primitives/table.tsx +116 -0
  172. package/src/primitives/tabs.stories.tsx +180 -0
  173. package/src/primitives/tabs.tsx +71 -0
  174. package/src/primitives/terminal-display.stories.tsx +191 -0
  175. package/src/primitives/terminal-display.tsx +189 -0
  176. package/src/primitives/theme-toggle.stories.tsx +32 -0
  177. package/src/primitives/theme-toggle.tsx +96 -0
  178. package/src/primitives/toast.stories.tsx +155 -0
  179. package/src/primitives/toast.tsx +190 -0
  180. package/src/primitives/upload-progress.stories.tsx +120 -0
  181. package/src/primitives/upload-progress.tsx +110 -0
  182. package/src/run/expanded-tool-detail.stories.tsx +182 -0
  183. package/src/run/expanded-tool-detail.tsx +186 -0
  184. package/src/run/index.ts +13 -0
  185. package/src/run/inline-thinking-item.stories.tsx +136 -0
  186. package/src/run/inline-thinking-item.tsx +120 -0
  187. package/src/run/inline-tool-item.stories.tsx +222 -0
  188. package/src/run/inline-tool-item.tsx +190 -0
  189. package/src/run/run-group.stories.tsx +322 -0
  190. package/src/run/run-group.tsx +569 -0
  191. package/src/run/run-item-primitives.tsx +17 -0
  192. package/src/run/tool-call-feed.stories.tsx +294 -0
  193. package/src/run/tool-call-feed.tsx +192 -0
  194. package/src/run/tool-call-step.stories.tsx +198 -0
  195. package/src/run/tool-call-step.tsx +240 -0
  196. package/src/sdk-hooks.ts +38 -0
  197. package/src/stores/active-sessions-store.ts +455 -0
  198. package/src/stores/chat-store.ts +43 -0
  199. package/src/stores/index.ts +2 -0
  200. package/src/tool-previews/command-preview.tsx +116 -0
  201. package/src/tool-previews/diff-preview.tsx +85 -0
  202. package/src/tool-previews/glob-results-preview.tsx +98 -0
  203. package/src/tool-previews/grep-results-preview.tsx +157 -0
  204. package/src/tool-previews/index.ts +22 -0
  205. package/src/tool-previews/preview-primitives.tsx +84 -0
  206. package/src/tool-previews/question-preview.tsx +101 -0
  207. package/src/tool-previews/web-search-preview.tsx +117 -0
  208. package/src/tool-previews/write-file-preview.tsx +80 -0
  209. package/src/types/branding.ts +11 -0
  210. package/src/types/index.ts +5 -0
  211. package/src/types/message.ts +13 -0
  212. package/src/types/parts.ts +51 -0
  213. package/src/types/run.ts +56 -0
  214. package/src/types/tool-display.ts +41 -0
  215. package/src/utils/copy-text.ts +30 -0
  216. package/src/utils/format.test.ts +43 -0
  217. package/src/utils/format.ts +56 -0
  218. package/src/utils/index.ts +10 -0
  219. package/src/utils/time-ago.ts +9 -0
  220. package/src/utils/tool-display.ts +238 -0
@@ -0,0 +1,328 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { render, screen } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
5
+
6
+ const options: SegmentedControlOption[] = [
7
+ { value: "all", label: "All" },
8
+ { value: "personal", label: "Personal" },
9
+ { value: "team", label: "Team" },
10
+ ]
11
+
12
+ describe("SegmentedControl", () => {
13
+ it("renders all options as radio buttons", () => {
14
+ render(
15
+ <SegmentedControl value="all" onValueChange={vi.fn()} options={options} />,
16
+ )
17
+ const radios = screen.getAllByRole("radio")
18
+ expect(radios).toHaveLength(3)
19
+ expect(screen.getByText("All")).toBeInTheDocument()
20
+ expect(screen.getByText("Personal")).toBeInTheDocument()
21
+ expect(screen.getByText("Team")).toBeInTheDocument()
22
+ })
23
+
24
+ it("marks the active option with aria-checked", () => {
25
+ render(
26
+ <SegmentedControl value="personal" onValueChange={vi.fn()} options={options} />,
27
+ )
28
+ const personalRadio = screen.getByRole("radio", { name: "Personal" })
29
+ const allRadio = screen.getByRole("radio", { name: "All" })
30
+
31
+ expect(personalRadio).toHaveAttribute("aria-checked", "true")
32
+ expect(allRadio).toHaveAttribute("aria-checked", "false")
33
+ })
34
+
35
+ it("sets tabIndex 0 on active option and -1 on inactive options", () => {
36
+ render(
37
+ <SegmentedControl value="all" onValueChange={vi.fn()} options={options} />,
38
+ )
39
+ const radios = screen.getAllByRole("radio")
40
+ expect(radios[0]).toHaveAttribute("tabindex", "0")
41
+ expect(radios[1]).toHaveAttribute("tabindex", "-1")
42
+ expect(radios[2]).toHaveAttribute("tabindex", "-1")
43
+ })
44
+
45
+ it("calls onValueChange when clicking an inactive option", async () => {
46
+ const user = userEvent.setup()
47
+ const onChange = vi.fn()
48
+ render(
49
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
50
+ )
51
+
52
+ await user.click(screen.getByRole("radio", { name: "Team" }))
53
+ expect(onChange).toHaveBeenCalledWith("team")
54
+ })
55
+
56
+ it("does not call onValueChange when clicking the already-active option", async () => {
57
+ const user = userEvent.setup()
58
+ const onChange = vi.fn()
59
+ render(
60
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
61
+ )
62
+
63
+ await user.click(screen.getByRole("radio", { name: "All" }))
64
+ expect(onChange).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it("renders adornment when provided", () => {
68
+ const withAdornment: SegmentedControlOption[] = [
69
+ { value: "all", label: "All", adornment: <span data-testid="count">42</span> },
70
+ { value: "personal", label: "Personal" },
71
+ ]
72
+ render(
73
+ <SegmentedControl value="all" onValueChange={vi.fn()} options={withAdornment} />,
74
+ )
75
+ expect(screen.getByTestId("count")).toBeInTheDocument()
76
+ expect(screen.getByText("42")).toBeInTheDocument()
77
+ })
78
+
79
+ // --- Keyboard navigation ---
80
+
81
+ it("navigates to next option on ArrowRight", async () => {
82
+ const user = userEvent.setup()
83
+ const onChange = vi.fn()
84
+ render(
85
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
86
+ )
87
+
88
+ screen.getByRole("radio", { name: "All" }).focus()
89
+ await user.keyboard("{ArrowRight}")
90
+
91
+ expect(onChange).toHaveBeenCalledWith("personal")
92
+ })
93
+
94
+ it("moves DOM focus to the target option on arrow key", async () => {
95
+ const user = userEvent.setup()
96
+ render(
97
+ <SegmentedControl value="all" onValueChange={vi.fn()} options={options} />,
98
+ )
99
+
100
+ const personalRadio = screen.getByRole("radio", { name: "Personal" })
101
+ screen.getByRole("radio", { name: "All" }).focus()
102
+ await user.keyboard("{ArrowRight}")
103
+
104
+ expect(document.activeElement).toBe(personalRadio)
105
+ })
106
+
107
+ it("navigates to previous option on ArrowLeft", async () => {
108
+ const user = userEvent.setup()
109
+ const onChange = vi.fn()
110
+ render(
111
+ <SegmentedControl value="personal" onValueChange={onChange} options={options} />,
112
+ )
113
+
114
+ screen.getByRole("radio", { name: "Personal" }).focus()
115
+ await user.keyboard("{ArrowLeft}")
116
+
117
+ expect(onChange).toHaveBeenCalledWith("all")
118
+ })
119
+
120
+ it("wraps around on ArrowRight from last option", async () => {
121
+ const user = userEvent.setup()
122
+ const onChange = vi.fn()
123
+ render(
124
+ <SegmentedControl value="team" onValueChange={onChange} options={options} />,
125
+ )
126
+
127
+ screen.getByRole("radio", { name: "Team" }).focus()
128
+ await user.keyboard("{ArrowRight}")
129
+
130
+ expect(onChange).toHaveBeenCalledWith("all")
131
+ })
132
+
133
+ it("wraps around on ArrowLeft from first option", async () => {
134
+ const user = userEvent.setup()
135
+ const onChange = vi.fn()
136
+ render(
137
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
138
+ )
139
+
140
+ screen.getByRole("radio", { name: "All" }).focus()
141
+ await user.keyboard("{ArrowLeft}")
142
+
143
+ expect(onChange).toHaveBeenCalledWith("team")
144
+ })
145
+
146
+ it("navigates to first option on Home", async () => {
147
+ const user = userEvent.setup()
148
+ const onChange = vi.fn()
149
+ render(
150
+ <SegmentedControl value="team" onValueChange={onChange} options={options} />,
151
+ )
152
+
153
+ screen.getByRole("radio", { name: "Team" }).focus()
154
+ await user.keyboard("{Home}")
155
+
156
+ expect(onChange).toHaveBeenCalledWith("all")
157
+ })
158
+
159
+ it("navigates to last option on End", async () => {
160
+ const user = userEvent.setup()
161
+ const onChange = vi.fn()
162
+ render(
163
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
164
+ )
165
+
166
+ screen.getByRole("radio", { name: "All" }).focus()
167
+ await user.keyboard("{End}")
168
+
169
+ expect(onChange).toHaveBeenCalledWith("team")
170
+ })
171
+
172
+ it("does not fire onValueChange when Home is pressed on first option", async () => {
173
+ const user = userEvent.setup()
174
+ const onChange = vi.fn()
175
+ render(
176
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
177
+ )
178
+
179
+ screen.getByRole("radio", { name: "All" }).focus()
180
+ await user.keyboard("{Home}")
181
+
182
+ expect(onChange).not.toHaveBeenCalled()
183
+ })
184
+
185
+ it("does not fire onValueChange when End is pressed on last option", async () => {
186
+ const user = userEvent.setup()
187
+ const onChange = vi.fn()
188
+ render(
189
+ <SegmentedControl value="team" onValueChange={onChange} options={options} />,
190
+ )
191
+
192
+ screen.getByRole("radio", { name: "Team" }).focus()
193
+ await user.keyboard("{End}")
194
+
195
+ expect(onChange).not.toHaveBeenCalled()
196
+ })
197
+
198
+ // --- Edge cases ---
199
+
200
+ it("does not crash on keyboard input when options array is empty", async () => {
201
+ const user = userEvent.setup()
202
+ const onChange = vi.fn()
203
+ const { container } = render(
204
+ <SegmentedControl value="all" onValueChange={onChange} options={[]} />,
205
+ )
206
+
207
+ const radiogroup = container.querySelector("[role='radiogroup']") as HTMLElement
208
+ radiogroup.focus()
209
+ await user.keyboard("{ArrowRight}")
210
+ await user.keyboard("{Home}")
211
+ await user.keyboard("{End}")
212
+
213
+ expect(onChange).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it("gives first option tabIndex 0 when value does not match any option", () => {
217
+ render(
218
+ <SegmentedControl value={"unknown" as string} onValueChange={vi.fn()} options={options} />,
219
+ )
220
+ const radios = screen.getAllByRole("radio")
221
+ expect(radios[0]).toHaveAttribute("tabindex", "0")
222
+ expect(radios[1]).toHaveAttribute("tabindex", "-1")
223
+ expect(radios[2]).toHaveAttribute("tabindex", "-1")
224
+ })
225
+
226
+ it("navigates from first option when value does not match any option", async () => {
227
+ const user = userEvent.setup()
228
+ const onChange = vi.fn()
229
+ render(
230
+ <SegmentedControl value={"unknown" as string} onValueChange={onChange} options={options} />,
231
+ )
232
+
233
+ screen.getByRole("radio", { name: "All" }).focus()
234
+ await user.keyboard("{ArrowRight}")
235
+
236
+ expect(onChange).toHaveBeenCalledWith("personal")
237
+ })
238
+
239
+ it("supports ArrowDown as alias for ArrowRight", async () => {
240
+ const user = userEvent.setup()
241
+ const onChange = vi.fn()
242
+ render(
243
+ <SegmentedControl value="all" onValueChange={onChange} options={options} />,
244
+ )
245
+
246
+ screen.getByRole("radio", { name: "All" }).focus()
247
+ await user.keyboard("{ArrowDown}")
248
+
249
+ expect(onChange).toHaveBeenCalledWith("personal")
250
+ })
251
+
252
+ it("supports ArrowUp as alias for ArrowLeft", async () => {
253
+ const user = userEvent.setup()
254
+ const onChange = vi.fn()
255
+ render(
256
+ <SegmentedControl value="personal" onValueChange={onChange} options={options} />,
257
+ )
258
+
259
+ screen.getByRole("radio", { name: "Personal" }).focus()
260
+ await user.keyboard("{ArrowUp}")
261
+
262
+ expect(onChange).toHaveBeenCalledWith("all")
263
+ })
264
+
265
+ // --- Variants ---
266
+
267
+ it("applies row variant classes by default", () => {
268
+ render(
269
+ <SegmentedControl value="all" onValueChange={vi.fn()} options={options} />,
270
+ )
271
+ const radiogroup = screen.getByRole("radiogroup")
272
+ expect(radiogroup.className).toContain("flex-wrap")
273
+ expect(radiogroup.className).toContain("rounded-lg")
274
+ expect(radiogroup.className).toContain("bg-card")
275
+ })
276
+
277
+ it("applies tabs variant classes with nowrap", () => {
278
+ render(
279
+ <SegmentedControl
280
+ value="all"
281
+ onValueChange={vi.fn()}
282
+ options={options}
283
+ variant="tabs"
284
+ />,
285
+ )
286
+ const radiogroup = screen.getByRole("radiogroup")
287
+ expect(radiogroup.className).toContain("flex-nowrap")
288
+ expect(radiogroup.className).toContain("overflow-x-auto")
289
+ expect(radiogroup.className).toContain("border-b")
290
+ expect(radiogroup.className).not.toContain("bg-card")
291
+ })
292
+
293
+ it("passes aria-label to the radiogroup", () => {
294
+ render(
295
+ <SegmentedControl
296
+ value="all"
297
+ onValueChange={vi.fn()}
298
+ options={options}
299
+ aria-label="Scope filter"
300
+ />,
301
+ )
302
+ expect(screen.getByRole("radiogroup")).toHaveAttribute("aria-label", "Scope filter")
303
+ })
304
+
305
+ it("passes aria-labelledby to the radiogroup", () => {
306
+ render(
307
+ <SegmentedControl
308
+ value="all"
309
+ onValueChange={vi.fn()}
310
+ options={options}
311
+ aria-labelledby="heading-id"
312
+ />,
313
+ )
314
+ expect(screen.getByRole("radiogroup")).toHaveAttribute("aria-labelledby", "heading-id")
315
+ })
316
+
317
+ it("passes id to the radiogroup", () => {
318
+ render(
319
+ <SegmentedControl
320
+ value="all"
321
+ onValueChange={vi.fn()}
322
+ options={options}
323
+ id="scope-control"
324
+ />,
325
+ )
326
+ expect(screen.getByRole("radiogroup")).toHaveAttribute("id", "scope-control")
327
+ })
328
+ })
@@ -0,0 +1,154 @@
1
+ "use client"
2
+
3
+ declare const process: { env: { NODE_ENV?: string } } | undefined
4
+
5
+ import * as React from "react"
6
+ import { cn } from "../lib/utils"
7
+
8
+ /**
9
+ * Visually lightweight segmented control for single-value selection.
10
+ *
11
+ * Uses role="radiogroup" / role="radio" because this is a value-selector
12
+ * with no associated panels — not a tab interface. Arrow keys navigate
13
+ * between options (with wrapping), Home/End jump to first/last.
14
+ *
15
+ * **Accessibility:** Provide either `aria-label` or `aria-labelledby` — the
16
+ * ARIA spec requires every radiogroup to have an accessible name. A dev-mode
17
+ * console warning fires when both are omitted.
18
+ *
19
+ * Design rules baked into the default variant:
20
+ * - Only the SELECTED segment shows a surface colour + accent text.
21
+ * - Unselected segments have NO background — they're plain-text labels
22
+ * that darken on hover. This keeps the selected segment as the
23
+ * single visual anchor instead of the whole group competing with
24
+ * itself.
25
+ */
26
+ export interface SegmentedControlOption<T extends string = string> {
27
+ value: T
28
+ label: React.ReactNode
29
+ /** Rendered right of the label, typically a count or status pill. */
30
+ adornment?: React.ReactNode
31
+ }
32
+
33
+ export interface SegmentedControlProps<T extends string = string>
34
+ extends Pick<React.HTMLAttributes<HTMLDivElement>, "id" | "className" | "aria-label" | "aria-labelledby"> {
35
+ value: T
36
+ onValueChange: (value: T) => void
37
+ options: SegmentedControlOption<T>[]
38
+ /**
39
+ * Layout:
40
+ * - "row" — horizontal pill bar (default, fits in a header region)
41
+ * - "tabs" — horizontal with a bottom border so the selected pill
42
+ * reads as a classic tab (used on the Team page)
43
+ */
44
+ variant?: "row" | "tabs"
45
+ }
46
+
47
+ export function SegmentedControl<T extends string = string>({
48
+ value,
49
+ onValueChange,
50
+ options,
51
+ variant = "row",
52
+ className,
53
+ ...rest
54
+ }: SegmentedControlProps<T>) {
55
+ if (
56
+ typeof process !== "undefined" &&
57
+ process?.env?.NODE_ENV !== "production" &&
58
+ !rest["aria-label"] &&
59
+ !rest["aria-labelledby"]
60
+ ) {
61
+ console.warn(
62
+ '[SegmentedControl] role="radiogroup" requires either aria-label or aria-labelledby for accessibility.',
63
+ )
64
+ }
65
+
66
+ const optionRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map())
67
+
68
+ const hasMatch = options.some((o) => o.value === value)
69
+
70
+ const handleKeyDown = (e: React.KeyboardEvent) => {
71
+ if (options.length === 0) return
72
+ // When no option matches value, start navigation from the first option
73
+ let idx = options.findIndex((o) => o.value === value)
74
+ if (idx === -1) idx = 0
75
+ let next: number | undefined
76
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % options.length
77
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + options.length) % options.length
78
+ else if (e.key === "Home") next = 0
79
+ else if (e.key === "End") next = options.length - 1
80
+ if (next !== undefined) {
81
+ e.preventDefault()
82
+ if (options[next].value !== value) {
83
+ onValueChange(options[next].value)
84
+ }
85
+ optionRefs.current.get(options[next].value)?.focus()
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div
91
+ role="radiogroup"
92
+ id={rest.id}
93
+ aria-label={rest["aria-label"]}
94
+ aria-labelledby={rest["aria-labelledby"]}
95
+ onKeyDown={handleKeyDown}
96
+ className={cn(
97
+ "flex gap-1",
98
+ variant === "row" &&
99
+ "flex-wrap items-center rounded-lg border border-border bg-card p-1",
100
+ variant === "tabs" &&
101
+ "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
102
+ className,
103
+ )}
104
+ >
105
+ {options.map((option, i) => {
106
+ const active = option.value === value
107
+ const focusable = active || (!hasMatch && i === 0)
108
+ return (
109
+ <button
110
+ key={option.value}
111
+ ref={(el) => {
112
+ if (el) optionRefs.current.set(option.value, el)
113
+ else optionRefs.current.delete(option.value)
114
+ }}
115
+ type="button"
116
+ role="radio"
117
+ aria-checked={active}
118
+ tabIndex={focusable ? 0 : -1}
119
+ onClick={() => {
120
+ if (!active) onValueChange(option.value)
121
+ }}
122
+ className={cn(
123
+ "relative inline-flex items-center gap-2 whitespace-nowrap text-sm transition-colors",
124
+ variant === "row" && "rounded-md px-3 py-1.5",
125
+ variant === "tabs" && "rounded-none border-b-2 -mb-px px-4 py-2",
126
+ // Active styling is the ONLY styled state — unselected
127
+ // segments stay transparent on purpose so they don't
128
+ // compete visually with the selection.
129
+ active
130
+ ? variant === "row"
131
+ ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)] font-semibold"
132
+ : "border-[var(--accent-text)] text-[var(--accent-text)] font-semibold"
133
+ : variant === "row"
134
+ ? "border border-transparent text-muted-foreground hover:text-foreground"
135
+ : "border-transparent text-muted-foreground hover:text-foreground",
136
+ )}
137
+ >
138
+ <span>{option.label}</span>
139
+ {option.adornment && (
140
+ <span
141
+ className={cn(
142
+ "text-xs",
143
+ active ? "text-[var(--accent-text)]/80" : "text-muted-foreground",
144
+ )}
145
+ >
146
+ {option.adornment}
147
+ </span>
148
+ )}
149
+ </button>
150
+ )
151
+ })}
152
+ </div>
153
+ )
154
+ }
@@ -0,0 +1,164 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectGroup,
6
+ SelectItem,
7
+ SelectLabel,
8
+ SelectSeparator,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from './select'
12
+
13
+ const meta: Meta = {
14
+ title: 'Primitives/Select',
15
+ parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj
20
+
21
+ export const Default: Story = {
22
+ render: () => (
23
+ <Select>
24
+ <SelectTrigger className="w-52">
25
+ <SelectValue placeholder="Select region" />
26
+ </SelectTrigger>
27
+ <SelectContent>
28
+ <SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
29
+ <SelectItem value="us-west-2">US West (Oregon)</SelectItem>
30
+ <SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
31
+ <SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
32
+ </SelectContent>
33
+ </Select>
34
+ ),
35
+ }
36
+
37
+ export const WithGroupsAndSeparator: Story = {
38
+ name: 'With Groups & Separator',
39
+ render: () => (
40
+ <Select>
41
+ <SelectTrigger className="w-56">
42
+ <SelectValue placeholder="Select runtime" />
43
+ </SelectTrigger>
44
+ <SelectContent>
45
+ <SelectGroup>
46
+ <SelectLabel>Node.js</SelectLabel>
47
+ <SelectItem value="node-18">Node.js 18 LTS</SelectItem>
48
+ <SelectItem value="node-20">Node.js 20 LTS</SelectItem>
49
+ <SelectItem value="node-22">Node.js 22</SelectItem>
50
+ </SelectGroup>
51
+ <SelectSeparator />
52
+ <SelectGroup>
53
+ <SelectLabel>Python</SelectLabel>
54
+ <SelectItem value="python-310">Python 3.10</SelectItem>
55
+ <SelectItem value="python-311">Python 3.11</SelectItem>
56
+ <SelectItem value="python-312">Python 3.12</SelectItem>
57
+ </SelectGroup>
58
+ <SelectSeparator />
59
+ <SelectGroup>
60
+ <SelectLabel>Go</SelectLabel>
61
+ <SelectItem value="go-121">Go 1.21</SelectItem>
62
+ <SelectItem value="go-122">Go 1.22</SelectItem>
63
+ </SelectGroup>
64
+ </SelectContent>
65
+ </Select>
66
+ ),
67
+ }
68
+
69
+ export const WithDefaultValue: Story = {
70
+ name: 'Pre-selected Value',
71
+ render: () => (
72
+ <Select defaultValue="us-east-1">
73
+ <SelectTrigger className="w-52">
74
+ <SelectValue />
75
+ </SelectTrigger>
76
+ <SelectContent>
77
+ <SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
78
+ <SelectItem value="us-west-2">US West (Oregon)</SelectItem>
79
+ <SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
80
+ </SelectContent>
81
+ </Select>
82
+ ),
83
+ }
84
+
85
+ export const Disabled: Story = {
86
+ render: () => (
87
+ <Select disabled>
88
+ <SelectTrigger className="w-52">
89
+ <SelectValue placeholder="Locked to us-east-1" />
90
+ </SelectTrigger>
91
+ <SelectContent>
92
+ <SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
93
+ </SelectContent>
94
+ </Select>
95
+ ),
96
+ }
97
+
98
+ export const WithDisabledItem: Story = {
99
+ name: 'With Disabled Item',
100
+ render: () => (
101
+ <Select>
102
+ <SelectTrigger className="w-52">
103
+ <SelectValue placeholder="Select tier" />
104
+ </SelectTrigger>
105
+ <SelectContent>
106
+ <SelectItem value="free">Free</SelectItem>
107
+ <SelectItem value="pro">Pro</SelectItem>
108
+ <SelectItem value="enterprise" disabled>
109
+ Enterprise (contact sales)
110
+ </SelectItem>
111
+ </SelectContent>
112
+ </Select>
113
+ ),
114
+ }
115
+
116
+ export const Overview: Story = {
117
+ name: 'Overview',
118
+ render: () => (
119
+ <div className="flex flex-col gap-6 p-6 w-72">
120
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest">Region</div>
121
+ <Select defaultValue="us-east-1">
122
+ <SelectTrigger>
123
+ <SelectValue />
124
+ </SelectTrigger>
125
+ <SelectContent>
126
+ <SelectItem value="us-east-1">US East (N. Virginia)</SelectItem>
127
+ <SelectItem value="us-west-2">US West (Oregon)</SelectItem>
128
+ <SelectItem value="eu-west-1">EU (Ireland)</SelectItem>
129
+ <SelectItem value="ap-southeast-1">Asia Pacific (Singapore)</SelectItem>
130
+ </SelectContent>
131
+ </Select>
132
+
133
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest">Runtime</div>
134
+ <Select>
135
+ <SelectTrigger>
136
+ <SelectValue placeholder="Select runtime..." />
137
+ </SelectTrigger>
138
+ <SelectContent>
139
+ <SelectGroup>
140
+ <SelectLabel>Node.js</SelectLabel>
141
+ <SelectItem value="node-20">Node.js 20 LTS</SelectItem>
142
+ <SelectItem value="node-22">Node.js 22</SelectItem>
143
+ </SelectGroup>
144
+ <SelectSeparator />
145
+ <SelectGroup>
146
+ <SelectLabel>Python</SelectLabel>
147
+ <SelectItem value="python-311">Python 3.11</SelectItem>
148
+ <SelectItem value="python-312">Python 3.12</SelectItem>
149
+ </SelectGroup>
150
+ </SelectContent>
151
+ </Select>
152
+
153
+ <div className="text-muted-foreground text-xs font-mono uppercase tracking-widest">Disabled</div>
154
+ <Select disabled>
155
+ <SelectTrigger>
156
+ <SelectValue placeholder="Locked to current plan" />
157
+ </SelectTrigger>
158
+ <SelectContent>
159
+ <SelectItem value="free">Free</SelectItem>
160
+ </SelectContent>
161
+ </Select>
162
+ </div>
163
+ ),
164
+ }