@elabs-ai/components-ai 4.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,392 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import {
6
+ PromptInput,
7
+ PromptInputBody,
8
+ PromptInputButton,
9
+ PromptInputFooter,
10
+ PromptInputStop,
11
+ PromptInputSubmit,
12
+ PromptInputTextarea,
13
+ type PromptInputMessage,
14
+ } from "./prompt-input";
15
+
16
+ /** The canonical composer shape: textarea + a status-aware submit. */
17
+ function Harness({
18
+ onSubmit = () => undefined,
19
+ ...submitProps
20
+ }: {
21
+ onSubmit?: (message: PromptInputMessage) => void;
22
+ } & React.ComponentProps<typeof PromptInputSubmit>) {
23
+ return (
24
+ <PromptInput onSubmit={onSubmit}>
25
+ <PromptInputBody>
26
+ <PromptInputTextarea placeholder="Ask…" />
27
+ </PromptInputBody>
28
+ <PromptInputFooter>
29
+ <PromptInputSubmit {...submitProps} />
30
+ </PromptInputFooter>
31
+ </PromptInput>
32
+ );
33
+ }
34
+
35
+ describe("PromptInput — empty submissions are blocked", () => {
36
+ it("does not fire onSubmit when Enter is pressed on an empty composer", async () => {
37
+ const onSubmit = vi.fn();
38
+ render(<Harness onSubmit={onSubmit} />);
39
+
40
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "{Enter}");
41
+ expect(onSubmit).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("does not fire onSubmit for whitespace-only text", async () => {
45
+ const onSubmit = vi.fn();
46
+ render(<Harness onSubmit={onSubmit} />);
47
+
48
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), " {Enter}");
49
+ expect(onSubmit).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("leaves the typed value intact when a submit is blocked", async () => {
53
+ // The guard runs BEFORE form.reset() — a later guard would still have
54
+ // wiped the composer while refusing to send.
55
+ const onSubmit = vi.fn();
56
+ render(<Harness onSubmit={onSubmit} />);
57
+
58
+ const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
59
+ await userEvent.type(textarea, " ");
60
+ await userEvent.type(textarea, "{Enter}");
61
+
62
+ expect(onSubmit).not.toHaveBeenCalled();
63
+ expect(textarea.value).toBe(" ");
64
+ });
65
+
66
+ it("DOES submit an attachment with no text (the guard must not over-reach)", async () => {
67
+ const onSubmit = vi.fn();
68
+ render(<Harness onSubmit={onSubmit} />);
69
+
70
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
71
+ await userEvent.upload(screen.getByLabelText("Upload files"), file);
72
+
73
+ // No text at all — an attachments-only message is legitimate.
74
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "{Enter}");
75
+
76
+ expect(onSubmit).toHaveBeenCalledTimes(1);
77
+ expect(onSubmit).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ files: [expect.objectContaining({ filename: "notes.txt", type: "file" })],
80
+ text: "",
81
+ }),
82
+ expect.anything(),
83
+ );
84
+ });
85
+
86
+ it("clears aria-disabled on the send button for an attachment alone", async () => {
87
+ render(<Harness />);
88
+ expect(screen.getByRole("button", { name: "Submit" })).toHaveAttribute("aria-disabled", "true");
89
+
90
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
91
+ await userEvent.upload(screen.getByLabelText("Upload files"), file);
92
+
93
+ expect(screen.getByRole("button", { name: "Submit" })).not.toHaveAttribute("aria-disabled");
94
+ });
95
+
96
+ it("fires onSubmit with the untrimmed text once there is real content", async () => {
97
+ const onSubmit = vi.fn();
98
+ render(<Harness onSubmit={onSubmit} />);
99
+
100
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), " hi {Enter}");
101
+ expect(onSubmit).toHaveBeenCalledTimes(1);
102
+ // Only the guard trims — the payload keeps the user's exact text.
103
+ expect(onSubmit).toHaveBeenCalledWith(
104
+ expect.objectContaining({ files: [], text: " hi " }),
105
+ expect.anything(),
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("PromptInputSubmit — disabled affordance mirrors the guard", () => {
111
+ it("marks itself aria-disabled at rest and clears it after typing", async () => {
112
+ render(<Harness />);
113
+ const send = screen.getByRole("button", { name: "Submit" });
114
+ // aria-disabled, NOT the native attribute: a focused control that becomes
115
+ // natively disabled is removed from the focus order, dropping focus to
116
+ // <body> after every keyboard submit. It must stay a real tab stop.
117
+ expect(send).toHaveAttribute("aria-disabled", "true");
118
+ expect(send).toBeEnabled();
119
+
120
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "hello");
121
+ expect(send).not.toHaveAttribute("aria-disabled");
122
+ });
123
+
124
+ it("stays focusable and keeps focus when it becomes not-ready", async () => {
125
+ render(<Harness />);
126
+ const send = screen.getByRole("button", { name: "Submit" });
127
+ send.focus();
128
+ expect(document.activeElement).toBe(send);
129
+
130
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "hi");
131
+ send.focus();
132
+ await userEvent.click(send);
133
+
134
+ // The composer clears, the button goes not-ready — and focus must survive.
135
+ expect(send).toHaveAttribute("aria-disabled", "true");
136
+ expect(document.activeElement).not.toBe(document.body);
137
+ });
138
+
139
+ it("returns to aria-disabled after a successful submit clears the composer", async () => {
140
+ render(<Harness />);
141
+ const send = screen.getByRole("button", { name: "Submit" });
142
+
143
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "hello{Enter}");
144
+ expect(send).toHaveAttribute("aria-disabled", "true");
145
+ });
146
+
147
+ it("honours an explicit disabled prop natively (consumer opts out of the tab order)", () => {
148
+ render(<Harness disabled />);
149
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
150
+ });
151
+
152
+ it("does not fire onSubmit when clicked while not-ready", async () => {
153
+ const onSubmit = vi.fn();
154
+ render(<Harness onSubmit={onSubmit} />);
155
+ // aria-disabled does not block activation — the handler must.
156
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
157
+ expect(onSubmit).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("stays enabled while generating — the button IS the Stop control", () => {
161
+ render(<Harness status="streaming" onStop={() => undefined} />);
162
+ expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled();
163
+ });
164
+
165
+ it("stays enabled on error so the user can retry", () => {
166
+ render(<Harness status="error" />);
167
+ expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
168
+ });
169
+
170
+ it("keeps working outside a PromptInput (no context to read)", () => {
171
+ render(<PromptInputSubmit />);
172
+ expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
173
+ });
174
+ });
175
+
176
+ describe("PromptInput — Enter is blocked while generating (composer empty)", () => {
177
+ it("does not submit on Enter mid-stream when the composer is empty", async () => {
178
+ // Regression: PromptInputSubmit flips to type="button" while generating, so
179
+ // the old `button[type="submit"]` lookup matched nothing and `null?.disabled`
180
+ // (undefined → falsy) let Enter send a second message.
181
+ const onSubmit = vi.fn();
182
+ render(<Harness onSubmit={onSubmit} status="streaming" onStop={() => undefined} />);
183
+
184
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "{Enter}");
185
+ expect(onSubmit).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("marks the generating control with data-generating for that lookup", () => {
189
+ render(<Harness status="streaming" onStop={() => undefined} />);
190
+ expect(screen.getByRole("button", { name: "Stop" })).toHaveAttribute("data-generating", "true");
191
+ });
192
+ });
193
+
194
+ describe("PromptInputSubmit — merged primary-action contract while running (#351)", () => {
195
+ it("stays Stop while running with an empty composer — click calls onStop, never onSubmit", async () => {
196
+ const onSubmit = vi.fn();
197
+ const onStop = vi.fn();
198
+ render(<Harness onSubmit={onSubmit} status="streaming" onStop={onStop} />);
199
+
200
+ const control = screen.getByRole("button", { name: "Stop" });
201
+ expect(control).toHaveAttribute("data-action", "stop");
202
+ expect(control).toHaveAttribute("data-generating", "true");
203
+
204
+ await userEvent.click(control);
205
+ expect(onStop).toHaveBeenCalledTimes(1);
206
+ expect(onSubmit).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it("flips back to Send once the user types during a running turn — click submits, never stops", async () => {
210
+ const onSubmit = vi.fn();
211
+ const onStop = vi.fn();
212
+ render(<Harness onSubmit={onSubmit} status="streaming" onStop={onStop} />);
213
+
214
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "a follow-up");
215
+
216
+ const control = screen.getByRole("button", { name: "Submit" });
217
+ expect(control).toHaveAttribute("data-action", "send");
218
+ expect(control).not.toHaveAttribute("data-generating");
219
+
220
+ await userEvent.click(control);
221
+ expect(onSubmit).toHaveBeenCalledTimes(1);
222
+ expect(onStop).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("submits on Enter once the user has typed during a running turn (the P0 fix)", async () => {
226
+ const onSubmit = vi.fn();
227
+ render(<Harness onSubmit={onSubmit} status="streaming" onStop={() => undefined} />);
228
+
229
+ await userEvent.type(screen.getByPlaceholderText("Ask…"), "another one{Enter}");
230
+ expect(onSubmit).toHaveBeenCalledTimes(1);
231
+ expect(onSubmit).toHaveBeenCalledWith(
232
+ expect.objectContaining({ text: "another one" }),
233
+ expect.anything(),
234
+ );
235
+ });
236
+
237
+ it("honours an explicit disabled prop as the documented opt-out — Enter does not submit", async () => {
238
+ const onSubmit = vi.fn();
239
+ render(<Harness onSubmit={onSubmit} status="streaming" onStop={() => undefined} disabled />);
240
+
241
+ const control = screen.getByPlaceholderText("Ask…");
242
+ await userEvent.type(control, "another one{Enter}");
243
+ expect(onSubmit).not.toHaveBeenCalled();
244
+
245
+ // Query broadly: an explicit `disabled` still renders as Send once typed
246
+ // (action flips), but stays disabled either way.
247
+ const button = screen.getByRole("button", { name: "Submit" });
248
+ expect(button).toBeDisabled();
249
+ });
250
+ });
251
+
252
+ describe("PromptInputStop — the composed 'separate' arrangement (#351)", () => {
253
+ function SeparateHarness({
254
+ onSubmit = () => undefined,
255
+ onStop = () => undefined,
256
+ status,
257
+ }: {
258
+ onSubmit?: (message: PromptInputMessage) => void;
259
+ onStop?: () => void;
260
+ status?: React.ComponentProps<typeof PromptInputSubmit>["status"];
261
+ }) {
262
+ return (
263
+ <PromptInput onSubmit={onSubmit}>
264
+ <PromptInputBody>
265
+ <PromptInputTextarea placeholder="Ask…" />
266
+ </PromptInputBody>
267
+ <PromptInputFooter>
268
+ <PromptInputStop status={status} onStop={onStop} />
269
+ <PromptInputSubmit status={status} onStop={onStop} />
270
+ </PromptInputFooter>
271
+ </PromptInput>
272
+ );
273
+ }
274
+
275
+ it("renders nothing at rest, and null once the turn finishes", () => {
276
+ render(<SeparateHarness />);
277
+ expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
278
+ });
279
+
280
+ it("keeps PromptInputSubmit as Send even while running with an empty composer, disabled with nothing to send; the Stop control owns stopping", async () => {
281
+ const onStop = vi.fn();
282
+ render(<SeparateHarness status="streaming" onStop={onStop} />);
283
+
284
+ const submit = screen.getByRole("button", { name: "Submit" });
285
+ expect(submit).toHaveAttribute("data-action", "send");
286
+ expect(submit).not.toHaveAttribute("data-generating");
287
+ // Refused via `aria-disabled` so the control stays focusable — the native
288
+ // attribute would drop focus to <body> after every keyboard send.
289
+ expect(submit).toHaveAttribute("aria-disabled", "true");
290
+ expect(submit).toBeEnabled();
291
+
292
+ const stop = screen.getByRole("button", { name: "Stop" });
293
+ await userEvent.click(stop);
294
+ expect(onStop).toHaveBeenCalledTimes(1);
295
+ });
296
+ });
297
+
298
+ describe("PromptInput — tone prop (double-card composer, #254)", () => {
299
+ it("defaults to the surface (muted) inner well — existing usages unaffected", () => {
300
+ const { container } = render(<Harness />);
301
+ const group = container.querySelector('[data-slot="input-group"]') as HTMLElement;
302
+ expect(group.className.split(" ")).toContain("bg-surface-muted");
303
+ });
304
+
305
+ it("renders a bg-card inner well when tone='card', for the tinted-outer/white-inner look", () => {
306
+ const { container } = render(
307
+ <PromptInput onSubmit={() => undefined} tone="card">
308
+ <PromptInputBody>
309
+ <PromptInputTextarea placeholder="Ask…" />
310
+ </PromptInputBody>
311
+ </PromptInput>,
312
+ );
313
+ const group = container.querySelector('[data-slot="input-group"]') as HTMLElement;
314
+ expect(group.className.split(" ")).toContain("bg-card");
315
+ expect(group.className.split(" ")).not.toContain("bg-surface-muted");
316
+ });
317
+ });
318
+
319
+ describe("PromptInputButton — tooltip derives aria-label (#356)", () => {
320
+ it("uses a string tooltip as the accessible name when no aria-label is given", () => {
321
+ render(
322
+ <PromptInputButton tooltip="Voice">
323
+ <svg aria-hidden="true" />
324
+ </PromptInputButton>,
325
+ );
326
+ expect(screen.getByRole("button", { name: "Voice" })).toBeInTheDocument();
327
+ });
328
+
329
+ it("uses the tooltip object's `content` string as the accessible name", () => {
330
+ render(
331
+ <PromptInputButton tooltip={{ content: "Record a voice message", shortcut: "⌘R" }}>
332
+ <svg aria-hidden="true" />
333
+ </PromptInputButton>,
334
+ );
335
+ expect(screen.getByRole("button", { name: "Record a voice message" })).toBeInTheDocument();
336
+ });
337
+
338
+ it("lets an explicit aria-label win over the tooltip-derived default", () => {
339
+ render(
340
+ <PromptInputButton tooltip="Voice" aria-label="Start voice recording">
341
+ <svg aria-hidden="true" />
342
+ </PromptInputButton>,
343
+ );
344
+ expect(screen.getByRole("button", { name: "Start voice recording" })).toBeInTheDocument();
345
+ expect(screen.queryByRole("button", { name: "Voice" })).not.toBeInTheDocument();
346
+ });
347
+
348
+ it("leaves buttons with no tooltip and no explicit aria-label unaffected", () => {
349
+ render(<PromptInputButton>Model</PromptInputButton>);
350
+ expect(screen.getByRole("button", { name: "Model" })).toBeInTheDocument();
351
+ });
352
+ });
353
+
354
+ describe("PromptInputButton — tooltip must not clobber a visible text label (WCAG 2.5.3)", () => {
355
+ it("keeps the VISIBLE text as (or within) the accessible name when the button also has a tooltip", () => {
356
+ render(<PromptInputButton tooltip="Change model">Claude Opus 4</PromptInputButton>);
357
+
358
+ // The visible label must survive — a tooltip-derived aria-label may not
359
+ // silently replace it (2.5.3 requires the visible label be contained in
360
+ // the accessible name).
361
+ expect(screen.getByRole("button", { name: "Claude Opus 4" })).toBeInTheDocument();
362
+ expect(screen.queryByRole("button", { name: "Change model" })).not.toBeInTheDocument();
363
+ });
364
+
365
+ it("still derives the accessible name for a genuinely icon-only button (#356 regression)", () => {
366
+ render(
367
+ <PromptInputButton tooltip="Voice">
368
+ <svg aria-hidden="true" />
369
+ </PromptInputButton>,
370
+ );
371
+ expect(screen.getByRole("button", { name: "Voice" })).toBeInTheDocument();
372
+ });
373
+
374
+ it("does not let an explicitly-passed aria-label={undefined} clobber the derived name", () => {
375
+ render(
376
+ <PromptInputButton aria-label={undefined} tooltip="Voice">
377
+ <svg aria-hidden="true" />
378
+ </PromptInputButton>,
379
+ );
380
+ expect(screen.getByRole("button", { name: "Voice" })).toBeInTheDocument();
381
+ });
382
+
383
+ it("still lets an explicit, DEFINED aria-label win over the derived default", () => {
384
+ render(
385
+ <PromptInputButton aria-label="Start voice recording" tooltip="Voice">
386
+ <svg aria-hidden="true" />
387
+ </PromptInputButton>,
388
+ );
389
+ expect(screen.getByRole("button", { name: "Start voice recording" })).toBeInTheDocument();
390
+ expect(screen.queryByRole("button", { name: "Voice" })).not.toBeInTheDocument();
391
+ });
392
+ });