@r2digisolutions/ui 0.32.3 → 0.33.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.
@@ -345,6 +345,7 @@
345
345
 
346
346
  <div class="group relative">
347
347
  <!-- Fila principal -->
348
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
348
349
  <div
349
350
  role="row"
350
351
  tabindex="0"
@@ -377,7 +378,7 @@
377
378
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
378
379
  {@const sticky = stickyOffsets[col.id as keyof T]}
379
380
  <div
380
- class={`flex items-center border-r border-neutral-200/60 px-3 ${
381
+ class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50${
381
382
  density === 'compact' ? 'py-1.5' : 'py-2.5'
382
383
  } dark:border-neutral-800/70 ${
383
384
  col.sticky === 'left'
@@ -397,7 +398,7 @@
397
398
  })}
398
399
  {:else}
399
400
  <span
400
- class={`line-clamp-2 ${
401
+ class={`line-clamp-2 text-black dark:text-neutral-50 ${
401
402
  col.align === 'right'
402
403
  ? 'ml-auto text-right'
403
404
  : col.align === 'center'
@@ -500,6 +501,7 @@
500
501
  : (row as any)[firstCol.id]
501
502
  : null}
502
503
  {@const restCols = cols.slice(1)}
504
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
503
505
  <div
504
506
  class={`group relative rounded-2xl border border-neutral-200/80 bg-white/80 p-3 text-[11px] text-neutral-800 shadow-sm ring-0 transition-all hover:border-purple-400/70 hover:shadow-md dark:border-neutral-800/80 dark:bg-neutral-900/80 dark:text-neutral-50 ${
505
507
  controller.selectedIds.has(id)
@@ -522,7 +524,7 @@
522
524
  </div>
523
525
  {/if}
524
526
 
525
- <div class="mb-2 pr-6">
527
+ <div class="mb-2 pr-6 text-black dark:text-neutral-50">
526
528
  {#if cell && firstCol}
527
529
  {@render cell({
528
530
  row,
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Option, Props } from './type.js';
3
- import { BoxSelect, Check, ChevronDown, Save, Search, X, Plus } from 'lucide-svelte';
3
+ import { BoxSelect, Check, ChevronDown, Save, Search, X, Plus, ChevronLeft } from 'lucide-svelte';
4
4
  import { SvelteMap } from 'svelte/reactivity';
5
5
  import Tag from '../../ui/Tag/Tag.svelte';
6
6
  import Dialog from '../../ui/Dialog/Dialog.svelte';
@@ -25,8 +25,20 @@
25
25
  multiple = true,
26
26
  onConfirm,
27
27
  onCancel,
28
+ onValueChange, // ✅ NUEVO (para padre “controlado” sin bind)
28
29
  required,
29
30
  errors = [],
31
+ item,
32
+
33
+ tree = false,
34
+ childrenKey = 'children',
35
+ selectParents = false,
36
+ showPathInSearch = true,
37
+
38
+ // UX opcional: si single y quieres cerrar al elegir
39
+ closeOnPick = false,
40
+
41
+ debug = false,
30
42
  ...props
31
43
  }: Props = $props();
32
44
 
@@ -36,39 +48,187 @@
36
48
  let selected_items = $state(new SvelteMap<string, Option>());
37
49
  let pending_selection = $state(new SvelteMap<string, Option>());
38
50
 
51
+ let navStack = $state<string[]>([]);
52
+
53
+ const keyOf = (v: unknown) => String(v ?? '').trim();
54
+ const getChildren = (opt: Option): Option[] => ((opt as any)?.[childrenKey] as Option[]) ?? [];
55
+ const hasChildren = (opt: Option) => getChildren(opt)?.length > 0;
56
+
57
+ function log(...args: any[]) {
58
+ if (!debug) return;
59
+ const safe = args.map((a) => {
60
+ try {
61
+ return $state.snapshot(a);
62
+ } catch {
63
+ return a;
64
+ }
65
+ });
66
+ console.log(`[Select:${name}]`, ...safe);
67
+ }
68
+
69
+ // ===== indexado árbol
70
+ function flattenTree(
71
+ nodes: Option[],
72
+ parentValue: string | null,
73
+ out: Option[] = [],
74
+ parentBy = new Map<string, string | null>(),
75
+ childrenBy = new Map<string, string[]>(),
76
+ byValue = new Map<string, Option>()
77
+ ) {
78
+ for (const n of nodes ?? []) {
79
+ const k = keyOf((n as any)?.value);
80
+ const node: Option = { ...(n as any), value: k };
81
+
82
+ byValue.set(k, node);
83
+ parentBy.set(k, parentValue);
84
+ out.push(node);
85
+
86
+ const children = getChildren(n);
87
+ if (children?.length) {
88
+ childrenBy.set(
89
+ k,
90
+ children.map((c) => keyOf((c as any)?.value))
91
+ );
92
+ flattenTree(children, k, out, parentBy, childrenBy, byValue);
93
+ }
94
+ }
95
+ return { out, parentBy, childrenBy, byValue };
96
+ }
97
+
98
+ const treeIndex = $derived.by(() => {
99
+ const normalized = (options ?? []).map((o) => ({
100
+ ...(o as any),
101
+ value: keyOf((o as any).value)
102
+ }));
103
+ return flattenTree(normalized, null);
104
+ });
105
+
106
+ const flatOptions = $derived.by(() => treeIndex.out);
107
+ const byValue = $derived.by(() => treeIndex.byValue);
108
+ const parentByValue = $derived.by(() => treeIndex.parentBy);
109
+ const childrenByValue = $derived.by(() => treeIndex.childrenBy);
110
+
111
+ function canonical(opt: Option) {
112
+ const k = keyOf(opt?.value);
113
+ return byValue.get(k) ?? { ...(opt as any), value: k };
114
+ }
115
+
116
+ function getPathValues(v: string) {
117
+ const path: string[] = [];
118
+ let cur: string | null | undefined = v;
119
+ while (cur) {
120
+ path.push(cur);
121
+ cur = parentByValue.get(cur) ?? null;
122
+ }
123
+ return path.reverse();
124
+ }
125
+
126
+ function getPathLabel(v: string) {
127
+ const parts = getPathValues(v)
128
+ .map((pv) => byValue.get(pv)?.label)
129
+ .filter(Boolean) as string[];
130
+ return parts.join(' › ');
131
+ }
132
+
133
+ const pendingKeys = $derived.by(() => {
134
+ const s = new Set<string>();
135
+ for (const k of pending_selection.keys()) s.add(keyOf(k));
136
+ return s;
137
+ });
138
+
139
+ // ===== IMPORTANTE:
140
+ // Solo sincronizamos selected_items con value externo (edit inicial / controlado)
39
141
  $effect(() => {
40
- selected_items = new SvelteMap((value ?? []).map((v) => [v.value, v] as [string, Option]));
142
+ const next = new SvelteMap<string, Option>();
143
+ for (const v of value ?? []) {
144
+ const ov =
145
+ v && typeof v === 'object'
146
+ ? (v as Option)
147
+ : ({ label: String(v), value: String(v) } as Option);
148
+ const c = canonical(ov);
149
+ next.set(keyOf(c.value), c);
150
+ }
151
+ selected_items = next;
41
152
  });
42
153
 
154
+ function firstKey(map: SvelteMap<string, Option>) {
155
+ for (const k of map.keys()) return k;
156
+ return null;
157
+ }
158
+
159
+ function computeNavStackForSelection(map: SvelteMap<string, Option>) {
160
+ if (!tree) return [];
161
+ if (!map.size) return [];
162
+
163
+ const k = firstKey(map);
164
+ if (!k) return [];
165
+
166
+ const kk = keyOf(k);
167
+ const selectedOpt = byValue.get(kk);
168
+
169
+ // si no existe en index, no forzamos nav
170
+ if (!selectedOpt) return [];
171
+
172
+ const path = getPathValues(kk);
173
+
174
+ // si tiene hijos, abrir dentro del seleccionado
175
+ if (hasChildren(selectedOpt)) return path;
176
+
177
+ // si es leaf, abrir en su padre
178
+ return path.slice(0, -1);
179
+ }
180
+
181
+ function rebuildMapFromArray(arr: Option[] | undefined) {
182
+ const next = new SvelteMap<string, Option>();
183
+ for (const v of arr ?? []) {
184
+ const c = canonical(v);
185
+ next.set(keyOf(c.value), c);
186
+ }
187
+ return next;
188
+ }
189
+
43
190
  function openDialog() {
44
- pending_selection = new SvelteMap(selected_items);
45
191
  search = '';
192
+
193
+ // Abrimos con lo que nos pasa el padre (value) si existe,
194
+ // si no, con lo último confirmado internamente (selected_items).
195
+ const base = value?.length ? rebuildMapFromArray(value) : new SvelteMap(selected_items);
196
+ pending_selection = base;
197
+ navStack = computeNavStackForSelection(base);
198
+
46
199
  open = true;
47
- }
48
200
 
49
- function toggleItem(item: Option) {
50
- if (!multiple) {
51
- pending_selection = new SvelteMap([[item.value, item]]);
52
- confirmSelection();
53
- return;
201
+ if (debug) {
202
+ const k = firstKey(base);
203
+ log('OPEN', {
204
+ valueIn: value,
205
+ pendingKeys: [...base.keys()],
206
+ selectedKey: k,
207
+ path: k ? getPathValues(keyOf(k)) : [],
208
+ navStack,
209
+ flatTotal: flatOptions.length,
210
+ byValueSize: byValue.size
211
+ });
54
212
  }
213
+ }
55
214
 
56
- const next = new SvelteMap(pending_selection);
215
+ function commitSelection(next: SvelteMap<string, Option>) {
216
+ const arr = [...next.values()];
57
217
 
58
- if (next.has(item.value)) {
59
- next.delete(item.value);
60
- } else {
61
- next.set(item.value, item);
62
- }
218
+ // 1) actualiza el bind interno (si el padre usa bind, perfecto)
219
+ value = arr;
63
220
 
64
- pending_selection = next;
221
+ // 2) guarda interno
222
+ selected_items = new SvelteMap(next);
223
+
224
+ // 3) callback para padre “controlado” (tu caso real)
225
+ onValueChange?.(arr);
65
226
  }
66
227
 
67
228
  function confirmSelection() {
68
- value = [...pending_selection.values()];
69
- selected_items = new SvelteMap(pending_selection);
229
+ commitSelection(pending_selection);
70
230
  open = false;
71
- onConfirm?.(value);
231
+ onConfirm?.([...pending_selection.values()]);
72
232
  }
73
233
 
74
234
  function cancelSelection() {
@@ -78,16 +238,59 @@
78
238
  }
79
239
 
80
240
  function removeSelected(v: string) {
241
+ const k = keyOf(v);
81
242
  const next = new SvelteMap(selected_items);
82
- next.delete(v);
243
+ next.delete(k);
244
+
83
245
  selected_items = next;
84
246
  value = [...selected_items.values()];
247
+ onValueChange?.([...selected_items.values()]);
248
+ }
249
+
250
+ function enterItem(opt: Option) {
251
+ navStack = [...navStack, keyOf(opt.value)];
85
252
  }
86
253
 
254
+ function goBack() {
255
+ navStack = navStack.slice(0, -1);
256
+ }
257
+
258
+ const currentLevelOptions = $derived.by(() => {
259
+ if (!tree) return (options ?? []).map(canonical);
260
+
261
+ const currentParent = navStack.length ? navStack[navStack.length - 1] : null;
262
+
263
+ if (!currentParent) {
264
+ return flatOptions
265
+ .filter((o) => (parentByValue.get(keyOf(o.value)) ?? null) === null)
266
+ .map(canonical);
267
+ }
268
+
269
+ const childValues = childrenByValue.get(currentParent) ?? [];
270
+ return childValues
271
+ .map((v) => byValue.get(v))
272
+ .filter(Boolean)
273
+ .map(canonical) as Option[];
274
+ });
275
+
87
276
  const filtered_options = $derived.by(() => {
88
- if (!search) return options;
277
+ if (tree && search) {
278
+ const term = search.toLowerCase();
279
+ return flatOptions.map(canonical).filter((opt) => {
280
+ const labelHit = opt.label.toLowerCase().includes(term);
281
+ const descHit = (opt.description ?? '').toLowerCase().includes(term);
282
+ const pathHit = showPathInSearch
283
+ ? getPathLabel(keyOf(opt.value)).toLowerCase().includes(term)
284
+ : false;
285
+ return labelHit || descHit || pathHit;
286
+ });
287
+ }
288
+
289
+ if (tree && !search) return currentLevelOptions;
290
+
291
+ if (!search) return (options ?? []).map(canonical);
89
292
  const term = search.toLowerCase();
90
- return options.filter((option) => option.label.toLowerCase().includes(term));
293
+ return (options ?? []).map(canonical).filter((opt) => opt.label.toLowerCase().includes(term));
91
294
  });
92
295
 
93
296
  const single_selected: Option | undefined = $derived.by(() => {
@@ -105,6 +308,54 @@
105
308
  return '';
106
309
  }
107
310
  });
311
+
312
+ function canSelect(opt: Option) {
313
+ if (!tree) return true;
314
+ const has = hasChildren(opt);
315
+ if (has && !selectParents) return false;
316
+ return true;
317
+ }
318
+
319
+ function toggleItem(optRaw: Option) {
320
+ const opt = canonical(optRaw);
321
+ const k = keyOf(opt.value);
322
+
323
+ const selectable = canSelect(opt);
324
+ const optHasChildren = tree && hasChildren(opt);
325
+
326
+ if (!selectable) {
327
+ if (tree && !search && optHasChildren) enterItem(opt);
328
+ return;
329
+ }
330
+
331
+ // single
332
+ if (!multiple) {
333
+ const next = new SvelteMap<string, Option>([[k, opt]]);
334
+ pending_selection = next;
335
+
336
+ // abrir dentro si tiene hijos
337
+ navStack = computeNavStackForSelection(next);
338
+
339
+ // opcional: commit inmediato
340
+ if (closeOnPick) {
341
+ commitSelection(next);
342
+ open = false;
343
+ onConfirm?.([...next.values()]);
344
+ }
345
+
346
+ return;
347
+ }
348
+
349
+ // multi
350
+ const next = new SvelteMap(pending_selection);
351
+ if (next.has(k)) next.delete(k);
352
+ else next.set(k, opt);
353
+ pending_selection = next;
354
+ }
355
+
356
+ function renderLabelLine(opt: Option) {
357
+ return opt.label;
358
+ }
108
359
  </script>
109
360
 
110
361
  <div class={['flex w-full flex-col gap-2', props.parentClass].join(' ')}>
@@ -151,15 +402,15 @@
151
402
 
152
403
  {#if multiple}
153
404
  <div class="flex flex-wrap gap-1.5">
154
- {#each Array.from(selected_items.values()) as item, index (item.value)}
155
- <input type="hidden" name="{name}[{index}]" value={item.value} />
405
+ {#each Array.from(selected_items.values()) as sel, index (sel.value)}
406
+ <input type="hidden" name="{name}[{index}]" value={sel.value} />
156
407
  <Tag
157
- onclose={() => removeSelected(item.value)}
408
+ onclose={() => removeSelected(sel.value)}
158
409
  variant="solid"
159
410
  color="indigo"
160
411
  class="rounded-full bg-indigo-500/10 text-[11px] text-indigo-700 ring-1 ring-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-200"
161
412
  >
162
- {item.label}
413
+ {sel.label}
163
414
  </Tag>
164
415
  {/each}
165
416
  </div>
@@ -190,28 +441,42 @@
190
441
  <Search class="h-4 w-4 text-neutral-400" />
191
442
  </div>
192
443
  </div>
193
- {#if multiple}
444
+
445
+ {#if tree && !search}
194
446
  <div
195
- class="flex items-center justify-between text-[11px] text-neutral-500 dark:text-neutral-400"
447
+ class="flex items-center justify-between gap-2 text-[11px] text-neutral-500 dark:text-neutral-400"
196
448
  >
197
- <span>
198
- Seleccionados:
199
- <span class="ml-1 font-semibold text-neutral-700 dark:text-neutral-200">
200
- {pending_selection.size}
201
- </span>
202
- {#if options.length}
203
- <span class="ml-1 text-neutral-400">/ {options.length}</span>
449
+ <div class="flex min-w-0 items-center gap-2">
450
+ {#if navStack.length > 0}
451
+ <button
452
+ type="button"
453
+ class="inline-flex items-center gap-1 rounded-full px-2 py-[2px] hover:bg-neutral-100 dark:hover:bg-neutral-800"
454
+ onclick={goBack}
455
+ >
456
+ <ChevronLeft class="h-3.5 w-3.5" />
457
+ Atrás
458
+ </button>
204
459
  {/if}
205
- </span>
206
- {#if pending_selection.size > 0}
207
- <button
208
- type="button"
209
- class="inline-flex items-center gap-1 rounded-full px-2 py-[2px] text-[11px] text-neutral-500 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
210
- onclick={() => (pending_selection = new SvelteMap())}
211
- >
212
- <X class="h-3 w-3" />
213
- Limpiar
214
- </button>
460
+
461
+ <span class="truncate">
462
+ {#if navStack.length === 0}
463
+ Raíz
464
+ {:else}
465
+ {getPathLabel(navStack[navStack.length - 1])}
466
+ {/if}
467
+ </span>
468
+ </div>
469
+
470
+ {#if multiple}
471
+ <span>
472
+ Seleccionados:
473
+ <span class="ml-1 font-semibold text-neutral-700 dark:text-neutral-200"
474
+ >{pending_selection.size}</span
475
+ >
476
+ <span class="ml-1 text-neutral-400"
477
+ >/ {tree ? flatOptions.length : options.length}</span
478
+ >
479
+ </span>
215
480
  {/if}
216
481
  </div>
217
482
  {/if}
@@ -219,71 +484,132 @@
219
484
  </DialogHeader>
220
485
 
221
486
  <DialogContent class="max-h-[70dvh] gap-1 py-2">
222
- {#each filtered_options as item (item.value)}
487
+ {#each filtered_options as opt (opt.value)}
488
+ {@const k = keyOf(opt.value)}
489
+ {@const optHasChildren = tree && hasChildren(opt)}
490
+ {@const selected = pendingKeys.has(k)}
491
+ {@const selectable = canSelect(opt)}
492
+
223
493
  {#if multiple}
224
- <label class="flex flex-row gap-1">
225
- <Checkbox
494
+ <label class="block">
495
+ <div
226
496
  class={[
227
- 'group flex items-center gap-3 rounded-2xl border px-3.5 py-2.5 text-sm transition-all',
228
- pending_selection.has(item.value)
497
+ 'group flex w-full items-start gap-3 rounded-2xl border px-3.5 py-2.5 text-sm transition-all',
498
+ selected
229
499
  ? 'border-indigo-500/70 bg-indigo-500/8 shadow-sm shadow-indigo-500/20 dark:border-indigo-400/70 dark:bg-indigo-500/10'
230
- : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800'
231
- ]}
232
- value={item.value}
233
- label={item.label}
234
- checked={pending_selection.has(item.value)}
235
- onchange={() => toggleItem(item)}
236
- />
237
- <div class="flex flex-col">
238
- <span>{item.label}</span>
239
- {#if item.description}
240
- <span
241
- class="block text-[11px] text-neutral-500 group-hover:text-neutral-600 dark:text-neutral-400 dark:group-hover:text-neutral-300"
500
+ : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800',
501
+ !selectable ? 'opacity-75' : ''
502
+ ].join(' ')}
503
+ >
504
+ <Checkbox
505
+ class="mt-0.5"
506
+ value={k}
507
+ label=""
508
+ checked={selected}
509
+ onchange={() => toggleItem(opt)}
510
+ />
511
+
512
+ <div class="flex min-w-0 flex-1 flex-col">
513
+ {#if item}
514
+ {@render item(opt)}
515
+ {:else}
516
+ <span
517
+ class="truncate text-[13px] font-medium text-neutral-800 dark:text-neutral-100"
518
+ >
519
+ {renderLabelLine(opt)}
520
+ </span>
521
+
522
+ {#if opt.description}
523
+ <span
524
+ class="truncate text-[11px] text-neutral-500 group-hover:text-neutral-600 dark:text-neutral-400 dark:group-hover:text-neutral-300"
525
+ >
526
+ {opt.description}
527
+ </span>
528
+ {/if}
529
+
530
+ {#if tree && search && showPathInSearch}
531
+ <span class="mt-0.5 truncate text-[11px] text-neutral-400">{getPathLabel(k)}</span
532
+ >
533
+ {/if}
534
+
535
+ {#if tree && !search && optHasChildren}
536
+ <span class="mt-0.5 truncate text-[11px] text-neutral-400"
537
+ >{getChildren(opt).length} subcategorías</span
538
+ >
539
+ {/if}
540
+ {/if}
541
+ </div>
542
+
543
+ {#if tree && optHasChildren && !search}
544
+ <button
545
+ type="button"
546
+ class="mt-0.5 inline-flex items-center gap-1 rounded-full px-2 py-[2px] text-[11px] text-neutral-500 hover:bg-neutral-200/70 dark:text-neutral-300 dark:hover:bg-neutral-800"
547
+ onclick={(e) => {
548
+ e.preventDefault();
549
+ e.stopPropagation();
550
+ enterItem(opt);
551
+ }}
242
552
  >
243
- {item.description}
244
- </span>
553
+ <ChevronDown class="h-3.5 w-3.5 -rotate-90" />
554
+ Ver
555
+ </button>
245
556
  {/if}
246
557
  </div>
247
558
  </label>
248
559
  {:else}
249
560
  <button
250
561
  type="button"
251
- onclick={() => toggleItem(item)}
562
+ onclick={() => toggleItem(opt)}
252
563
  class={[
253
564
  'flex w-full cursor-pointer items-center gap-3 rounded-2xl border px-3.5 py-2.5 text-sm transition-all',
254
- pending_selection.has(item.value)
565
+ selected
255
566
  ? 'border-indigo-500/70 bg-indigo-500/8 shadow-sm shadow-indigo-500/20 dark:border-indigo-400/70 dark:bg-indigo-500/10'
256
- : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800'
567
+ : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800',
568
+ !selectable ? 'opacity-75' : ''
257
569
  ].join(' ')}
258
570
  >
259
- <!-- icono left -->
260
571
  <div
261
572
  class={[
262
573
  'flex h-7 w-7 items-center justify-center rounded-full border text-neutral-500 transition-all',
263
- pending_selection.has(item.value)
574
+ selected
264
575
  ? 'border-transparent bg-linear-to-tr from-indigo-500 via-violet-500 to-blue-500 text-white shadow-sm shadow-indigo-500/40'
265
576
  : 'border-neutral-300 bg-white/90 dark:border-neutral-600 dark:bg-neutral-900/90'
266
577
  ].join(' ')}
267
578
  >
268
- {#if pending_selection.has(item.value)}
579
+ {#if selected}
269
580
  <Check class="h-3.5 w-3.5" />
270
581
  {:else}
271
582
  <Plus class="h-3.5 w-3.5" />
272
583
  {/if}
273
584
  </div>
274
585
 
275
- <div class="flex flex-1 flex-col text-left">
276
- <span class="text-[13px] font-medium text-neutral-800 dark:text-neutral-100">
277
- {item.label}
278
- </span>
279
- {#if item.description}
280
- <span class="text-[11px] text-neutral-500 dark:text-neutral-400">
281
- {item.description}
586
+ <div class="flex min-w-0 flex-1 flex-col text-left">
587
+ {#if item}
588
+ {@render item(opt)}
589
+ {:else}
590
+ <span class="truncate text-[13px] font-medium text-neutral-800 dark:text-neutral-100">
591
+ {renderLabelLine(opt)}
282
592
  </span>
593
+
594
+ {#if opt.description}
595
+ <span class="truncate text-[11px] text-neutral-500 dark:text-neutral-400"
596
+ >{opt.description}</span
597
+ >
598
+ {/if}
599
+
600
+ {#if tree && search && showPathInSearch}
601
+ <span class="mt-0.5 truncate text-[11px] text-neutral-400">{getPathLabel(k)}</span>
602
+ {/if}
603
+
604
+ {#if tree && !search && optHasChildren}
605
+ <span class="mt-0.5 truncate text-[11px] text-neutral-400"
606
+ >{getChildren(opt).length} subcategorías</span
607
+ >
608
+ {/if}
283
609
  {/if}
284
610
  </div>
285
611
 
286
- {#if pending_selection.has(item.value)}
612
+ {#if selected}
287
613
  <div
288
614
  class="inline-flex items-center gap-1 rounded-full bg-neutral-900/90 px-2.5 py-[3px] text-[10px] font-medium text-neutral-50 shadow-sm shadow-black/40 dark:bg-neutral-50/95 dark:text-neutral-900"
289
615
  >
@@ -291,6 +617,21 @@
291
617
  <span>Seleccionado</span>
292
618
  </div>
293
619
  {/if}
620
+
621
+ {#if tree && optHasChildren && !search}
622
+ <button
623
+ type="button"
624
+ class="ml-2 inline-flex items-center gap-1 rounded-full px-2 py-[2px] text-[11px] text-neutral-500 hover:bg-neutral-200/70 dark:text-neutral-300 dark:hover:bg-neutral-800"
625
+ onclick={(e) => {
626
+ e.preventDefault();
627
+ e.stopPropagation();
628
+ enterItem(opt);
629
+ }}
630
+ >
631
+ <ChevronDown class="h-3.5 w-3.5 -rotate-90" />
632
+ Ver
633
+ </button>
634
+ {/if}
294
635
  </button>
295
636
  {/if}
296
637
  {:else}
@@ -304,9 +645,7 @@
304
645
 
305
646
  <DialogFooter class="flex items-center justify-between gap-2">
306
647
  {#if footerMessage}
307
- <div class="text-[11px] text-neutral-500 dark:text-neutral-400">
308
- {footerMessage}
309
- </div>
648
+ <div class="text-[11px] text-neutral-500 dark:text-neutral-400">{footerMessage}</div>
310
649
  {/if}
311
650
 
312
651
  <div class="ml-auto flex gap-2">
@@ -318,9 +657,7 @@
318
657
  <Save class="h-4 w-4" />
319
658
  {i18n.t('common.confirm')}
320
659
  {#if multiple}
321
- <span class="ml-1 text-[11px] tabular-nums">
322
- ({pending_selection.size})
323
- </span>
660
+ <span class="ml-1 text-[11px] tabular-nums">({pending_selection.size})</span>
324
661
  {/if}
325
662
  </Button>
326
663
  </div>
@@ -4,6 +4,7 @@ export interface Option {
4
4
  label: string;
5
5
  value: string;
6
6
  description?: string;
7
+ children?: Option[];
7
8
  [key: string]: any;
8
9
  }
9
10
  export interface Props {
@@ -11,8 +12,14 @@ export interface Props {
11
12
  class?: ClassValue;
12
13
  required?: boolean;
13
14
  multiple?: boolean;
15
+ debug?: boolean;
14
16
  onConfirm?: (selected: Option[]) => void;
15
17
  onCancel?: () => void;
18
+ /**
19
+ * Snippet opcional para personalizar el contenido visual del item.
20
+ * Importante: el componente seguirá controlando el click / navegación / selección,
21
+ * pero tú controlas “qué se pinta” dentro.
22
+ */
16
23
  item?: Snippet<[option: Option]>;
17
24
  options: Option[];
18
25
  value?: Option[];
@@ -20,4 +27,12 @@ export interface Props {
20
27
  name: string;
21
28
  placeholder: string;
22
29
  errors?: string[];
30
+ /** Activa navegación tipo árbol si los items traen children (o la key indicada). */
31
+ tree?: boolean;
32
+ /** Si tu backend usa otra propiedad distinta a "children", cámbiala aquí. */
33
+ childrenKey?: string;
34
+ /** Si true, permite seleccionar padres además de navegar. */
35
+ selectParents?: boolean;
36
+ /** En modo búsqueda (plano), muestra la ruta padre › hijo debajo del item. */
37
+ showPathInSearch?: boolean;
23
38
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.32.3",
3
+ "version": "0.33.1",
4
4
  "private": false,
5
- "packageManager": "bun@1.3.4",
5
+ "packageManager": "bun@1.3.5",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -55,39 +55,39 @@
55
55
  "@storybook/addon-interactions": "8.6.14",
56
56
  "@storybook/addon-svelte-csf": "5.0.10",
57
57
  "@storybook/blocks": "8.6.14",
58
- "@storybook/svelte": "10.1.7",
59
- "@storybook/sveltekit": "10.1.7",
58
+ "@storybook/svelte": "10.1.10",
59
+ "@storybook/sveltekit": "10.1.10",
60
60
  "@storybook/test": "8.6.14",
61
61
  "@sveltejs/adapter-static": "3.0.10",
62
62
  "@sveltejs/kit": "2.49.2",
63
63
  "@sveltejs/package": "2.5.7",
64
64
  "@sveltejs/vite-plugin-svelte": "6.2.1",
65
65
  "@tailwindcss/postcss": "4.1.18",
66
- "@testing-library/svelte": "5.2.9",
67
- "@vitest/browser": "4.0.15",
66
+ "@testing-library/svelte": "5.3.1",
67
+ "@vitest/browser": "4.0.16",
68
68
  "changeset": "0.2.6",
69
- "eslint": "9.39.1",
69
+ "eslint": "9.39.2",
70
70
  "eslint-config-prettier": "10.1.8",
71
71
  "eslint-plugin-svelte": "3.13.1",
72
72
  "globals": "16.5.0",
73
- "jsdom": "27.3.0",
74
- "lucide-svelte": "0.561.0",
73
+ "jsdom": "27.4.0",
74
+ "lucide-svelte": "0.562.0",
75
75
  "prettier": "3.7.4",
76
- "prettier-plugin-svelte": "3.4.0",
76
+ "prettier-plugin-svelte": "3.4.1",
77
77
  "prettier-plugin-tailwindcss": "0.7.2",
78
78
  "publint": "0.3.16",
79
- "storybook": "10.1.7",
80
- "svelte": "5.45.10",
81
- "svelte-check": "4.3.4",
79
+ "storybook": "10.1.10",
80
+ "svelte": "5.46.1",
81
+ "svelte-check": "4.3.5",
82
82
  "tailwindcss": "4.1.18",
83
83
  "typescript": "5.9.3",
84
- "typescript-eslint": "8.49.0",
85
- "vite": "7.2.7",
86
- "vitest": "4.0.15"
84
+ "typescript-eslint": "8.50.1",
85
+ "vite": "7.3.0",
86
+ "vitest": "4.0.16"
87
87
  },
88
88
  "dependencies": {
89
89
  "@tailwindcss/container-queries": "0.1.1",
90
- "@tailwindcss/forms": "0.5.10",
90
+ "@tailwindcss/forms": "0.5.11",
91
91
  "@tailwindcss/typography": "0.5.19"
92
92
  }
93
93
  }