@r2digisolutions/components 0.17.2 → 0.17.4

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 {
@@ -26,11 +28,13 @@
26
28
  placeholder?: string;
27
29
  emptyHint?: string;
28
30
  suggestions?: string[];
31
+ /** Chips shown under the conversation (after the user has messages). */
32
+ followUpSuggestions?: string[];
29
33
  messages?: AssistantMessage[];
30
34
  draft?: string;
31
35
  loading?: boolean;
32
36
  loadingLabel?: string;
33
- /** When false, input stays disabled via `loading` but the spinner bubble is hidden (parent paints status in a message). */
37
+ /** When false, the spinner bubble is hidden (parent paints status in a message). */
34
38
  showLoadingBubble?: boolean;
35
39
  error?: string | null;
36
40
  voiceEnabled?: boolean;
@@ -43,12 +47,15 @@
43
47
  stopMicLabel?: string;
44
48
  voiceOnLabel?: string;
45
49
  voiceOffLabel?: string;
50
+ copyChatLabel?: string;
51
+ copiedChatLabel?: string;
46
52
  class?: string;
47
53
  result?: Snippet<[AssistantMessage]>;
48
54
  footerExtra?: Snippet;
49
55
  onsend?: () => void;
50
56
  onclose?: () => void;
51
57
  onnewchat?: () => void;
58
+ oncopychat?: () => void | Promise<void>;
52
59
  onmicclick?: () => void;
53
60
  onvoiceoutputchange?: (on: boolean) => void;
54
61
  onsuggestion?: (text: string) => void;
@@ -61,6 +68,7 @@
61
68
  placeholder = 'Escribe un mensaje…',
62
69
  emptyHint = '¿En qué puedo ayudarte?',
63
70
  suggestions = [],
71
+ followUpSuggestions = [],
64
72
  messages = [],
65
73
  draft = $bindable(''),
66
74
  loading = false,
@@ -77,21 +85,46 @@
77
85
  stopMicLabel = 'Detener micrófono',
78
86
  voiceOnLabel = 'Voz activada',
79
87
  voiceOffLabel = 'Voz desactivada',
88
+ copyChatLabel = 'Copiar chat',
89
+ copiedChatLabel = 'Copiado',
80
90
  class: className = '',
81
91
  result,
82
92
  footerExtra,
83
93
  onsend,
84
94
  onclose,
85
95
  onnewchat,
96
+ oncopychat,
86
97
  onmicclick,
87
98
  onvoiceoutputchange,
88
99
  onsuggestion
89
100
  }: AssistantChatProps = $props();
90
101
 
91
102
  let scrollEl = $state<HTMLDivElement | null>(null);
103
+ let inputEl: HTMLInputElement | null = null;
104
+ /** Restore the composer after send / SvelteKit form focus reset. */
105
+ let retainComposerFocus = false;
106
+ let copiedFlash = $state(false);
92
107
 
93
108
  const isEmpty = $derived(messages.length === 0 && !loading);
94
109
  const canSend = $derived(draft.trim().length > 0 && !loading);
110
+ const pendingAssistantId = $derived.by(() => {
111
+ if (!loading || messages.length === 0) return null;
112
+ const last = messages[messages.length - 1];
113
+ if (last?.role === 'assistant' && !last.content.trim()) return last.id;
114
+ return null;
115
+ });
116
+ const showTrailingLoader = $derived(loading && showLoadingBubble && !pendingAssistantId);
117
+
118
+ function focusComposer() {
119
+ inputEl?.focus({ preventScroll: true });
120
+ }
121
+
122
+ /** Parent can call this after client-side navigation (goto). */
123
+ export function focus() {
124
+ retainComposerFocus = true;
125
+ focusComposer();
126
+ void tick().then(focusComposer);
127
+ }
95
128
 
96
129
  $effect(() => {
97
130
  void messages.length;
@@ -104,14 +137,26 @@
104
137
  return () => cancelAnimationFrame(raf);
105
138
  });
106
139
 
140
+ $effect(() => {
141
+ if (!open || loading || !retainComposerFocus) return;
142
+ const id = requestAnimationFrame(() => {
143
+ if (open && retainComposerFocus) focusComposer();
144
+ if (!loading) retainComposerFocus = false;
145
+ });
146
+ return () => cancelAnimationFrame(id);
147
+ });
148
+
107
149
  function handleClose() {
150
+ retainComposerFocus = false;
108
151
  open = false;
109
152
  onclose?.();
110
153
  }
111
154
 
112
155
  function handleSend() {
113
156
  if (!canSend) return;
157
+ retainComposerFocus = true;
114
158
  onsend?.();
159
+ void tick().then(focusComposer);
115
160
  }
116
161
 
117
162
  function handleKeydown(e: KeyboardEvent) {
@@ -130,6 +175,15 @@
130
175
  voiceOutput = !voiceOutput;
131
176
  onvoiceoutputchange?.(voiceOutput);
132
177
  }
178
+
179
+ async function handleCopyChat() {
180
+ if (!oncopychat || messages.length === 0) return;
181
+ await oncopychat();
182
+ copiedFlash = true;
183
+ window.setTimeout(() => {
184
+ copiedFlash = false;
185
+ }, 1600);
186
+ }
133
187
  </script>
134
188
 
135
189
  <div
@@ -154,6 +208,19 @@
154
208
  <p class="text-xs text-muted truncate">{statusLabel}</p>
155
209
  {/if}
156
210
  </div>
211
+ <IconButton
212
+ variant="ghost"
213
+ size="xs"
214
+ label={copiedFlash ? copiedChatLabel : copyChatLabel}
215
+ disabled={messages.length === 0 || !oncopychat}
216
+ onclick={() => void handleCopyChat()}
217
+ >
218
+ {#if copiedFlash}
219
+ <Check size={14} strokeWidth={2} />
220
+ {:else}
221
+ <Copy size={14} strokeWidth={2} />
222
+ {/if}
223
+ </IconButton>
157
224
  <IconButton variant="ghost" size="xs" label={newChatLabel} onclick={() => onnewchat?.()}>
158
225
  <RotateCcw size={14} strokeWidth={2} />
159
226
  </IconButton>
@@ -193,7 +260,7 @@
193
260
  {#if message.role === 'user'}
194
261
  <div class="flex justify-end">
195
262
  <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%]"
263
+ 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
264
  >
198
265
  {message.content}
199
266
  </div>
@@ -208,9 +275,16 @@
208
275
  <Sparkles size={12} strokeWidth={2} />
209
276
  </span>
210
277
  <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)]"
278
+ 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
279
  >
213
- {message.content}
280
+ {#if message.id === pendingAssistantId}
281
+ <span class="gap-2 text-muted flex items-center">
282
+ <LoaderCircle size={14} strokeWidth={2} class="animate-spin shrink-0" />
283
+ <span class="text-xs">{loadingLabel}</span>
284
+ </span>
285
+ {:else}
286
+ {message.content}
287
+ {/if}
214
288
  </div>
215
289
  </div>
216
290
  {#if result}
@@ -222,7 +296,7 @@
222
296
  {/if}
223
297
  {/each}
224
298
 
225
- {#if loading && showLoadingBubble}
299
+ {#if showTrailingLoader}
226
300
  <div class="gap-2 flex items-center">
227
301
  <span
228
302
  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"
@@ -238,6 +312,20 @@
238
312
  </div>
239
313
  </div>
240
314
  {/if}
315
+
316
+ {#if !loading && followUpSuggestions.length}
317
+ <div class="ml-8 gap-2 flex flex-wrap">
318
+ {#each followUpSuggestions as suggestion (suggestion)}
319
+ <button
320
+ type="button"
321
+ class="border-border bg-surface px-3 py-1.5 text-xs text-secondary hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400 rounded-full border transition-colors"
322
+ onclick={() => handleSuggestion(suggestion)}
323
+ >
324
+ {suggestion}
325
+ </button>
326
+ {/each}
327
+ </div>
328
+ {/if}
241
329
  </div>
242
330
  {/if}
243
331
  </div>
@@ -246,6 +334,17 @@
246
334
  <footer class="border-border px-3 py-2.5 shrink-0 border-t">
247
335
  <div class="gap-1.5 flex items-center">
248
336
  <input
337
+ {@attach (node) => {
338
+ inputEl = node;
339
+ $effect(() => {
340
+ if (!open || loading || !retainComposerFocus) return;
341
+ node.focus();
342
+ retainComposerFocus = false;
343
+ });
344
+ return () => {
345
+ if (inputEl === node) inputEl = null;
346
+ };
347
+ }}
249
348
  type="text"
250
349
  bind:value={draft}
251
350
  placeholder={listening ? statusLabel || placeholder : placeholder}
@@ -253,7 +352,6 @@
253
352
  '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
353
  listening ? 'border-red-400 ring-red-500/20 ring-2' : 'border-border'
255
354
  ]}
256
- disabled={loading}
257
355
  onkeydown={handleKeydown}
258
356
  aria-label={placeholder}
259
357
  />
@@ -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';
@@ -11,11 +11,13 @@ interface AssistantChatProps {
11
11
  placeholder?: string;
12
12
  emptyHint?: string;
13
13
  suggestions?: string[];
14
+ /** Chips shown under the conversation (after the user has messages). */
15
+ followUpSuggestions?: string[];
14
16
  messages?: AssistantMessage[];
15
17
  draft?: string;
16
18
  loading?: boolean;
17
19
  loadingLabel?: string;
18
- /** When false, input stays disabled via `loading` but the spinner bubble is hidden (parent paints status in a message). */
20
+ /** When false, the spinner bubble is hidden (parent paints status in a message). */
19
21
  showLoadingBubble?: boolean;
20
22
  error?: string | null;
21
23
  voiceEnabled?: boolean;
@@ -28,16 +30,21 @@ interface AssistantChatProps {
28
30
  stopMicLabel?: string;
29
31
  voiceOnLabel?: string;
30
32
  voiceOffLabel?: string;
33
+ copyChatLabel?: string;
34
+ copiedChatLabel?: string;
31
35
  class?: string;
32
36
  result?: Snippet<[AssistantMessage]>;
33
37
  footerExtra?: Snippet;
34
38
  onsend?: () => void;
35
39
  onclose?: () => void;
36
40
  onnewchat?: () => void;
41
+ oncopychat?: () => void | Promise<void>;
37
42
  onmicclick?: () => void;
38
43
  onvoiceoutputchange?: (on: boolean) => void;
39
44
  onsuggestion?: (text: string) => void;
40
45
  }
41
- declare const AssistantChat: import("svelte").Component<AssistantChatProps, {}, "open" | "draft" | "voiceOutput">;
46
+ declare const AssistantChat: import("svelte").Component<AssistantChatProps, {
47
+ focus: () => void;
48
+ }, "open" | "draft" | "voiceOutput">;
42
49
  type AssistantChat = ReturnType<typeof AssistantChat>;
43
50
  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.4",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",