@r2digisolutions/components 0.17.1 → 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;
@@ -4,7 +4,12 @@ const meta = {
4
4
  component: DashboardGridStory,
5
5
  tags: ['autodocs'],
6
6
  parameters: {
7
- layout: 'padded'
7
+ layout: 'padded',
8
+ docs: {
9
+ description: {
10
+ component: 'Responsive dashboard grid. Use `stackBelow` (default `960`, or `false` to disable) to collapse into a single-column reading-order stack when the **grid container** is narrow (ResizeObserver; sidebar-aware). Drag/resize are disabled while stacked.'
11
+ }
12
+ }
8
13
  },
9
14
  argTypes: {
10
15
  example: {
@@ -89,6 +89,12 @@
89
89
  onreset?: () => void;
90
90
  /** Label shown in the drop placeholder while dragging */
91
91
  dropLabel?: string;
92
+ /**
93
+ * Container width (px) below which the grid stacks into a single column.
94
+ * Measured on the grid itself (sidebar-aware). Pass `false` to disable.
95
+ * Default `960`.
96
+ */
97
+ stackBelow?: number | false;
92
98
  }
93
99
 
94
100
  let {
@@ -118,13 +124,15 @@
118
124
  onremove,
119
125
  onadd,
120
126
  onreset,
121
- dropLabel = 'Drop here'
127
+ dropLabel = 'Drop here',
128
+ stackBelow = 960
122
129
  }: DashboardGridProps = $props();
123
130
 
124
131
  const DRAG_THRESHOLD = 6;
125
132
 
126
133
  let containerEl: HTMLDivElement | null = null;
127
134
  let ghostEl: HTMLDivElement | null = null;
135
+ let stacked = $state(false);
128
136
  let draggingId = $state<string | null>(null);
129
137
  let resizingId = $state<string | null>(null);
130
138
  let resizeEdge = $state<WidgetResizeEdge>('se');
@@ -158,6 +166,11 @@
158
166
 
159
167
  const contentRows = $derived(layoutBounds(layout).rows);
160
168
  const rows = $derived(Math.max(contentRows + (editable ? padRows : 0), minRows));
169
+ const displayLayout = $derived.by((): GridItem[] => {
170
+ if (!stacked) return layout;
171
+ return [...layout].sort((a, b) => a.y - b.y || a.x - b.x);
172
+ });
173
+ const interactionsEnabled = $derived(!stacked);
161
174
  const draggingItem = $derived(layout.find((it) => it.id === draggingId) ?? null);
162
175
  const snapPreview = $derived.by((): GridItem | null => {
163
176
  if (!draggingItem || !dragMoved) return null;
@@ -276,9 +289,36 @@
276
289
  };
277
290
  }
278
291
 
292
+ function syncStacked(width: number) {
293
+ if (stackBelow === false) {
294
+ if (stacked) {
295
+ resetInteraction();
296
+ stacked = false;
297
+ }
298
+ return;
299
+ }
300
+ const next = width < stackBelow;
301
+ if (next === stacked) return;
302
+ if (next) resetInteraction();
303
+ stacked = next;
304
+ }
305
+
279
306
  function attachContainer(node: HTMLElement) {
280
307
  containerEl = node as HTMLDivElement;
308
+ if (typeof ResizeObserver === 'undefined') {
309
+ syncStacked(node.clientWidth);
310
+ return () => {
311
+ if (containerEl === node) containerEl = null;
312
+ };
313
+ }
314
+ const ro = new ResizeObserver((entries) => {
315
+ const w = entries[0]?.contentRect.width ?? node.clientWidth;
316
+ syncStacked(w);
317
+ });
318
+ ro.observe(node);
319
+ syncStacked(node.clientWidth);
281
320
  return () => {
321
+ ro.disconnect();
282
322
  if (containerEl === node) containerEl = null;
283
323
  };
284
324
  }
@@ -300,11 +340,12 @@
300
340
  const target = e.target as HTMLElement | null;
301
341
  if (target?.closest('button, a, [data-resize-handle], [data-widget-toolbar]')) return;
302
342
  onselect?.(id);
303
- if (draggable) onDragStart(id, e);
343
+ if (!interactionsEnabled || !draggable) return;
344
+ onDragStart(id, e);
304
345
  }
305
346
 
306
347
  function onDragStart(id: string, e: PointerEvent) {
307
- if (!editable || !draggable) return;
348
+ if (!editable || !draggable || !interactionsEnabled) return;
308
349
  e.preventDefault();
309
350
  e.stopPropagation();
310
351
  const item = layout.find((it) => it.id === id);
@@ -341,7 +382,7 @@
341
382
  }
342
383
 
343
384
  function onResizeStart(id: string, e: PointerEvent, edge: WidgetResizeEdge) {
344
- if (!editable) return;
385
+ if (!editable || !interactionsEnabled) return;
345
386
  e.preventDefault();
346
387
  e.stopPropagation();
347
388
  const item = layout.find((it) => it.id === id);
@@ -440,6 +481,11 @@
440
481
  }
441
482
 
442
483
  function styleFor(item: GridItem): string {
484
+ if (stacked) {
485
+ // Prefer content height; keep a floor from the saved desktop span.
486
+ const minH = Math.max(item.h * rowHeight, 160);
487
+ return `width:100%;min-height:${minH}px;height:auto;`;
488
+ }
443
489
  const x = item.x + 1;
444
490
  const y = item.y + 1;
445
491
  return `grid-column: ${x} / span ${item.w}; grid-row: ${y} / span ${item.h};`;
@@ -455,10 +501,26 @@
455
501
  }
456
502
 
457
503
  const gridStyle = $derived(
458
- `display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));grid-auto-rows:${rowHeight}px;gap:${gap}px;min-height:${rows * rowHeight + Math.max(0, rows - 1) * gap}px;`
504
+ stacked
505
+ ? `display:flex;flex-direction:column;gap:${gap}px;`
506
+ : `display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));grid-auto-rows:${rowHeight}px;gap:${gap}px;min-height:${rows * rowHeight + Math.max(0, rows - 1) * gap}px;`
459
507
  );
460
508
 
461
509
  const guideRows = $derived(Math.max(rows, minRows));
510
+ const showGuides = $derived(editable && showGrid && !stacked);
511
+ const containerOverflowClass = $derived.by(() => {
512
+ if (stacked) {
513
+ return editable && showGrid ? 'pt-3 overflow-visible' : 'overflow-visible';
514
+ }
515
+ return editable ? 'pt-3 overflow-visible' : 'overflow-hidden';
516
+ });
517
+
518
+ $effect(() => {
519
+ // Re-evaluate when the breakpoint prop changes (ResizeObserver handles width).
520
+ void stackBelow;
521
+ if (containerEl) syncStacked(containerEl.clientWidth);
522
+ else if (stackBelow === false) syncStacked(0);
523
+ });
462
524
 
463
525
  onDestroy(() => {
464
526
  resetInteraction();
@@ -497,13 +559,13 @@
497
559
  {@attach attachContainer}
498
560
  class={[
499
561
  'rounded-xl relative w-full',
500
- editable ? 'pt-3 overflow-visible' : 'overflow-hidden',
501
- editable && showGrid && 'ring-border ring-1',
502
- editable && 'select-none'
562
+ containerOverflowClass,
563
+ showGuides && 'ring-border ring-1',
564
+ editable && !stacked && 'select-none'
503
565
  ]}
504
566
  style={gridStyle}
505
567
  >
506
- {#if editable && showGrid}
568
+ {#if showGuides}
507
569
  {#each Array.from({ length: cols * guideRows }, (_, i) => i) as cell (cell)}
508
570
  {@const cx = cell % cols}
509
571
  {@const cy = Math.floor(cell / cols)}
@@ -516,7 +578,7 @@
516
578
  {/each}
517
579
  {/if}
518
580
 
519
- {#if draggable && originPreview}
581
+ {#if interactionsEnabled && draggable && originPreview}
520
582
  <div
521
583
  class="pointer-events-none z-[2] rounded-xl border-2 border-dashed border-muted/60 bg-muted/10"
522
584
  style={styleFor(originPreview)}
@@ -524,7 +586,7 @@
524
586
  ></div>
525
587
  {/if}
526
588
 
527
- {#if draggable && snapPreview}
589
+ {#if interactionsEnabled && draggable && snapPreview}
528
590
  <div
529
591
  class="pointer-events-none z-[3] rounded-xl border-2 border-dashed border-brand-500 bg-brand-500/20 shadow-[inset_0_0_0_1px_rgba(var(--color-brand-500),0.25)]"
530
592
  style={styleFor(snapPreview)}
@@ -538,16 +600,18 @@
538
600
  </div>
539
601
  {/if}
540
602
 
541
- {#each layout as item (item.id)}
603
+ {#each displayLayout as item (item.id)}
542
604
  {@const meta = widgetMeta(item)}
543
605
  {@const clamped = clampItem(item, cols)}
544
606
  {@const selected = selectedId === item.id}
607
+ {@const canDrag = editable && interactionsEnabled && draggable && !item.static}
608
+ {@const canResize = editable && interactionsEnabled && !item.static}
545
609
  <!-- svelte-ignore a11y_no_static_element_interactions -->
546
610
  <div
547
611
  data-grid-id={item.id}
548
612
  class={[
549
613
  'min-h-0 min-w-0 relative z-[1] flex flex-col',
550
- editable && draggable && !item.static && 'cursor-grab',
614
+ canDrag && 'cursor-grab',
551
615
  (draggingId === item.id || resizingId === item.id) && 'z-20',
552
616
  draggingId === item.id && dragMoved && 'pointer-events-none opacity-30',
553
617
  selected && !dragMoved && 'ring-brand-500 ring-offset-surface ring-2 ring-offset-2'
@@ -596,13 +660,13 @@
596
660
  flush={meta.flush ?? false}
597
661
  collapsible={meta.collapsible ?? false}
598
662
  {editable}
599
- draggable={editable && draggable && !item.static}
600
- resizable={editable && !item.static}
601
- resizeEdges={editable ? (['s', 'e', 'se'] as const) : undefined}
663
+ draggable={canDrag}
664
+ resizable={canResize}
665
+ resizeEdges={canResize ? (['s', 'e', 'se'] as const) : undefined}
602
666
  loading={meta.loading}
603
667
  empty={meta.empty}
604
668
  onreload={meta.onReload}
605
- class="h-full w-full"
669
+ class={stacked ? 'min-h-full w-full' : 'h-full w-full'}
606
670
  ondragstart={(e) => onDragStart(item.id, e)}
607
671
  onresizestart={(e, edge) => onResizeStart(item.id, e, edge)}
608
672
  >
@@ -633,7 +697,7 @@
633
697
  {/if}
634
698
  </div>
635
699
 
636
- {#if draggable && ghostBox}
700
+ {#if interactionsEnabled && draggable && ghostBox}
637
701
  <div
638
702
  {@attach attachGhost}
639
703
  class="pointer-events-none fixed z-[500]"
@@ -61,6 +61,12 @@ interface DashboardGridProps {
61
61
  onreset?: () => void;
62
62
  /** Label shown in the drop placeholder while dragging */
63
63
  dropLabel?: string;
64
+ /**
65
+ * Container width (px) below which the grid stacks into a single column.
66
+ * Measured on the grid itself (sidebar-aware). Pass `false` to disable.
67
+ * Default `960`.
68
+ */
69
+ stackBelow?: number | false;
64
70
  }
65
71
  declare const DashboardGrid: import("svelte").Component<DashboardGridProps, {
66
72
  GridItem: typeof GridItem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.17.1",
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",