@r2digisolutions/components 0.17.2 → 0.17.3

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.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
2
+ import { tick, type Snippet } from 'svelte';
3
3
  import IconButton from '../../atoms/IconButton/IconButton.svelte';
4
4
  import {
5
5
  Sparkles,
@@ -10,7 +10,9 @@
10
10
  Volume2,
11
11
  VolumeX,
12
12
  LoaderCircle,
13
- RotateCcw
13
+ RotateCcw,
14
+ Copy,
15
+ Check
14
16
  } from '@lucide/svelte';
15
17
 
16
18
  export interface AssistantMessage {
@@ -30,7 +32,7 @@
30
32
  draft?: string;
31
33
  loading?: boolean;
32
34
  loadingLabel?: string;
33
- /** When false, input stays disabled via `loading` but the spinner bubble is hidden (parent paints status in a message). */
35
+ /** When false, the spinner bubble is hidden (parent paints status in a message). */
34
36
  showLoadingBubble?: boolean;
35
37
  error?: string | null;
36
38
  voiceEnabled?: boolean;
@@ -43,12 +45,15 @@
43
45
  stopMicLabel?: string;
44
46
  voiceOnLabel?: string;
45
47
  voiceOffLabel?: string;
48
+ copyChatLabel?: string;
49
+ copiedChatLabel?: string;
46
50
  class?: string;
47
51
  result?: Snippet<[AssistantMessage]>;
48
52
  footerExtra?: Snippet;
49
53
  onsend?: () => void;
50
54
  onclose?: () => void;
51
55
  onnewchat?: () => void;
56
+ oncopychat?: () => void | Promise<void>;
52
57
  onmicclick?: () => void;
53
58
  onvoiceoutputchange?: (on: boolean) => void;
54
59
  onsuggestion?: (text: string) => void;
@@ -77,21 +82,46 @@
77
82
  stopMicLabel = 'Detener micrófono',
78
83
  voiceOnLabel = 'Voz activada',
79
84
  voiceOffLabel = 'Voz desactivada',
85
+ copyChatLabel = 'Copiar chat',
86
+ copiedChatLabel = 'Copiado',
80
87
  class: className = '',
81
88
  result,
82
89
  footerExtra,
83
90
  onsend,
84
91
  onclose,
85
92
  onnewchat,
93
+ oncopychat,
86
94
  onmicclick,
87
95
  onvoiceoutputchange,
88
96
  onsuggestion
89
97
  }: AssistantChatProps = $props();
90
98
 
91
99
  let scrollEl = $state<HTMLDivElement | null>(null);
100
+ let inputEl: HTMLInputElement | null = null;
101
+ /** Restore the composer after send / SvelteKit form focus reset. */
102
+ let retainComposerFocus = false;
103
+ let copiedFlash = $state(false);
92
104
 
93
105
  const isEmpty = $derived(messages.length === 0 && !loading);
94
106
  const canSend = $derived(draft.trim().length > 0 && !loading);
107
+ const pendingAssistantId = $derived.by(() => {
108
+ if (!loading || messages.length === 0) return null;
109
+ const last = messages[messages.length - 1];
110
+ if (last?.role === 'assistant' && !last.content.trim()) return last.id;
111
+ return null;
112
+ });
113
+ const showTrailingLoader = $derived(loading && showLoadingBubble && !pendingAssistantId);
114
+
115
+ function focusComposer() {
116
+ inputEl?.focus({ preventScroll: true });
117
+ }
118
+
119
+ /** Parent can call this after client-side navigation (goto). */
120
+ export function focus() {
121
+ retainComposerFocus = true;
122
+ focusComposer();
123
+ void tick().then(focusComposer);
124
+ }
95
125
 
96
126
  $effect(() => {
97
127
  void messages.length;
@@ -104,14 +134,26 @@
104
134
  return () => cancelAnimationFrame(raf);
105
135
  });
106
136
 
137
+ $effect(() => {
138
+ if (!open || loading || !retainComposerFocus) return;
139
+ const id = requestAnimationFrame(() => {
140
+ if (open && retainComposerFocus) focusComposer();
141
+ if (!loading) retainComposerFocus = false;
142
+ });
143
+ return () => cancelAnimationFrame(id);
144
+ });
145
+
107
146
  function handleClose() {
147
+ retainComposerFocus = false;
108
148
  open = false;
109
149
  onclose?.();
110
150
  }
111
151
 
112
152
  function handleSend() {
113
153
  if (!canSend) return;
154
+ retainComposerFocus = true;
114
155
  onsend?.();
156
+ void tick().then(focusComposer);
115
157
  }
116
158
 
117
159
  function handleKeydown(e: KeyboardEvent) {
@@ -130,6 +172,15 @@
130
172
  voiceOutput = !voiceOutput;
131
173
  onvoiceoutputchange?.(voiceOutput);
132
174
  }
175
+
176
+ async function handleCopyChat() {
177
+ if (!oncopychat || messages.length === 0) return;
178
+ await oncopychat();
179
+ copiedFlash = true;
180
+ window.setTimeout(() => {
181
+ copiedFlash = false;
182
+ }, 1600);
183
+ }
133
184
  </script>
134
185
 
135
186
  <div
@@ -154,6 +205,19 @@
154
205
  <p class="text-xs text-muted truncate">{statusLabel}</p>
155
206
  {/if}
156
207
  </div>
208
+ <IconButton
209
+ variant="ghost"
210
+ size="xs"
211
+ label={copiedFlash ? copiedChatLabel : copyChatLabel}
212
+ disabled={messages.length === 0 || !oncopychat}
213
+ onclick={() => void handleCopyChat()}
214
+ >
215
+ {#if copiedFlash}
216
+ <Check size={14} strokeWidth={2} />
217
+ {:else}
218
+ <Copy size={14} strokeWidth={2} />
219
+ {/if}
220
+ </IconButton>
157
221
  <IconButton variant="ghost" size="xs" label={newChatLabel} onclick={() => onnewchat?.()}>
158
222
  <RotateCcw size={14} strokeWidth={2} />
159
223
  </IconButton>
@@ -193,7 +257,7 @@
193
257
  {#if message.role === 'user'}
194
258
  <div class="flex justify-end">
195
259
  <div
196
- class="rounded-2xl rounded-br-sm bg-brand-600 px-3.5 py-2.5 text-sm leading-relaxed text-white max-w-[80%]"
260
+ class="rounded-2xl rounded-br-sm bg-brand-600 px-3.5 py-2.5 text-sm leading-relaxed text-white max-w-[80%] whitespace-pre-wrap"
197
261
  >
198
262
  {message.content}
199
263
  </div>
@@ -208,9 +272,16 @@
208
272
  <Sparkles size={12} strokeWidth={2} />
209
273
  </span>
210
274
  <div
211
- class="rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5 text-sm leading-relaxed text-primary max-w-[calc(100%-2rem)]"
275
+ class="rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5 text-sm leading-relaxed text-primary max-w-[calc(100%-2rem)] whitespace-pre-wrap"
212
276
  >
213
- {message.content}
277
+ {#if message.id === pendingAssistantId}
278
+ <span class="gap-2 text-muted flex items-center">
279
+ <LoaderCircle size={14} strokeWidth={2} class="animate-spin shrink-0" />
280
+ <span class="text-xs">{loadingLabel}</span>
281
+ </span>
282
+ {:else}
283
+ {message.content}
284
+ {/if}
214
285
  </div>
215
286
  </div>
216
287
  {#if result}
@@ -222,7 +293,7 @@
222
293
  {/if}
223
294
  {/each}
224
295
 
225
- {#if loading && showLoadingBubble}
296
+ {#if showTrailingLoader}
226
297
  <div class="gap-2 flex items-center">
227
298
  <span
228
299
  class="h-6 w-6 bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400 flex shrink-0 items-center justify-center rounded-full"
@@ -246,6 +317,17 @@
246
317
  <footer class="border-border px-3 py-2.5 shrink-0 border-t">
247
318
  <div class="gap-1.5 flex items-center">
248
319
  <input
320
+ {@attach (node) => {
321
+ inputEl = node;
322
+ $effect(() => {
323
+ if (!open || loading || !retainComposerFocus) return;
324
+ node.focus();
325
+ retainComposerFocus = false;
326
+ });
327
+ return () => {
328
+ if (inputEl === node) inputEl = null;
329
+ };
330
+ }}
249
331
  type="text"
250
332
  bind:value={draft}
251
333
  placeholder={listening ? statusLabel || placeholder : placeholder}
@@ -253,7 +335,6 @@
253
335
  'h-9 min-w-0 rounded-xl bg-surface px-3 text-sm text-primary placeholder:text-muted focus:border-brand-400 focus:ring-brand-500/20 flex-1 border transition-shadow outline-none focus:ring-2',
254
336
  listening ? 'border-red-400 ring-red-500/20 ring-2' : 'border-border'
255
337
  ]}
256
- disabled={loading}
257
338
  onkeydown={handleKeydown}
258
339
  aria-label={placeholder}
259
340
  />
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  export interface AssistantMessage {
3
3
  id: string;
4
4
  role: 'user' | 'assistant';
@@ -15,7 +15,7 @@ interface AssistantChatProps {
15
15
  draft?: string;
16
16
  loading?: boolean;
17
17
  loadingLabel?: string;
18
- /** When false, input stays disabled via `loading` but the spinner bubble is hidden (parent paints status in a message). */
18
+ /** When false, the spinner bubble is hidden (parent paints status in a message). */
19
19
  showLoadingBubble?: boolean;
20
20
  error?: string | null;
21
21
  voiceEnabled?: boolean;
@@ -28,16 +28,21 @@ interface AssistantChatProps {
28
28
  stopMicLabel?: string;
29
29
  voiceOnLabel?: string;
30
30
  voiceOffLabel?: string;
31
+ copyChatLabel?: string;
32
+ copiedChatLabel?: string;
31
33
  class?: string;
32
34
  result?: Snippet<[AssistantMessage]>;
33
35
  footerExtra?: Snippet;
34
36
  onsend?: () => void;
35
37
  onclose?: () => void;
36
38
  onnewchat?: () => void;
39
+ oncopychat?: () => void | Promise<void>;
37
40
  onmicclick?: () => void;
38
41
  onvoiceoutputchange?: (on: boolean) => void;
39
42
  onsuggestion?: (text: string) => void;
40
43
  }
41
- declare const AssistantChat: import("svelte").Component<AssistantChatProps, {}, "open" | "draft" | "voiceOutput">;
44
+ declare const AssistantChat: import("svelte").Component<AssistantChatProps, {
45
+ focus: () => void;
46
+ }, "open" | "draft" | "voiceOutput">;
42
47
  type AssistantChat = ReturnType<typeof AssistantChat>;
43
48
  export default AssistantChat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",