@r2digisolutions/components 0.15.4 → 0.16.1

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.
@@ -10,6 +10,8 @@
10
10
  align?: PopoverAlign;
11
11
  strategy?: 'fixed' | 'absolute';
12
12
  offset?: number;
13
+ /** Solo ancla/posiciona; sin fondo, borde ni padding (el contenido aporta el chrome). */
14
+ unstyled?: boolean;
13
15
  class?: string;
14
16
  panelClass?: string;
15
17
  trigger?: Snippet;
@@ -23,6 +25,7 @@
23
25
  align = 'center',
24
26
  strategy = 'fixed',
25
27
  offset = 8,
28
+ unstyled = false,
26
29
  class: className = '',
27
30
  panelClass = '',
28
31
  trigger,
@@ -230,7 +233,10 @@
230
233
  ontoggle={onToggle}
231
234
  style={panelStyle}
232
235
  class={[
233
- 'popover-panel m-0 rounded-xl border-border bg-surface-elevated p-3 shadow-xl max-w-[min(20rem,calc(100vw-1rem))] border outline-none',
236
+ 'popover-panel m-0 outline-none',
237
+ unstyled
238
+ ? 'border-0 bg-transparent p-0 shadow-none'
239
+ : 'rounded-xl border border-border bg-surface-elevated p-3 shadow-xl max-w-[min(26rem,calc(100vw-1rem))]',
234
240
  panelClass
235
241
  ]}
236
242
  >
@@ -241,7 +247,10 @@
241
247
  bind:this={panelEl}
242
248
  role="dialog"
243
249
  class={[
244
- 'min-w-48 rounded-xl border-border bg-surface-elevated p-3 shadow-xl absolute z-50 border',
250
+ 'absolute z-50 outline-none',
251
+ unstyled
252
+ ? 'border-0 bg-transparent p-0 shadow-none'
253
+ : 'min-w-48 rounded-xl border border-border bg-surface-elevated p-3 shadow-xl',
245
254
  absolutePos[placement],
246
255
  absoluteAlign[align][placement],
247
256
  panelClass
@@ -7,6 +7,8 @@ interface PopoverProps {
7
7
  align?: PopoverAlign;
8
8
  strategy?: 'fixed' | 'absolute';
9
9
  offset?: number;
10
+ /** Solo ancla/posiciona; sin fondo, borde ni padding (el contenido aporta el chrome). */
11
+ unstyled?: boolean;
10
12
  class?: string;
11
13
  panelClass?: string;
12
14
  trigger?: Snippet;
@@ -0,0 +1,307 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
4
+ import {
5
+ Sparkles,
6
+ X,
7
+ Send,
8
+ Mic,
9
+ MicOff,
10
+ Volume2,
11
+ VolumeX,
12
+ LoaderCircle,
13
+ RotateCcw
14
+ } from '@lucide/svelte';
15
+
16
+ export interface AssistantMessage {
17
+ id: string;
18
+ role: 'user' | 'assistant';
19
+ content: string;
20
+ }
21
+
22
+ interface AssistantChatProps {
23
+ open?: boolean;
24
+ title?: string;
25
+ statusLabel?: string;
26
+ placeholder?: string;
27
+ emptyHint?: string;
28
+ suggestions?: string[];
29
+ messages?: AssistantMessage[];
30
+ draft?: string;
31
+ loading?: boolean;
32
+ error?: string | null;
33
+ voiceEnabled?: boolean;
34
+ voiceOutput?: boolean;
35
+ listening?: boolean;
36
+ newChatLabel?: string;
37
+ closeLabel?: string;
38
+ sendLabel?: string;
39
+ micLabel?: string;
40
+ stopMicLabel?: string;
41
+ voiceOnLabel?: string;
42
+ voiceOffLabel?: string;
43
+ class?: string;
44
+ result?: Snippet<[AssistantMessage]>;
45
+ footerExtra?: Snippet;
46
+ onsend?: () => void;
47
+ onclose?: () => void;
48
+ onnewchat?: () => void;
49
+ onmicclick?: () => void;
50
+ onvoiceoutputchange?: (on: boolean) => void;
51
+ onsuggestion?: (text: string) => void;
52
+ }
53
+
54
+ let {
55
+ open = $bindable(false),
56
+ title = 'Asistente',
57
+ statusLabel,
58
+ placeholder = 'Escribe un mensaje…',
59
+ emptyHint = '¿En qué puedo ayudarte?',
60
+ suggestions = [],
61
+ messages = [],
62
+ draft = $bindable(''),
63
+ loading = false,
64
+ error = null,
65
+ voiceEnabled = true,
66
+ voiceOutput = $bindable(true),
67
+ listening = false,
68
+ newChatLabel = 'Nueva conversación',
69
+ closeLabel = 'Cerrar',
70
+ sendLabel = 'Enviar',
71
+ micLabel = 'Activar micrófono',
72
+ stopMicLabel = 'Detener micrófono',
73
+ voiceOnLabel = 'Voz activada',
74
+ voiceOffLabel = 'Voz desactivada',
75
+ class: className = '',
76
+ result,
77
+ footerExtra,
78
+ onsend,
79
+ onclose,
80
+ onnewchat,
81
+ onmicclick,
82
+ onvoiceoutputchange,
83
+ onsuggestion
84
+ }: AssistantChatProps = $props();
85
+
86
+ let scrollEl = $state<HTMLDivElement | null>(null);
87
+
88
+ const isEmpty = $derived(messages.length === 0 && !loading);
89
+ const canSend = $derived(draft.trim().length > 0 && !loading);
90
+
91
+ $effect(() => {
92
+ void messages.length;
93
+ void loading;
94
+ const el = scrollEl;
95
+ if (!el) return;
96
+ const raf = requestAnimationFrame(() => {
97
+ if (el) el.scrollTop = el.scrollHeight;
98
+ });
99
+ return () => cancelAnimationFrame(raf);
100
+ });
101
+
102
+ function handleClose() {
103
+ open = false;
104
+ onclose?.();
105
+ }
106
+
107
+ function handleSend() {
108
+ if (!canSend) return;
109
+ onsend?.();
110
+ }
111
+
112
+ function handleKeydown(e: KeyboardEvent) {
113
+ if (e.key === 'Enter' && !e.shiftKey) {
114
+ e.preventDefault();
115
+ handleSend();
116
+ }
117
+ }
118
+
119
+ function handleSuggestion(text: string) {
120
+ draft = text;
121
+ onsuggestion?.(text);
122
+ }
123
+
124
+ function toggleVoice() {
125
+ voiceOutput = !voiceOutput;
126
+ onvoiceoutputchange?.(voiceOutput);
127
+ }
128
+ </script>
129
+
130
+ <div
131
+ class={[
132
+ 'flex flex-col overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl',
133
+ 'h-[min(560px,70vh)] w-full min-w-[320px] max-w-[420px]',
134
+ className
135
+ ]}
136
+ role="complementary"
137
+ aria-label={title}
138
+ >
139
+ <!-- Header -->
140
+ <header class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
141
+ <span
142
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400"
143
+ aria-hidden="true"
144
+ >
145
+ <Sparkles size={15} strokeWidth={2} />
146
+ </span>
147
+ <div class="min-w-0 flex-1">
148
+ <p class="truncate text-sm font-semibold text-primary">{title}</p>
149
+ {#if statusLabel}
150
+ <p class="truncate text-xs text-muted">{statusLabel}</p>
151
+ {/if}
152
+ </div>
153
+ <IconButton variant="ghost" size="xs" label={newChatLabel} onclick={() => onnewchat?.()}>
154
+ <RotateCcw size={14} strokeWidth={2} />
155
+ </IconButton>
156
+ <IconButton variant="ghost" size="xs" label={closeLabel} onclick={handleClose}>
157
+ <X size={14} strokeWidth={2} />
158
+ </IconButton>
159
+ </header>
160
+
161
+ <!-- Body -->
162
+ <div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
163
+ {#if isEmpty}
164
+ <div class="flex flex-col items-center justify-center gap-4 py-8 text-center">
165
+ <span
166
+ class="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-500 dark:bg-brand-950/30 dark:text-brand-400"
167
+ aria-hidden="true"
168
+ >
169
+ <Sparkles size={24} strokeWidth={1.5} />
170
+ </span>
171
+ <p class="text-sm text-muted">{emptyHint}</p>
172
+ {#if suggestions.length}
173
+ <div class="flex flex-wrap justify-center gap-2">
174
+ {#each suggestions as suggestion (suggestion)}
175
+ <button
176
+ type="button"
177
+ class="rounded-full border border-border bg-surface px-3 py-1.5 text-xs text-secondary transition-colors hover:border-brand-400 hover:text-brand-600 dark:hover:text-brand-400"
178
+ onclick={() => handleSuggestion(suggestion)}
179
+ >
180
+ {suggestion}
181
+ </button>
182
+ {/each}
183
+ </div>
184
+ {/if}
185
+ </div>
186
+ {:else}
187
+ <div class="flex flex-col gap-3">
188
+ {#each messages as message (message.id)}
189
+ {#if message.role === 'user'}
190
+ <div class="flex justify-end">
191
+ <div
192
+ class="max-w-[80%] rounded-2xl rounded-br-sm bg-brand-600 px-3.5 py-2.5 text-sm leading-relaxed text-white"
193
+ >
194
+ {message.content}
195
+ </div>
196
+ </div>
197
+ {:else}
198
+ <div class="flex flex-col gap-1.5">
199
+ <div class="flex items-start gap-2">
200
+ <span
201
+ class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400"
202
+ aria-hidden="true"
203
+ >
204
+ <Sparkles size={12} strokeWidth={2} />
205
+ </span>
206
+ <div
207
+ class="max-w-[calc(100%-2rem)] rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5 text-sm leading-relaxed text-primary"
208
+ >
209
+ {message.content}
210
+ </div>
211
+ </div>
212
+ {#if result}
213
+ <div class="ml-8">
214
+ {@render result(message)}
215
+ </div>
216
+ {/if}
217
+ </div>
218
+ {/if}
219
+ {/each}
220
+
221
+ {#if loading}
222
+ <div class="flex items-center gap-2">
223
+ <span
224
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-100 text-brand-600 dark:bg-brand-900/40 dark:text-brand-400"
225
+ aria-hidden="true"
226
+ >
227
+ <Sparkles size={12} strokeWidth={2} />
228
+ </span>
229
+ <div
230
+ class="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-surface-overlay px-3.5 py-2.5"
231
+ >
232
+ <LoaderCircle size={14} strokeWidth={2} class="animate-spin text-muted" />
233
+ <span class="text-xs text-muted">Procesando…</span>
234
+ </div>
235
+ </div>
236
+ {/if}
237
+
238
+ {#if error}
239
+ <div
240
+ class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600 dark:border-red-800/40 dark:bg-red-950/20 dark:text-red-400"
241
+ >
242
+ {error}
243
+ </div>
244
+ {/if}
245
+ </div>
246
+ {/if}
247
+ </div>
248
+
249
+ <!-- Footer -->
250
+ <footer class="shrink-0 border-t border-border px-3 py-2.5">
251
+ <div class="flex items-center gap-1.5">
252
+ <input
253
+ type="text"
254
+ bind:value={draft}
255
+ {placeholder}
256
+ class="h-9 min-w-0 flex-1 rounded-xl border border-border bg-surface px-3 text-sm text-primary outline-none transition-shadow placeholder:text-muted focus:border-brand-400 focus:ring-2 focus:ring-brand-500/20"
257
+ disabled={loading}
258
+ onkeydown={handleKeydown}
259
+ aria-label={placeholder}
260
+ />
261
+
262
+ <div class="flex shrink-0 items-center gap-0.5">
263
+ {#if voiceEnabled}
264
+ <IconButton
265
+ variant="ghost"
266
+ size="sm"
267
+ label={listening ? stopMicLabel : micLabel}
268
+ onclick={() => onmicclick?.()}
269
+ class={listening ? 'text-red-500 hover:text-red-600' : ''}
270
+ >
271
+ {#if listening}
272
+ <MicOff size={16} strokeWidth={2} />
273
+ {:else}
274
+ <Mic size={16} strokeWidth={2} />
275
+ {/if}
276
+ </IconButton>
277
+ <IconButton
278
+ variant="ghost"
279
+ size="sm"
280
+ label={voiceOutput ? voiceOnLabel : voiceOffLabel}
281
+ onclick={toggleVoice}
282
+ >
283
+ {#if voiceOutput}
284
+ <Volume2 size={16} strokeWidth={2} />
285
+ {:else}
286
+ <VolumeX size={16} strokeWidth={2} />
287
+ {/if}
288
+ </IconButton>
289
+ {/if}
290
+
291
+ <IconButton
292
+ variant={canSend ? 'primary' : 'ghost'}
293
+ size="sm"
294
+ label={sendLabel}
295
+ disabled={!canSend}
296
+ onclick={handleSend}
297
+ >
298
+ <Send size={16} strokeWidth={2} />
299
+ </IconButton>
300
+ </div>
301
+ </div>
302
+
303
+ {#if footerExtra}
304
+ {@render footerExtra()}
305
+ {/if}
306
+ </footer>
307
+ </div>
@@ -0,0 +1,40 @@
1
+ import type { Snippet } from 'svelte';
2
+ export interface AssistantMessage {
3
+ id: string;
4
+ role: 'user' | 'assistant';
5
+ content: string;
6
+ }
7
+ interface AssistantChatProps {
8
+ open?: boolean;
9
+ title?: string;
10
+ statusLabel?: string;
11
+ placeholder?: string;
12
+ emptyHint?: string;
13
+ suggestions?: string[];
14
+ messages?: AssistantMessage[];
15
+ draft?: string;
16
+ loading?: boolean;
17
+ error?: string | null;
18
+ voiceEnabled?: boolean;
19
+ voiceOutput?: boolean;
20
+ listening?: boolean;
21
+ newChatLabel?: string;
22
+ closeLabel?: string;
23
+ sendLabel?: string;
24
+ micLabel?: string;
25
+ stopMicLabel?: string;
26
+ voiceOnLabel?: string;
27
+ voiceOffLabel?: string;
28
+ class?: string;
29
+ result?: Snippet<[AssistantMessage]>;
30
+ footerExtra?: Snippet;
31
+ onsend?: () => void;
32
+ onclose?: () => void;
33
+ onnewchat?: () => void;
34
+ onmicclick?: () => void;
35
+ onvoiceoutputchange?: (on: boolean) => void;
36
+ onsuggestion?: (text: string) => void;
37
+ }
38
+ declare const AssistantChat: import("svelte").Component<AssistantChatProps, {}, "open" | "draft" | "voiceOutput">;
39
+ type AssistantChat = ReturnType<typeof AssistantChat>;
40
+ export default AssistantChat;
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import IconButton from '../../atoms/IconButton/IconButton.svelte';
3
+ import { Sparkles } from '@lucide/svelte';
4
+
5
+ interface AssistantFabProps {
6
+ label?: string;
7
+ onclick?: () => void;
8
+ class?: string;
9
+ }
10
+
11
+ let {
12
+ label = 'Abrir asistente',
13
+ onclick,
14
+ class: className = ''
15
+ }: AssistantFabProps = $props();
16
+ </script>
17
+
18
+ <IconButton
19
+ variant="primary"
20
+ size="xl"
21
+ rounded
22
+ {label}
23
+ onclick={() => onclick?.()}
24
+ class={['shadow-lg shadow-brand-500/30', className].filter(Boolean).join(' ')}
25
+ >
26
+ <Sparkles size={22} strokeWidth={2} />
27
+ </IconButton>
@@ -0,0 +1,8 @@
1
+ interface AssistantFabProps {
2
+ label?: string;
3
+ onclick?: () => void;
4
+ class?: string;
5
+ }
6
+ declare const AssistantFab: import("svelte").Component<AssistantFabProps, {}, "">;
7
+ type AssistantFab = ReturnType<typeof AssistantFab>;
8
+ export default AssistantFab;
package/dist/index.d.ts CHANGED
@@ -698,6 +698,9 @@ export { default as RegisterForm } from './components/organisms/RegisterForm/Reg
698
698
  export { default as ForgotPasswordForm } from './components/organisms/ForgotPasswordForm/ForgotPasswordForm.svelte';
699
699
  export { default as OtpVerify } from './components/organisms/OtpVerify/OtpVerify.svelte';
700
700
  export { default as ChatWindow } from './components/organisms/ChatWindow/ChatWindow.svelte';
701
+ export { default as AssistantChat } from './components/organisms/AssistantChat/AssistantChat.svelte';
702
+ export type { AssistantMessage } from './components/organisms/AssistantChat/AssistantChat.svelte';
703
+ export { default as AssistantFab } from './components/organisms/AssistantFab/AssistantFab.svelte';
701
704
  export { default as CommentThread } from './components/organisms/CommentThread/CommentThread.svelte';
702
705
  export type { CommentItem } from './components/organisms/CommentThread/CommentThread.svelte';
703
706
  export { default as ActivityFeed } from './components/organisms/ActivityFeed/ActivityFeed.svelte';
package/dist/index.js CHANGED
@@ -483,6 +483,8 @@ export { default as RegisterForm } from './components/organisms/RegisterForm/Reg
483
483
  export { default as ForgotPasswordForm } from './components/organisms/ForgotPasswordForm/ForgotPasswordForm.svelte';
484
484
  export { default as OtpVerify } from './components/organisms/OtpVerify/OtpVerify.svelte';
485
485
  export { default as ChatWindow } from './components/organisms/ChatWindow/ChatWindow.svelte';
486
+ export { default as AssistantChat } from './components/organisms/AssistantChat/AssistantChat.svelte';
487
+ export { default as AssistantFab } from './components/organisms/AssistantFab/AssistantFab.svelte';
486
488
  export { default as CommentThread } from './components/organisms/CommentThread/CommentThread.svelte';
487
489
  export { default as ActivityFeed } from './components/organisms/ActivityFeed/ActivityFeed.svelte';
488
490
  export { default as SettingsLayout } from './components/organisms/SettingsLayout/SettingsLayout.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.15.4",
3
+ "version": "0.16.1",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",