@r2digisolutions/ui 0.28.4 → 0.29.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.
@@ -0,0 +1,329 @@
1
+ <script lang="ts">
2
+ import type { Option, Props } from './type.js';
3
+ import { BoxSelect, Check, ChevronDown, Save, Search, X, Plus } from 'lucide-svelte';
4
+ import { SvelteMap } from 'svelte/reactivity';
5
+ import Tag from '../../ui/Tag/Tag.svelte';
6
+ import Dialog from '../../ui/Dialog/Dialog.svelte';
7
+ import DialogHeader from '../../ui/Dialog/DialogHeader.svelte';
8
+ import DialogTitle from '../../ui/Dialog/DialogTitle.svelte';
9
+ import DialogDescription from '../../ui/Dialog/DialogDescription.svelte';
10
+ import Input from '../../ui/Input/Input.svelte';
11
+ import DialogContent from '../../ui/Dialog/DialogContent.svelte';
12
+ import Checkbox from '../../ui/Checkbox/Checkbox.svelte';
13
+ import DialogFooter from '../../ui/Dialog/DialogFooter.svelte';
14
+ import Button from '../../ui/Button/Button.svelte';
15
+ import { i18n } from '../../../settings/index.js';
16
+ import NoContent from '../../ui/NoContent/NoContent.svelte';
17
+ import Field from '../../ui/Field/Field.svelte';
18
+
19
+ let {
20
+ options,
21
+ value = $bindable(),
22
+ label,
23
+ name,
24
+ placeholder,
25
+ multiple = true,
26
+ onConfirm,
27
+ onCancel,
28
+ required,
29
+ errors = [],
30
+ ...props
31
+ }: Props = $props();
32
+
33
+ let open = $state(false);
34
+ let search = $state('');
35
+
36
+ let selected_items = $state(new SvelteMap<string, Option>());
37
+ let pending_selection = $state(new SvelteMap<string, Option>());
38
+
39
+ $effect(() => {
40
+ selected_items = new SvelteMap((value ?? []).map((v) => [v.value, v] as [string, Option]));
41
+ });
42
+
43
+ function openDialog() {
44
+ pending_selection = new SvelteMap(selected_items);
45
+ search = '';
46
+ open = true;
47
+ }
48
+
49
+ function toggleItem(item: Option) {
50
+ if (!multiple) {
51
+ pending_selection = new SvelteMap([[item.value, item]]);
52
+ confirmSelection();
53
+ return;
54
+ }
55
+
56
+ const next = new SvelteMap(pending_selection);
57
+
58
+ if (next.has(item.value)) {
59
+ next.delete(item.value);
60
+ } else {
61
+ next.set(item.value, item);
62
+ }
63
+
64
+ pending_selection = next;
65
+ }
66
+
67
+ function confirmSelection() {
68
+ value = [...pending_selection.values()];
69
+ selected_items = new SvelteMap(pending_selection);
70
+ open = false;
71
+ onConfirm?.(value);
72
+ }
73
+
74
+ function cancelSelection() {
75
+ pending_selection = new SvelteMap(selected_items);
76
+ open = false;
77
+ onCancel?.();
78
+ }
79
+
80
+ function removeSelected(v: string) {
81
+ const next = new SvelteMap(selected_items);
82
+ next.delete(v);
83
+ selected_items = next;
84
+ value = [...selected_items.values()];
85
+ }
86
+
87
+ const filtered_options = $derived.by(() => {
88
+ if (!search) return options;
89
+ const term = search.toLowerCase();
90
+ return options.filter((option) => option.label.toLowerCase().includes(term));
91
+ });
92
+
93
+ const single_selected: Option | undefined = $derived.by(() => {
94
+ if (!selected_items.size) return;
95
+ return selected_items.values().next().value;
96
+ });
97
+
98
+ const footerMessage = $derived.by(() => {
99
+ if (multiple) {
100
+ if (pending_selection.size === 0) return 'No hay elementos seleccionados.';
101
+ if (pending_selection.size === 1) return '1 elemento seleccionado.';
102
+ return `${pending_selection.size} elementos seleccionados.`;
103
+ } else {
104
+ if (single_selected) return `Seleccionado: ${single_selected.label}`;
105
+ return '';
106
+ }
107
+ });
108
+ </script>
109
+
110
+ <div class={['flex w-full flex-col gap-2', props.parentClass].join(' ')}>
111
+ <Field {name} {label} {errors} {required}>
112
+ <button
113
+ type="button"
114
+ onclick={openDialog}
115
+ class="flex h-11 w-full items-center gap-2 rounded-2xl border border-neutral-200/70 bg-neutral-50/80 px-3.5 text-left text-sm text-neutral-800 shadow-xs transition-all hover:border-neutral-300 hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:outline-none dark:border-neutral-700/70 dark:bg-neutral-900/70 dark:text-neutral-100 dark:hover:border-neutral-500 dark:hover:bg-neutral-900"
116
+ >
117
+ <BoxSelect class="h-4 w-4 text-neutral-400 dark:text-neutral-500" />
118
+
119
+ <span class="truncate text-[13px] text-neutral-600 dark:text-neutral-300">
120
+ {#if multiple}
121
+ {#if selected_items.size > 0}
122
+ {selected_items.size === 1
123
+ ? Array.from(selected_items.values())[0].label
124
+ : `${selected_items.size} seleccionados`}
125
+ {:else}
126
+ {placeholder}
127
+ {/if}
128
+ {:else if selected_items.size > 0}
129
+ {single_selected?.label ?? placeholder}
130
+ {:else}
131
+ {placeholder}
132
+ {/if}
133
+ </span>
134
+
135
+ {#if multiple && selected_items.size > 0}
136
+ <span
137
+ class="ml-1 rounded-full bg-neutral-200/80 px-2 py-[2px] text-[11px] text-neutral-700 tabular-nums dark:bg-neutral-800/90 dark:text-neutral-200"
138
+ >
139
+ {selected_items.size}
140
+ </span>
141
+ {/if}
142
+
143
+ <ChevronDown
144
+ class={[
145
+ 'ml-auto h-4 w-4 text-neutral-400 transition-transform dark:text-neutral-500',
146
+ open ? 'rotate-180' : ''
147
+ ].join(' ')}
148
+ />
149
+ </button>
150
+ </Field>
151
+
152
+ {#if multiple}
153
+ <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} />
156
+ <Tag
157
+ onclose={() => removeSelected(item.value)}
158
+ variant="solid"
159
+ color="indigo"
160
+ 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
+ >
162
+ {item.label}
163
+ </Tag>
164
+ {/each}
165
+ </div>
166
+ {:else}
167
+ <input type="hidden" {name} value={single_selected?.value ?? ''} />
168
+ {/if}
169
+ </div>
170
+
171
+ <Dialog {open} onclose={cancelSelection}>
172
+ <DialogHeader>
173
+ <DialogTitle>{label}</DialogTitle>
174
+ {#if placeholder}
175
+ <DialogDescription>{placeholder}</DialogDescription>
176
+ {/if}
177
+
178
+ <div class="mt-4 space-y-2">
179
+ <div class="relative">
180
+ <Input
181
+ type="search"
182
+ class="w-full"
183
+ placeholder="Buscar..."
184
+ name="search"
185
+ autofocus
186
+ value={search}
187
+ oninput={(e) => (search = e.currentTarget.value)}
188
+ />
189
+ <div class="top absolute left-2">
190
+ <Search class="h-4 w-4 text-neutral-400" />
191
+ </div>
192
+ </div>
193
+
194
+ {#if multiple}
195
+ <div
196
+ class="flex items-center justify-between text-[11px] text-neutral-500 dark:text-neutral-400"
197
+ >
198
+ <span>
199
+ Seleccionados:
200
+ <span class="ml-1 font-semibold text-neutral-700 dark:text-neutral-200">
201
+ {pending_selection.size}
202
+ </span>
203
+ {#if options.length}
204
+ <span class="ml-1 text-neutral-400">/ {options.length}</span>
205
+ {/if}
206
+ </span>
207
+ {#if pending_selection.size > 0}
208
+ <button
209
+ type="button"
210
+ 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"
211
+ onclick={() => (pending_selection = new SvelteMap())}
212
+ >
213
+ <X class="h-3 w-3" />
214
+ Limpiar
215
+ </button>
216
+ {/if}
217
+ </div>
218
+ {/if}
219
+ </div>
220
+ </DialogHeader>
221
+
222
+ <DialogContent class="max-h-[70dvh] gap-1 py-2">
223
+ {#each filtered_options as item (item.value)}
224
+ {#if multiple}
225
+ <label class="flex flex-row gap-1">
226
+ <Checkbox
227
+ class={[
228
+ 'group flex items-center gap-3 rounded-2xl border px-3.5 py-2.5 text-sm transition-all',
229
+ pending_selection.has(item.value)
230
+ ? 'border-indigo-500/70 bg-indigo-500/8 shadow-sm shadow-indigo-500/20 dark:border-indigo-400/70 dark:bg-indigo-500/10'
231
+ : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800'
232
+ ]}
233
+ value={item.value}
234
+ label={item.label}
235
+ checked={pending_selection.has(item.value)}
236
+ onchange={() => toggleItem(item)}
237
+ />
238
+ <div class="flex flex-col">
239
+ <span>{item.label}</span>
240
+ {#if item.description}
241
+ <span
242
+ class="block text-[11px] text-neutral-500 group-hover:text-neutral-600 dark:text-neutral-400 dark:group-hover:text-neutral-300"
243
+ >
244
+ {item.description}
245
+ </span>
246
+ {/if}
247
+ </div>
248
+ </label>
249
+ {:else}
250
+ <button
251
+ type="button"
252
+ onclick={() => toggleItem(item)}
253
+ class={[
254
+ 'flex w-full cursor-pointer items-center gap-3 rounded-2xl border px-3.5 py-2.5 text-sm transition-all',
255
+ pending_selection.has(item.value)
256
+ ? 'border-indigo-500/70 bg-indigo-500/8 shadow-sm shadow-indigo-500/20 dark:border-indigo-400/70 dark:bg-indigo-500/10'
257
+ : 'border-transparent bg-neutral-100/80 hover:bg-neutral-200/80 dark:bg-neutral-900/70 dark:hover:bg-neutral-800'
258
+ ].join(' ')}
259
+ >
260
+ <!-- icono left -->
261
+ <div
262
+ class={[
263
+ 'flex h-7 w-7 items-center justify-center rounded-full border text-neutral-500 transition-all',
264
+ pending_selection.has(item.value)
265
+ ? 'border-transparent bg-linear-to-tr from-indigo-500 via-violet-500 to-blue-500 text-white shadow-sm shadow-indigo-500/40'
266
+ : 'border-neutral-300 bg-white/90 dark:border-neutral-600 dark:bg-neutral-900/90'
267
+ ].join(' ')}
268
+ >
269
+ {#if pending_selection.has(item.value)}
270
+ <Check class="h-3.5 w-3.5" />
271
+ {:else}
272
+ <Plus class="h-3.5 w-3.5" />
273
+ {/if}
274
+ </div>
275
+
276
+ <div class="flex flex-1 flex-col text-left">
277
+ <span class="text-[13px] font-medium text-neutral-800 dark:text-neutral-100">
278
+ {item.label}
279
+ </span>
280
+ {#if item.description}
281
+ <span class="text-[11px] text-neutral-500 dark:text-neutral-400">
282
+ {item.description}
283
+ </span>
284
+ {/if}
285
+ </div>
286
+
287
+ {#if pending_selection.has(item.value)}
288
+ <div
289
+ 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"
290
+ >
291
+ <Check class="h-3 w-3" />
292
+ <span>Seleccionado</span>
293
+ </div>
294
+ {/if}
295
+ </button>
296
+ {/if}
297
+ {:else}
298
+ <NoContent
299
+ icon={Search}
300
+ title="No se encontraron resultados"
301
+ subtitle="Prueba con otros términos o limpia el buscador."
302
+ />
303
+ {/each}
304
+ </DialogContent>
305
+
306
+ <DialogFooter class="flex items-center justify-between gap-2">
307
+ {#if footerMessage}
308
+ <div class="text-[11px] text-neutral-500 dark:text-neutral-400">
309
+ {footerMessage}
310
+ </div>
311
+ {/if}
312
+
313
+ <div class="ml-auto flex gap-2">
314
+ <Button variant="secondary" onclick={cancelSelection}>
315
+ <X class="h-4 w-4" />
316
+ {i18n.t('common.cancel')}
317
+ </Button>
318
+ <Button variant="primary" onclick={confirmSelection}>
319
+ <Save class="h-4 w-4" />
320
+ {i18n.t('common.confirm')}
321
+ {#if multiple}
322
+ <span class="ml-1 text-[11px] tabular-nums">
323
+ ({pending_selection.size})
324
+ </span>
325
+ {/if}
326
+ </Button>
327
+ </div>
328
+ </DialogFooter>
329
+ </Dialog>
@@ -0,0 +1,4 @@
1
+ import type { Props } from './type.js';
2
+ declare const SelectMultiple: import("svelte").Component<Props, {}, "value">;
3
+ type SelectMultiple = ReturnType<typeof SelectMultiple>;
4
+ export default SelectMultiple;
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ClassValue } from 'svelte/elements';
3
+ export interface Option {
4
+ label: string;
5
+ value: string;
6
+ description?: string;
7
+ [key: string]: any;
8
+ }
9
+ export interface Props {
10
+ parentClass?: ClassValue;
11
+ class?: ClassValue;
12
+ required?: boolean;
13
+ multiple?: boolean;
14
+ onConfirm?: (selected: Option[]) => void;
15
+ onCancel?: () => void;
16
+ item?: Snippet<[option: Option]>;
17
+ options: Option[];
18
+ value?: Option[];
19
+ label: string;
20
+ name: string;
21
+ placeholder: string;
22
+ errors?: string[];
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,6 +2,7 @@ import DataTable from './DataTable/DataTable.svelte';
2
2
  import TabsStepper from './TabsStepper/TabsStepper.svelte';
3
3
  import DataTableShell from './DataTableShell/DataTableShell.svelte';
4
4
  import { DataTableController } from './DataTableShell/core/DataTableController.svelte.js';
5
+ import SelectMultiple from './SelectMultiple/SelectMultiple.svelte';
5
6
  export * from './DataTable/core/types.js';
6
7
  export * from './TabsStepper/core/types.js';
7
- export { DataTable, TabsStepper, DataTableShell, DataTableController };
8
+ export { DataTable, TabsStepper, DataTableShell, DataTableController, SelectMultiple };
@@ -2,6 +2,7 @@ import DataTable from './DataTable/DataTable.svelte';
2
2
  import TabsStepper from './TabsStepper/TabsStepper.svelte';
3
3
  import DataTableShell from './DataTableShell/DataTableShell.svelte';
4
4
  import { DataTableController } from './DataTableShell/core/DataTableController.svelte.js';
5
+ import SelectMultiple from './SelectMultiple/SelectMultiple.svelte';
5
6
  export * from './DataTable/core/types.js';
6
7
  export * from './TabsStepper/core/types.js';
7
- export { DataTable, TabsStepper, DataTableShell, DataTableController };
8
+ export { DataTable, TabsStepper, DataTableShell, DataTableController, SelectMultiple };
@@ -30,7 +30,8 @@
30
30
  white: `${type}-white text-black`,
31
31
  info: `${type}-blue-500 text-whit`,
32
32
  outline: `${type}-gray-200 text-gray-800`,
33
- default: `${type}-gray-800 text-white`
33
+ default: `${type}-gray-800 text-white`,
34
+ indigo: `${type}-indigo-500 text-white`
34
35
  });
35
36
 
36
37
  const variants: Record<typeof variant, string> = $derived({
@@ -4,7 +4,7 @@ export interface Props {
4
4
  onclick?(): void;
5
5
  href?: string;
6
6
  variant?: 'solid' | 'outline';
7
- color?: 'primary' | 'secondary' | 'danger' | 'white' | 'teal' | 'info' | 'outline' | 'default';
7
+ color?: 'primary' | 'secondary' | 'danger' | 'white' | 'teal' | 'info' | 'outline' | 'default' | 'indigo';
8
8
  shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
9
9
  onclose?(): void;
10
10
  class?: ClassValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.28.4",
3
+ "version": "0.29.1",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.4",
6
6
  "publishConfig": {
@@ -82,7 +82,7 @@
82
82
  "tailwindcss": "4.1.17",
83
83
  "typescript": "5.9.3",
84
84
  "typescript-eslint": "8.48.1",
85
- "vite": "7.2.6",
85
+ "vite": "7.2.7",
86
86
  "vitest": "4.0.15"
87
87
  },
88
88
  "dependencies": {