@juspay/svelte-ui-components 2.126.0 → 2.127.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.
@@ -0,0 +1,354 @@
1
+ <script lang="ts">
2
+ import Button from '../Button/Button.svelte';
3
+ import Img from '../Img/Img.svelte';
4
+ import Scroller from '../Scroller/Scroller.svelte';
5
+ import { tooltip } from '../Tooltip/tooltip-action';
6
+ import type { AttachmentChipRowProperties } from './properties';
7
+
8
+ /**
9
+ * The pending-attachment strip above a chat composer: image thumbnails, video
10
+ * poster tiles (with a play badge) and file tiles, each with a floating remove
11
+ * button, scrolling horizontally when they overflow. Renders NOTHING while
12
+ * there are no attachments.
13
+ */
14
+ let {
15
+ images = [],
16
+ files = [],
17
+ videos = [],
18
+ onRemoveImage,
19
+ onRemoveFile,
20
+ onRemoveVideo,
21
+ onOpenImage,
22
+ onOpenVideo,
23
+ onOpenFile,
24
+ imageTooltip,
25
+ videoTooltip,
26
+ removeIcon,
27
+ fileIcon,
28
+ testId,
29
+ classes
30
+ }: AttachmentChipRowProperties = $props();
31
+ </script>
32
+
33
+ {#if images.length > 0 || videos.length > 0 || files.length > 0}
34
+ <Scroller
35
+ direction="horizontal"
36
+ showArrows={false}
37
+ hideScrollbar={false}
38
+ showGradient={false}
39
+ classes={`attachment-chip-row ${classes ?? ''}`}
40
+ {testId}
41
+ >
42
+ {#each images as image (image.id)}
43
+ <div
44
+ class="chip"
45
+ data-pw={typeof testId === 'string' ? `${testId}-image-${image.id}` : null}
46
+ testID={typeof testId === 'string' ? `${testId}-image-${image.id}` : null}
47
+ >
48
+ <svelte:element
49
+ this={typeof onOpenImage === 'function' ? 'button' : 'div'}
50
+ type={typeof onOpenImage === 'function' ? 'button' : null}
51
+ role={typeof onOpenImage === 'function' ? 'button' : null}
52
+ aria-label={typeof onOpenImage === 'function'
53
+ ? `Open ${image.filename ?? 'image'}`
54
+ : null}
55
+ class="thumb"
56
+ class:openable={typeof onOpenImage === 'function'}
57
+ onclick={typeof onOpenImage === 'function' ? () => onOpenImage(image) : null}
58
+ use:tooltip={{
59
+ text: typeof imageTooltip === 'function' ? imageTooltip(image) : '',
60
+ position: 'top'
61
+ }}
62
+ >
63
+ <Img src={image.thumbnailData} alt={image.filename ?? 'Uploaded image'} fallback="" />
64
+ </svelte:element>
65
+ {#if typeof onRemoveImage === 'function'}
66
+ <div class="remove-wrap">
67
+ <Button
68
+ variant="secondary"
69
+ iconOnly={true}
70
+ ariaLabel="Remove image"
71
+ testId={testId && `${testId}-remove-image-${image.id}`}
72
+ onclick={() => onRemoveImage(image.id)}
73
+ >
74
+ {#snippet icon()}
75
+ {#if removeIcon}
76
+ {@render removeIcon()}
77
+ {:else}
78
+ <svg class="cross" viewBox="0 0 16 16" fill="none" aria-hidden="true">
79
+ <path
80
+ d="M4 4l8 8m0-8l-8 8"
81
+ stroke="currentColor"
82
+ stroke-width="1.5"
83
+ stroke-linecap="round"
84
+ />
85
+ </svg>
86
+ {/if}
87
+ {/snippet}
88
+ </Button>
89
+ </div>
90
+ {/if}
91
+ </div>
92
+ {/each}
93
+ {#each videos as video (video.id)}
94
+ <div
95
+ class="chip"
96
+ data-pw={typeof testId === 'string' ? `${testId}-video-${video.id}` : null}
97
+ testID={typeof testId === 'string' ? `${testId}-video-${video.id}` : null}
98
+ >
99
+ <svelte:element
100
+ this={typeof onOpenVideo === 'function' ? 'button' : 'div'}
101
+ type={typeof onOpenVideo === 'function' ? 'button' : null}
102
+ role={typeof onOpenVideo === 'function' ? 'button' : null}
103
+ aria-label={typeof onOpenVideo === 'function'
104
+ ? `Play ${video.filename ?? 'video'}`
105
+ : null}
106
+ class="thumb video-tile"
107
+ class:openable={typeof onOpenVideo === 'function'}
108
+ onclick={typeof onOpenVideo === 'function' ? () => onOpenVideo(video) : null}
109
+ use:tooltip={{
110
+ text: typeof videoTooltip === 'function' ? videoTooltip(video) : '',
111
+ position: 'top'
112
+ }}
113
+ >
114
+ {#if typeof video.thumbnailData === 'string' && video.thumbnailData.length > 0}
115
+ <Img src={video.thumbnailData} alt={video.filename ?? 'Video attachment'} fallback="" />
116
+ {/if}
117
+ <span class="play-badge" aria-hidden="true">
118
+ <svg class="play-glyph" viewBox="0 0 16 16" fill="none">
119
+ <path d="M6 4.5v7l5.5-3.5z" fill="currentColor" />
120
+ </svg>
121
+ </span>
122
+ </svelte:element>
123
+ {#if typeof onRemoveVideo === 'function'}
124
+ <div class="remove-wrap">
125
+ <Button
126
+ variant="secondary"
127
+ iconOnly={true}
128
+ ariaLabel="Remove video"
129
+ testId={testId && `${testId}-remove-video-${video.id}`}
130
+ onclick={() => onRemoveVideo(video.id)}
131
+ >
132
+ {#snippet icon()}
133
+ {#if removeIcon}
134
+ {@render removeIcon()}
135
+ {:else}
136
+ <svg class="cross" viewBox="0 0 16 16" fill="none" aria-hidden="true">
137
+ <path
138
+ d="M4 4l8 8m0-8l-8 8"
139
+ stroke="currentColor"
140
+ stroke-width="1.5"
141
+ stroke-linecap="round"
142
+ />
143
+ </svg>
144
+ {/if}
145
+ {/snippet}
146
+ </Button>
147
+ </div>
148
+ {/if}
149
+ </div>
150
+ {/each}
151
+ {#each files as file (file.id)}
152
+ <div
153
+ class="chip"
154
+ data-pw={typeof testId === 'string' ? `${testId}-file-${file.id}` : null}
155
+ testID={typeof testId === 'string' ? `${testId}-file-${file.id}` : null}
156
+ >
157
+ <svelte:element
158
+ this={typeof onOpenFile === 'function' ? 'button' : 'div'}
159
+ type={typeof onOpenFile === 'function' ? 'button' : null}
160
+ role={typeof onOpenFile === 'function' ? 'button' : null}
161
+ aria-label={typeof onOpenFile === 'function' ? `Open ${file.filename}` : null}
162
+ class="thumb file-tile"
163
+ class:openable={typeof onOpenFile === 'function'}
164
+ onclick={typeof onOpenFile === 'function' ? () => onOpenFile(file) : null}
165
+ >
166
+ {#if fileIcon}
167
+ {@render fileIcon()}
168
+ {:else}
169
+ <svg class="file-glyph" viewBox="0 0 24 24" fill="none" aria-hidden="true">
170
+ <path
171
+ d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
172
+ fill="currentColor"
173
+ />
174
+ <path
175
+ d="M14 2v6h6"
176
+ stroke="var(--attachment-chip-row-file-glyph-fold-color, #ffffff)"
177
+ stroke-width="1.5"
178
+ stroke-linejoin="round"
179
+ />
180
+ </svg>
181
+ {/if}
182
+ <div class="file-info">
183
+ <span class="file-name" use:tooltip={{ text: file.filename, position: 'top' }}>
184
+ {file.filename}
185
+ </span>
186
+ </div>
187
+ </svelte:element>
188
+ {#if typeof onRemoveFile === 'function'}
189
+ <div class="remove-wrap">
190
+ <Button
191
+ variant="secondary"
192
+ iconOnly={true}
193
+ ariaLabel="Remove file"
194
+ testId={testId && `${testId}-remove-file-${file.id}`}
195
+ onclick={() => onRemoveFile(file.id)}
196
+ >
197
+ {#snippet icon()}
198
+ {#if removeIcon}
199
+ {@render removeIcon()}
200
+ {:else}
201
+ <svg class="cross" viewBox="0 0 16 16" fill="none" aria-hidden="true">
202
+ <path
203
+ d="M4 4l8 8m0-8l-8 8"
204
+ stroke="currentColor"
205
+ stroke-width="1.5"
206
+ stroke-linecap="round"
207
+ />
208
+ </svg>
209
+ {/if}
210
+ {/snippet}
211
+ </Button>
212
+ </div>
213
+ {/if}
214
+ </div>
215
+ {/each}
216
+ </Scroller>
217
+ {/if}
218
+
219
+ <style>
220
+ /* Keyed on a component-namespaced class passed to the Scroller root — the Scroller is
221
+ this component's own root, so there is no scoped ancestor to hang the rule on. */
222
+ :global(.attachment-chip-row) {
223
+ --scroller-gap: var(--attachment-chip-row-gap, 0.5rem);
224
+ }
225
+
226
+ .chip {
227
+ position: relative;
228
+ flex-shrink: 0;
229
+
230
+ /* Top/right padding is the room the floating remove button straddles into — it
231
+ keeps the button inside the chip's own box, so a clipping ancestor (the
232
+ Scroller viewport, a composer strip) can never cut it off. */
233
+ padding-top: var(--attachment-chip-row-chip-padding-top, 0.25rem);
234
+ padding-right: var(--attachment-chip-row-chip-padding-right, 0.25rem);
235
+ }
236
+
237
+ .thumb {
238
+ display: block;
239
+ width: var(--attachment-chip-row-chip-size, 4.5rem);
240
+ height: var(--attachment-chip-row-chip-size, 4.5rem);
241
+ padding: 0;
242
+ border: 0;
243
+ border-radius: var(--attachment-chip-row-chip-border-radius, 0.5rem);
244
+ background-color: var(--attachment-chip-row-chip-background, #ffffff);
245
+ overflow: hidden;
246
+ }
247
+
248
+ button.thumb {
249
+ cursor: pointer;
250
+ font: inherit;
251
+ color: inherit;
252
+ text-align: inherit;
253
+ }
254
+
255
+ .thumb :global(img) {
256
+ width: var(--attachment-chip-row-thumb-img-width, 100%);
257
+ height: var(--attachment-chip-row-thumb-img-height, 100%);
258
+ object-fit: var(--attachment-chip-row-thumb-img-fit, cover);
259
+ }
260
+
261
+ .remove-wrap {
262
+ position: absolute;
263
+ top: var(--attachment-chip-row-remove-offset, 0);
264
+ right: var(--attachment-chip-row-remove-offset, 0);
265
+ --button-width: var(--attachment-chip-row-remove-size, 1.25rem);
266
+ --button-height: var(--attachment-chip-row-remove-size, 1.25rem);
267
+ --button-min-width: 0;
268
+ --button-padding: 0;
269
+ --button-border-radius: var(--attachment-chip-row-remove-border-radius, 999px);
270
+
271
+ /* Self-contained colors, readable on both themes: a dark disc with a white
272
+ glyph and a white ring — never inherited from the ambient --button-* scope,
273
+ which flips with the host theme and can go dark-on-dark. */
274
+ --button-color: var(--attachment-chip-row-remove-background, #18181b);
275
+ --button-text-color: var(--attachment-chip-row-remove-color, #ffffff);
276
+ --button-hover-color: var(--attachment-chip-row-remove-hover-background, #3f3f46);
277
+ --button-border: var(--attachment-chip-row-remove-ring, 1.5px solid #ffffff);
278
+ --button-hover-border: var(--attachment-chip-row-remove-ring, 1.5px solid #ffffff);
279
+ --button-box-shadow: var(--attachment-chip-row-remove-box-shadow, 0 1px 3px rgb(0 0 0 / 30%));
280
+ }
281
+
282
+ .cross {
283
+ width: var(--attachment-chip-row-remove-glyph-size, 0.625rem);
284
+ height: var(--attachment-chip-row-remove-glyph-size, 0.625rem);
285
+ }
286
+
287
+ .video-tile {
288
+ position: relative;
289
+ background-color: var(--attachment-chip-row-video-tile-background, #18181b);
290
+ }
291
+
292
+ .play-badge {
293
+ position: absolute;
294
+ inset: 0;
295
+ display: flex;
296
+ align-items: center;
297
+ justify-content: center;
298
+ pointer-events: none;
299
+ }
300
+
301
+ .play-glyph {
302
+ width: var(--attachment-chip-row-play-glyph-size, 1.25rem);
303
+ height: var(--attachment-chip-row-play-glyph-size, 1.25rem);
304
+ color: var(--attachment-chip-row-play-glyph-color, #ffffff);
305
+ filter: var(--attachment-chip-row-play-glyph-shadow, drop-shadow(0 1px 2px rgb(0 0 0 / 45%)));
306
+ }
307
+
308
+ .file-tile {
309
+ display: flex;
310
+ flex-direction: column;
311
+ align-items: center;
312
+ justify-content: center;
313
+ gap: var(--attachment-chip-row-file-tile-gap, 0.25rem);
314
+ padding: var(--attachment-chip-row-file-tile-padding, 0.25rem 2px);
315
+ background-color: var(--attachment-chip-row-file-tile-background, #ededed);
316
+ border: var(--attachment-chip-row-file-tile-border, 1px solid #e1e1e1);
317
+ box-shadow: var(--attachment-chip-row-file-tile-box-shadow, 0 2px 0.25rem rgb(0 0 0 / 10%));
318
+ box-sizing: border-box;
319
+ }
320
+
321
+ .file-glyph {
322
+ width: var(--attachment-chip-row-file-glyph-size, 1.5rem);
323
+ height: var(--attachment-chip-row-file-glyph-size, 1.5rem);
324
+ color: var(--attachment-chip-row-file-glyph-color, #858585);
325
+ flex-shrink: 0;
326
+ }
327
+
328
+ .file-info {
329
+ width: 100%;
330
+ text-align: center;
331
+ min-width: 0;
332
+ }
333
+
334
+ .file-name {
335
+ display: block;
336
+ margin: 0;
337
+ white-space: nowrap;
338
+ overflow: hidden;
339
+ text-overflow: ellipsis;
340
+ max-width: 100%;
341
+ font-size: var(--attachment-chip-row-file-name-font-size, 0.75rem);
342
+ font-weight: var(--attachment-chip-row-file-name-font-weight, 400);
343
+ letter-spacing: var(--attachment-chip-row-file-name-letter-spacing, normal);
344
+ line-height: var(--attachment-chip-row-file-name-line-height, 1.25rem);
345
+ color: var(--attachment-chip-row-file-name-color, #52525b);
346
+ }
347
+
348
+ @media (width <= 767px) {
349
+ .remove-wrap {
350
+ --button-width: var(--attachment-chip-row-remove-size, 1.125rem);
351
+ --button-height: var(--attachment-chip-row-remove-size, 1.125rem);
352
+ }
353
+ }
354
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { AttachmentChipRowProperties } from './properties';
2
+ declare const AttachmentChipRow: import("svelte").Component<AttachmentChipRowProperties, {}, "">;
3
+ type AttachmentChipRow = ReturnType<typeof AttachmentChipRow>;
4
+ export default AttachmentChipRow;
@@ -0,0 +1,47 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type AttachmentChipImage = {
3
+ id: string;
4
+ /** Image src for the thumbnail — a data URI or URL. */
5
+ thumbnailData: string;
6
+ filename?: string | null;
7
+ };
8
+ export type AttachmentChipFile = {
9
+ id: string;
10
+ filename: string;
11
+ };
12
+ export type AttachmentChipVideo = {
13
+ id: string;
14
+ /** Poster/thumbnail src for the tile — a data URI or URL. Omit for a plain dark tile. */
15
+ thumbnailData?: string | null;
16
+ filename?: string | null;
17
+ };
18
+ export type AttachmentChipRowProperties = OptionalAttachmentChipRowProperties & MandatoryAttachmentChipRowProperties;
19
+ export type MandatoryAttachmentChipRowProperties = Record<never, never>;
20
+ export type OptionalAttachmentChipRowProperties = {
21
+ /** Omit to render the row read-only — chips lose their remove buttons. */
22
+ onRemoveImage?: (id: string) => void;
23
+ onRemoveFile?: (id: string) => void;
24
+ onRemoveVideo?: (id: string) => void;
25
+ /**
26
+ * Open/preview callbacks — when provided, the chip's tile becomes a real button
27
+ * and clicking it fires with the attachment (e.g. open a lightbox, play the
28
+ * video). Omitted, the tile stays non-interactive.
29
+ */
30
+ onOpenImage?: (image: AttachmentChipImage) => void;
31
+ onOpenVideo?: (video: AttachmentChipVideo) => void;
32
+ onOpenFile?: (file: AttachmentChipFile) => void;
33
+ images?: AttachmentChipImage[];
34
+ files?: AttachmentChipFile[];
35
+ /** Video attachments — a poster tile with a play badge (or a plain dark tile without a poster). */
36
+ videos?: AttachmentChipVideo[];
37
+ /** Tooltip text for an image chip. Nothing is shown when omitted or empty. */
38
+ imageTooltip?: (image: AttachmentChipImage) => string;
39
+ /** Tooltip text for a video chip. Nothing is shown when omitted or empty. */
40
+ videoTooltip?: (video: AttachmentChipVideo) => string;
41
+ /** Glyph inside the remove button. Falls back to a built-in cross. */
42
+ removeIcon?: Snippet;
43
+ /** Glyph on a file chip. Falls back to a built-in document. */
44
+ fileIcon?: Snippet;
45
+ testId?: string;
46
+ classes?: string;
47
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
1
  import type { ChatBubbleProperties } from './properties';
2
- declare const ChatBubble: import("svelte").Component<ChatBubbleProperties, {}, "open" | "dragX" | "dragY" | "panelWidth" | "panelHeight" | "expanded" | "expandedPanelWidth" | "expandedPanelHeight">;
2
+ declare const ChatBubble: import("svelte").Component<ChatBubbleProperties, {}, "open" | "expanded" | "dragX" | "dragY" | "panelWidth" | "panelHeight" | "expandedPanelWidth" | "expandedPanelHeight">;
3
3
  type ChatBubble = ReturnType<typeof ChatBubble>;
4
4
  export default ChatBubble;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Button from '../Button/Button.svelte';
3
3
  import Pill from '../Pill/Pill.svelte';
4
+ import AttachmentChipRow from '../AttachmentChipRow/AttachmentChipRow.svelte';
4
5
  import sendSvg from '../assets/send.svg?raw';
5
6
  import stopSvg from '../assets/stop.svg?raw';
6
7
  import micSvg from '../assets/mic.svg?raw';
@@ -17,6 +18,21 @@
17
18
  streaming = false,
18
19
  recording = false,
19
20
  attachments = $bindable([]),
21
+ attachmentsPreview,
22
+ richImages = [],
23
+ richFiles = [],
24
+ richVideos = [],
25
+ richImageTooltip,
26
+ richVideoTooltip,
27
+ richRemoveIcon,
28
+ richFileIcon,
29
+ onremoverichimage,
30
+ onremoverichfile,
31
+ onremoverichvideo,
32
+ onopenrichimage,
33
+ onopenrichvideo,
34
+ onopenrichfile,
35
+ sendable = null,
20
36
  accept = '',
21
37
  multiple = false,
22
38
  sendLabel = 'Send message',
@@ -27,22 +43,42 @@
27
43
  stopIcon,
28
44
  voiceIcon,
29
45
  attachIcon,
46
+ actionIcon,
47
+ actionLabel = 'Voice conversation',
30
48
  leading,
31
49
  onsubmit,
32
50
  oninput,
33
51
  onkeydown,
52
+ onpaste,
34
53
  onstop,
35
54
  onvoice,
36
55
  onattach,
56
+ onattachclick,
57
+ onaction,
37
58
  testId,
59
+ inputTestId,
60
+ inputAriaLabel,
61
+ sendTestId,
62
+ sendSlotTestId,
63
+ stopTestId,
64
+ voiceTestId,
65
+ attachTestId,
66
+ actionTestId,
38
67
  classes
39
68
  }: ChatComposerProperties = $props();
40
69
 
41
70
  let fileInput: HTMLInputElement | null = $state(null);
42
71
 
43
72
  let showVoice = $derived(typeof onvoice === 'function');
44
- let showAttach = $derived(typeof onattach === 'function');
45
- let canSend = $derived(!disabled && (value.trim().length > 0 || attachments.length > 0));
73
+ let showAttach = $derived(typeof onattach === 'function' || typeof onattachclick === 'function');
74
+ let hasRichAttachments = $derived(
75
+ richImages.length > 0 || richVideos.length > 0 || richFiles.length > 0
76
+ );
77
+
78
+ let canSend = $derived(
79
+ !disabled &&
80
+ (sendable ?? (value.trim().length > 0 || attachments.length > 0 || hasRichAttachments))
81
+ );
46
82
 
47
83
  function submit(): void {
48
84
  if (!canSend) {
@@ -100,7 +136,28 @@
100
136
  data-pw={typeof testId === 'string' ? testId : null}
101
137
  testID={typeof testId === 'string' ? testId : null}
102
138
  >
103
- {#if attachments.length > 0}
139
+ {#if typeof attachmentsPreview === 'function'}
140
+ {@render attachmentsPreview()}
141
+ {:else if hasRichAttachments}
142
+ <div class="attachments-rich">
143
+ <AttachmentChipRow
144
+ images={richImages}
145
+ videos={richVideos}
146
+ files={richFiles}
147
+ onRemoveImage={onremoverichimage}
148
+ onRemoveVideo={onremoverichvideo}
149
+ onRemoveFile={onremoverichfile}
150
+ onOpenImage={onopenrichimage}
151
+ onOpenVideo={onopenrichvideo}
152
+ onOpenFile={onopenrichfile}
153
+ imageTooltip={richImageTooltip}
154
+ videoTooltip={richVideoTooltip}
155
+ removeIcon={richRemoveIcon}
156
+ fileIcon={richFileIcon}
157
+ testId={testId && `${testId}-rich-attachments`}
158
+ />
159
+ </div>
160
+ {:else if attachments.length > 0}
104
161
  <div class="attachments">
105
162
  {#each attachments as file, index (file.name + index)}
106
163
  <Pill text={file.name} dismissible ondismiss={() => removeAttachment(index)} />
@@ -111,7 +168,18 @@
111
168
  <div class="input-row">
112
169
  {#if showAttach}
113
170
  <div class="control attach">
114
- <Button onclick={() => fileInput?.click()} {disabled} ariaLabel={attachLabel}>
171
+ <Button
172
+ onclick={() => {
173
+ if (typeof onattachclick === 'function') {
174
+ onattachclick();
175
+ return;
176
+ }
177
+ fileInput?.click();
178
+ }}
179
+ {disabled}
180
+ ariaLabel={attachLabel}
181
+ testId={attachTestId}
182
+ >
115
183
  {#if typeof attachIcon === 'function'}
116
184
  {@render attachIcon()}
117
185
  {:else}
@@ -137,20 +205,22 @@
137
205
 
138
206
  <textarea
139
207
  class="input"
208
+ data-pw={inputTestId ?? null}
140
209
  bind:value
141
210
  {placeholder}
142
211
  {disabled}
143
212
  rows="1"
144
- aria-label={placeholder.length > 0 ? placeholder : 'Message'}
213
+ aria-label={inputAriaLabel ?? (placeholder.length > 0 ? placeholder : 'Message')}
145
214
  maxlength={maxLength > 0 ? maxLength : null}
146
215
  oninput={handleInput}
147
216
  onkeydown={handleKeydown}
217
+ onpaste={(event) => onpaste?.(event)}
148
218
  use:autoGrow={value}
149
219
  ></textarea>
150
220
 
151
221
  {#if showVoice}
152
222
  <div class="control voice" class:recording>
153
- <Button onclick={() => onvoice?.()} {disabled} ariaLabel={voiceLabel}>
223
+ <Button onclick={() => onvoice?.()} {disabled} ariaLabel={voiceLabel} testId={voiceTestId}>
154
224
  {#if typeof voiceIcon === 'function'}
155
225
  {@render voiceIcon()}
156
226
  {:else}
@@ -162,8 +232,8 @@
162
232
  {/if}
163
233
 
164
234
  {#if streaming}
165
- <div class="control send stop">
166
- <Button onclick={() => onstop?.()} ariaLabel={stopLabel}>
235
+ <div class="control send stop" data-pw={sendSlotTestId ?? null}>
236
+ <Button onclick={() => onstop?.()} ariaLabel={stopLabel} testId={stopTestId}>
167
237
  {#if typeof stopIcon === 'function'}
168
238
  {@render stopIcon()}
169
239
  {:else}
@@ -172,9 +242,20 @@
172
242
  {/if}
173
243
  </Button>
174
244
  </div>
245
+ {:else if typeof onaction === 'function' && !canSend && !recording}
246
+ <div class="control send action" data-pw={sendSlotTestId ?? null}>
247
+ <Button onclick={() => onaction()} {disabled} ariaLabel={actionLabel} testId={actionTestId}>
248
+ {#if typeof actionIcon === 'function'}
249
+ {@render actionIcon()}
250
+ {:else}
251
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
252
+ {@html micSvg}
253
+ {/if}
254
+ </Button>
255
+ </div>
175
256
  {:else}
176
- <div class="control send">
177
- <Button onclick={submit} disabled={!canSend} ariaLabel={sendLabel}>
257
+ <div class="control send" data-pw={sendSlotTestId ?? null}>
258
+ <Button onclick={submit} disabled={!canSend} ariaLabel={sendLabel} testId={sendTestId}>
178
259
  {#if typeof sendIcon === 'function'}
179
260
  {@render sendIcon()}
180
261
  {:else}
@@ -205,6 +286,10 @@
205
286
  opacity: var(--chat-composer-disabled-opacity, 0.6);
206
287
  }
207
288
 
289
+ .attachments-rich {
290
+ padding: var(--chat-composer-attachments-padding, 2px 4px 0);
291
+ }
292
+
208
293
  .attachments {
209
294
  display: flex;
210
295
  flex-wrap: wrap;
@@ -233,6 +318,7 @@
233
318
  background: transparent;
234
319
  font-family: var(--chat-composer-font-family, inherit);
235
320
  font-size: var(--chat-composer-font-size, 0.9375rem);
321
+ font-weight: var(--chat-composer-font-weight, 400);
236
322
  line-height: var(--chat-composer-line-height, 1.5);
237
323
  color: var(--chat-composer-color, #18181b);
238
324
  padding: var(--chat-composer-input-padding, 6px 4px);