@r2digisolutions/ui 0.27.3 → 0.28.0

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.
Files changed (35) hide show
  1. package/dist/components/container/DataTableShell/DataTableShell.svelte +631 -0
  2. package/dist/components/container/DataTableShell/DataTableShell.svelte.d.ts +48 -0
  3. package/dist/components/container/DataTableShell/components/AdvancedFiltersBuilder.svelte +311 -0
  4. package/dist/components/container/DataTableShell/components/AdvancedFiltersBuilder.svelte.d.ts +7 -0
  5. package/dist/components/container/DataTableShell/components/ColumnVisibilityMenu.svelte +112 -0
  6. package/dist/components/container/DataTableShell/components/ColumnVisibilityMenu.svelte.d.ts +8 -0
  7. package/dist/components/container/DataTableShell/components/ContextMenu.svelte +70 -0
  8. package/dist/components/container/DataTableShell/components/ContextMenu.svelte.d.ts +30 -0
  9. package/dist/components/container/DataTableShell/components/DataTableFiltersSidebar.svelte +0 -0
  10. package/dist/components/container/DataTableShell/components/DataTableFiltersSidebar.svelte.d.ts +26 -0
  11. package/dist/components/container/DataTableShell/components/DataTableFooter.svelte +36 -0
  12. package/dist/components/container/DataTableShell/components/DataTableFooter.svelte.d.ts +18 -0
  13. package/dist/components/container/DataTableShell/components/DataTableToolbar.svelte +822 -0
  14. package/dist/components/container/DataTableShell/components/DataTableToolbar.svelte.d.ts +30 -0
  15. package/dist/components/container/DataTableShell/components/Pagination.svelte +117 -0
  16. package/dist/components/container/DataTableShell/components/Pagination.svelte.d.ts +28 -0
  17. package/dist/components/container/DataTableShell/components/Submenu.svelte +109 -0
  18. package/dist/components/container/DataTableShell/components/Submenu.svelte.d.ts +30 -0
  19. package/dist/components/container/DataTableShell/components/Toolbar.svelte +0 -0
  20. package/dist/components/container/DataTableShell/components/Toolbar.svelte.d.ts +26 -0
  21. package/dist/components/container/DataTableShell/core/DataTableController.svelte.d.ts +54 -0
  22. package/dist/components/container/DataTableShell/core/DataTableController.svelte.js +148 -0
  23. package/dist/components/container/DataTableShell/core/DataTableEngine.svelte.d.ts +68 -0
  24. package/dist/components/container/DataTableShell/core/DataTableEngine.svelte.js +319 -0
  25. package/dist/components/container/DataTableShell/core/DataTableInternal.svelte.d.ts +68 -0
  26. package/dist/components/container/DataTableShell/core/DataTableInternal.svelte.js +396 -0
  27. package/dist/components/container/DataTableShell/core/context.d.ts +3 -0
  28. package/dist/components/container/DataTableShell/core/context.js +12 -0
  29. package/dist/components/container/DataTableShell/core/filters-types.d.ts +14 -0
  30. package/dist/components/container/DataTableShell/core/filters-types.js +1 -0
  31. package/dist/components/container/DataTableShell/core/types.d.ts +60 -0
  32. package/dist/components/container/DataTableShell/core/types.js +1 -0
  33. package/dist/components/container/index.d.ts +3 -1
  34. package/dist/components/container/index.js +3 -1
  35. package/package.json +13 -13
@@ -0,0 +1,822 @@
1
+ <script lang="ts" generics="T">
2
+ import {
3
+ Search,
4
+ SlidersHorizontal,
5
+ LayoutList,
6
+ LayoutGrid,
7
+ X,
8
+ ChevronDown,
9
+ ChevronRight
10
+ } from 'lucide-svelte';
11
+ import { useTable } from '../core/context.js';
12
+ import type { DataTableController } from '../core/DataTableController.svelte';
13
+ import type {
14
+ QueryStructure,
15
+ FilterOperator,
16
+ Operator,
17
+ Filter,
18
+ QueryGroup,
19
+ TQueryFilter
20
+ } from '../core/filters-types.js';
21
+
22
+ interface Props {
23
+ density: 'comfortable' | 'compact';
24
+ viewMode: 'list' | 'grid';
25
+ onDensityChange?: (d: 'comfortable' | 'compact') => void;
26
+ onViewModeChange?: (m: 'list' | 'grid') => void;
27
+ }
28
+
29
+ const { density, viewMode, onDensityChange, onViewModeChange }: Props = $props();
30
+ const id = $props.id();
31
+
32
+ const controller = useTable<T>() as DataTableController<T>;
33
+
34
+ let search = $state('');
35
+ let filtersOpen = $state(false);
36
+ let currentQuery = $state<QueryStructure | null>(null);
37
+ let filtersDialog: HTMLDivElement | null = $state(null);
38
+
39
+ type LocalCondition = {
40
+ id: number;
41
+ kind: 'condition';
42
+ field: string;
43
+ operator: FilterOperator;
44
+ value: string;
45
+ };
46
+
47
+ type LocalGroup = {
48
+ id: number;
49
+ kind: 'group';
50
+ joinOperation: Operator;
51
+ children: LocalFilterNode[];
52
+ collapsed?: boolean;
53
+ };
54
+
55
+ type LocalFilterNode = LocalCondition | LocalGroup;
56
+
57
+ let rootGroup = $state<LocalGroup>({
58
+ id: 0,
59
+ kind: 'group',
60
+ joinOperation: 'AND',
61
+ children: [],
62
+ collapsed: false
63
+ });
64
+
65
+ let nextNodeId = 1;
66
+
67
+ const operators: FilterOperator[] = [
68
+ 'equals',
69
+ 'contains',
70
+ 'not_contains',
71
+ 'greater_than',
72
+ 'less_than',
73
+ 'startsWith',
74
+ 'endsWith',
75
+ 'not_equals',
76
+ 'is_empty',
77
+ 'is_not_empty',
78
+ 'in',
79
+ 'not_in'
80
+ ];
81
+
82
+ const columns = $derived(
83
+ (((controller as any).allColumns ?? []) as { id: string; label: string }[]) || []
84
+ );
85
+
86
+ function isConditionUsable(c: LocalCondition) {
87
+ if (!c.field || !c.operator) return false;
88
+ if (c.operator === 'is_empty' || c.operator === 'is_not_empty') return true;
89
+ return c.value != null && String(c.value).trim() !== '';
90
+ }
91
+
92
+ function countUsableInNode(node: LocalFilterNode): number {
93
+ if (node.kind === 'condition') {
94
+ return isConditionUsable(node) ? 1 : 0;
95
+ }
96
+ return node.children.reduce((acc, child) => acc + countUsableInNode(child), 0);
97
+ }
98
+
99
+ const activeFilterCount = $derived(countUsableInNode(rootGroup));
100
+
101
+ function describeOperator(op: FilterOperator) {
102
+ switch (op) {
103
+ case 'equals':
104
+ return '=';
105
+ case 'not_equals':
106
+ return '≠';
107
+ case 'contains':
108
+ return 'contiene';
109
+ case 'not_contains':
110
+ return 'no contiene';
111
+ case 'greater_than':
112
+ return '>';
113
+ case 'less_than':
114
+ return '<';
115
+ case 'startsWith':
116
+ return 'empieza por';
117
+ case 'endsWith':
118
+ return 'termina en';
119
+ case 'is_empty':
120
+ return 'vacío';
121
+ case 'is_not_empty':
122
+ return 'no vacío';
123
+ case 'in':
124
+ return 'en lista';
125
+ case 'not_in':
126
+ return 'no en lista';
127
+ default:
128
+ return op;
129
+ }
130
+ }
131
+
132
+ function describeConditionChip(c: LocalCondition) {
133
+ const col = columns.find((col) => col.id === c.field);
134
+ const label = (col?.label ?? c.field) || 'sin columna';
135
+ const op = describeOperator(c.operator);
136
+ if (c.operator === 'is_empty' || c.operator === 'is_not_empty') {
137
+ return `${label} · ${op}`;
138
+ }
139
+ const value =
140
+ c.operator === 'in' || c.operator === 'not_in'
141
+ ? c.value
142
+ .split(',')
143
+ .map((v) => v.trim())
144
+ .filter(Boolean)
145
+ .slice(0, 3)
146
+ .join(', ')
147
+ : c.value;
148
+ return `${label} · ${op} ${value}`;
149
+ }
150
+
151
+ type FilterChip = {
152
+ id: number;
153
+ label: string;
154
+ };
155
+
156
+ function collectChipsFromNode(node: LocalFilterNode, acc: FilterChip[]) {
157
+ if (node.kind === 'condition') {
158
+ if (isConditionUsable(node)) {
159
+ acc.push({
160
+ id: node.id,
161
+ label: describeConditionChip(node)
162
+ });
163
+ }
164
+ return;
165
+ }
166
+ for (const child of node.children) {
167
+ collectChipsFromNode(child, acc);
168
+ }
169
+ }
170
+
171
+ const filterChips = $derived.by(() => {
172
+ const acc: FilterChip[] = [];
173
+ collectChipsFromNode(rootGroup, acc);
174
+ return acc;
175
+ });
176
+
177
+ $effect(() => {
178
+ controller.setSearch(search.trim());
179
+ controller.setPage(1);
180
+ });
181
+
182
+ function openFilters() {
183
+ syncFromQuery();
184
+ filtersOpen = true;
185
+ }
186
+
187
+ function closeFilters() {
188
+ filtersOpen = false;
189
+ }
190
+
191
+ function mapFromFilters(filters: TQueryFilter): LocalFilterNode[] {
192
+ const result: LocalFilterNode[] = [];
193
+ for (const f of filters) {
194
+ if (Array.isArray(f)) {
195
+ const [field, operator, value] = f as Filter;
196
+ result.push({
197
+ id: nextNodeId++,
198
+ kind: 'condition',
199
+ field: String(field),
200
+ operator: operator as FilterOperator,
201
+ value:
202
+ value == null
203
+ ? ''
204
+ : Array.isArray(value)
205
+ ? (value as (string | number | boolean)[]).join(', ')
206
+ : String(value)
207
+ });
208
+ } else if (f && (f as QueryGroup).type === 'group') {
209
+ const g = f as QueryGroup;
210
+ const group: LocalGroup = {
211
+ id: nextNodeId++,
212
+ kind: 'group',
213
+ joinOperation: g.joinOperation,
214
+ children: mapFromFilters(g.filters),
215
+ collapsed: false
216
+ };
217
+ result.push(group);
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+
223
+ function syncFromQuery() {
224
+ const q =
225
+ ((controller as any).query as QueryStructure | undefined | null) ?? currentQuery ?? null;
226
+
227
+ nextNodeId = 1;
228
+
229
+ if (!q || !q.useQuery || !q.filters?.length) {
230
+ rootGroup = {
231
+ id: nextNodeId++,
232
+ kind: 'group',
233
+ joinOperation: 'AND',
234
+ children: [],
235
+ collapsed: false
236
+ };
237
+ currentQuery = null;
238
+ return;
239
+ }
240
+
241
+ rootGroup = {
242
+ id: nextNodeId++,
243
+ kind: 'group',
244
+ joinOperation: q.joinOperation,
245
+ children: mapFromFilters(q.filters),
246
+ collapsed: false
247
+ };
248
+
249
+ currentQuery = q;
250
+ }
251
+
252
+ function updateGroup(
253
+ node: LocalGroup,
254
+ targetId: number,
255
+ fn: (g: LocalGroup) => LocalGroup
256
+ ): LocalGroup {
257
+ if (node.id === targetId) {
258
+ return fn(node);
259
+ }
260
+ return {
261
+ ...node,
262
+ children: node.children.map((child) => {
263
+ if (child.kind === 'group') {
264
+ return updateGroup(child, targetId, fn);
265
+ }
266
+ return child;
267
+ })
268
+ };
269
+ }
270
+
271
+ function removeNodeFromGroup(node: LocalGroup, targetId: number): LocalGroup {
272
+ return {
273
+ ...node,
274
+ children: node.children
275
+ .filter((child) => child.id !== targetId)
276
+ .map((child) => (child.kind === 'group' ? removeNodeFromGroup(child, targetId) : child))
277
+ };
278
+ }
279
+
280
+ function addConditionToGroup(groupId: number) {
281
+ rootGroup = updateGroup(rootGroup, groupId, (g) => ({
282
+ ...g,
283
+ children: [
284
+ ...g.children,
285
+ {
286
+ id: nextNodeId++,
287
+ kind: 'condition',
288
+ field: '',
289
+ operator: 'equals',
290
+ value: ''
291
+ } as LocalCondition
292
+ ]
293
+ }));
294
+ }
295
+
296
+ function addGroupToGroup(groupId: number) {
297
+ rootGroup = updateGroup(rootGroup, groupId, (g) => ({
298
+ ...g,
299
+ children: [
300
+ ...g.children,
301
+ {
302
+ id: nextNodeId++,
303
+ kind: 'group',
304
+ joinOperation: 'AND',
305
+ children: [],
306
+ collapsed: false
307
+ } as LocalGroup
308
+ ]
309
+ }));
310
+ }
311
+
312
+ function removeFilterNode(id: number) {
313
+ if (id === rootGroup.id) return;
314
+ rootGroup = removeNodeFromGroup(rootGroup, id);
315
+ }
316
+
317
+ function toggleGroupCollapse(id: number) {
318
+ rootGroup = updateGroup(rootGroup, id, (g) => ({
319
+ ...g,
320
+ collapsed: !g.collapsed
321
+ }));
322
+ }
323
+
324
+ function updateGroupJoinOperation(id: number, op: Operator) {
325
+ rootGroup = updateGroup(rootGroup, id, (g) => ({
326
+ ...g,
327
+ joinOperation: op
328
+ }));
329
+ }
330
+
331
+ function toFilterNode(node: LocalFilterNode): Filter | QueryGroup | null {
332
+ if (node.kind === 'condition') {
333
+ if (!isConditionUsable(node)) return null;
334
+ let value: string | number | boolean | string[] = node.value;
335
+ if (node.operator === 'in' || node.operator === 'not_in') {
336
+ value = node.value
337
+ .split(',')
338
+ .map((v) => v.trim())
339
+ .filter(Boolean);
340
+ }
341
+ return [node.field, node.operator, value];
342
+ }
343
+ const children = node.children
344
+ .map(toFilterNode)
345
+ .filter((c): c is Filter | QueryGroup => c != null);
346
+ if (!children.length) return null;
347
+ return {
348
+ type: 'group',
349
+ joinOperation: node.joinOperation,
350
+ filters: children
351
+ };
352
+ }
353
+
354
+ function clearFilters() {
355
+ rootGroup = {
356
+ id: rootGroup.id || 0,
357
+ kind: 'group',
358
+ joinOperation: 'AND',
359
+ children: [],
360
+ collapsed: false
361
+ };
362
+ currentQuery = null;
363
+ controller.setQuery(null as any);
364
+ }
365
+
366
+ function applyFilters() {
367
+ const filters = rootGroup.children
368
+ .map(toFilterNode)
369
+ .filter((c): c is Filter | QueryGroup => c != null);
370
+
371
+ if (!filters.length) {
372
+ controller.setQuery(null as any);
373
+ currentQuery = null;
374
+ filtersOpen = false;
375
+ return;
376
+ }
377
+
378
+ const q: QueryStructure = {
379
+ useQuery: true,
380
+ joinOperation: rootGroup.joinOperation,
381
+ filters
382
+ };
383
+
384
+ controller.setQuery(q);
385
+ currentQuery = q;
386
+ filtersOpen = false;
387
+ controller.setPage(1);
388
+ }
389
+
390
+ function handleDensityClick(d: 'comfortable' | 'compact') {
391
+ if (onDensityChange) onDensityChange(d);
392
+ }
393
+
394
+ function handleViewModeClick(m: 'list' | 'grid') {
395
+ if (onViewModeChange) onViewModeChange(m);
396
+ }
397
+
398
+ function clearSearch() {
399
+ if (!search) return;
400
+ search = '';
401
+ }
402
+
403
+ function removeChip(id: number) {
404
+ removeFilterNode(id);
405
+ applyFilters();
406
+ }
407
+
408
+ const styles_sidebar = $derived.by(() => {
409
+ let styles = `
410
+ inset: auto;
411
+ position-anchor: --filters-dialog-${id};
412
+ position-area: right bottom;
413
+ position-try-fallbacks: flip-block;
414
+ transform: translateX(-100%);
415
+ margin-block-start: 0.5rem;
416
+ `;
417
+
418
+ return styles;
419
+ });
420
+ </script>
421
+
422
+ {#snippet FilterCondition({ condition }: { condition: LocalCondition })}
423
+ <div
424
+ class="rounded-2xl border border-neutral-200/80 bg-white/90 px-3 py-2 shadow-sm dark:border-neutral-800/80 dark:bg-neutral-900/90"
425
+ >
426
+ <div class="mb-1 flex items-center justify-between gap-2">
427
+ <span
428
+ class="text-[10px] font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400"
429
+ >
430
+ Condición
431
+ </span>
432
+ <button
433
+ type="button"
434
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] text-neutral-500 hover:bg-red-50 hover:text-red-500 dark:text-neutral-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
435
+ onclick={() => removeFilterNode(condition.id)}
436
+ >
437
+ Quitar
438
+ </button>
439
+ </div>
440
+
441
+ <div class="grid grid-cols-1 gap-1.5">
442
+ <select
443
+ class="w-full rounded-xl border border-neutral-200/80 bg-white/95 px-2 py-1.5 text-[11px] text-neutral-900 outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/70 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-50"
444
+ bind:value={condition.field}
445
+ >
446
+ <option value="">Columna…</option>
447
+ {#each columns as col}
448
+ <option value={col.id}>{col.label}</option>
449
+ {/each}
450
+ </select>
451
+
452
+ <select
453
+ class="w-full rounded-xl border border-neutral-200/80 bg-white/95 px-2 py-1.5 text-[11px] text-neutral-900 outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500/70 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-50"
454
+ bind:value={condition.operator}
455
+ >
456
+ {#each operators as op}
457
+ <option value={op}>{op}</option>
458
+ {/each}
459
+ </select>
460
+
461
+ {#if condition.operator !== 'is_empty' && condition.operator !== 'is_not_empty'}
462
+ <input
463
+ type="text"
464
+ placeholder={condition.operator === 'in' || condition.operator === 'not_in'
465
+ ? 'Valores separados por coma…'
466
+ : 'Valor…'}
467
+ bind:value={condition.value}
468
+ class="w-full rounded-xl border border-neutral-200/80 bg-white/95 px-2 py-1.5 text-[11px] text-neutral-900 outline-none placeholder:text-neutral-400 focus:border-purple-500 focus:ring-1 focus:ring-purple-500/70 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-50 dark:placeholder:text-neutral-500"
469
+ />
470
+ {:else}
471
+ <div
472
+ class="rounded-xl border border-dashed border-neutral-200/80 bg-neutral-50/90 px-2 py-1.5 text-[10px] text-neutral-500 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-400"
473
+ >
474
+ Este operador no necesita valor.
475
+ </div>
476
+ {/if}
477
+ </div>
478
+ </div>
479
+ {/snippet}
480
+
481
+ {#snippet FilterGroup({ group, isRoot }: { group: LocalGroup; isRoot: boolean })}
482
+ <div
483
+ class="rounded-2xl border border-neutral-200/80 bg-gradient-to-b from-white/98 to-neutral-50/95 px-3 py-2.5 shadow-md dark:border-neutral-800/80 dark:from-neutral-950/98 dark:to-neutral-900/95"
484
+ >
485
+ <div class="mb-2 flex items-center justify-between gap-2">
486
+ <div class="flex items-center gap-1.5">
487
+ <button
488
+ type="button"
489
+ class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-100 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
490
+ onclick={() => toggleGroupCollapse(group.id)}
491
+ >
492
+ {#if group.collapsed}
493
+ <ChevronRight class="h-3.5 w-3.5" />
494
+ {:else}
495
+ <ChevronDown class="h-3.5 w-3.5" />
496
+ {/if}
497
+ </button>
498
+ <div class="flex flex-col">
499
+ <span class="text-[11px] font-semibold text-neutral-900 dark:text-neutral-50">
500
+ {isRoot ? 'Grupo raíz' : 'Grupo'}
501
+ </span>
502
+ <span class="text-[10px] text-neutral-500 dark:text-neutral-400">
503
+ Condiciones unidas con
504
+ <span class="font-semibold"> {group.joinOperation}</span>
505
+ </span>
506
+ </div>
507
+ </div>
508
+
509
+ <div class="flex items-center gap-1">
510
+ <div
511
+ class="inline-flex items-center rounded-full border border-neutral-200/80 bg-neutral-100/80 p-0.5 text-[10px] text-neutral-700 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-200"
512
+ >
513
+ <button
514
+ type="button"
515
+ class={`inline-flex items-center rounded-full px-2 py-0.5 ${
516
+ group.joinOperation === 'AND'
517
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
518
+ : 'hover:text-neutral-900 dark:hover:text-neutral-50'
519
+ }`}
520
+ onclick={() => updateGroupJoinOperation(group.id, 'AND')}
521
+ >
522
+ AND
523
+ </button>
524
+ <button
525
+ type="button"
526
+ class={`inline-flex items-center rounded-full px-2 py-0.5 ${
527
+ group.joinOperation === 'OR'
528
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
529
+ : 'hover:text-neutral-900 dark:hover:text-neutral-50'
530
+ }`}
531
+ onclick={() => updateGroupJoinOperation(group.id, 'OR')}
532
+ >
533
+ OR
534
+ </button>
535
+ </div>
536
+
537
+ {#if !isRoot}
538
+ <button
539
+ type="button"
540
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] text-neutral-500 hover:bg-red-50 hover:text-red-500 dark:text-neutral-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
541
+ onclick={() => removeFilterNode(group.id)}
542
+ >
543
+ Quitar
544
+ </button>
545
+ {/if}
546
+ </div>
547
+ </div>
548
+
549
+ {#if !group.collapsed}
550
+ <div
551
+ class="space-y-2 border-l border-dashed border-neutral-200/80 pl-3 dark:border-neutral-700/80"
552
+ >
553
+ {#if !group.children.length}
554
+ <div
555
+ class="rounded-xl border border-dashed border-neutral-200/80 bg-neutral-50/80 px-2.5 py-2 text-[10px] text-neutral-500 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-400"
556
+ >
557
+ Este grupo no tiene condiciones. Añade una condición o un subgrupo.
558
+ </div>
559
+ {:else}
560
+ <div class="space-y-1.5">
561
+ {#each group.children as child (child.id)}
562
+ {#if child.kind === 'group'}
563
+ {@render FilterGroup({ group: child, isRoot: false })}
564
+ {:else}
565
+ {@render FilterCondition({ condition: child })}
566
+ {/if}
567
+ {/each}
568
+ </div>
569
+ {/if}
570
+
571
+ <div class="mt-2 flex flex-wrap items-center gap-1.5">
572
+ <button
573
+ type="button"
574
+ class="inline-flex items-center justify-center rounded-xl border border-dashed border-neutral-200/80 bg-neutral-50/80 px-2.5 py-1 text-[10px] text-neutral-700 hover:border-purple-500 hover:text-purple-600 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-200 dark:hover:border-purple-500 dark:hover:text-purple-200"
575
+ onclick={() => addConditionToGroup(group.id)}
576
+ >
577
+ + Condición
578
+ </button>
579
+ <button
580
+ type="button"
581
+ class="inline-flex items-center justify-center rounded-xl border border-dashed border-neutral-200/80 bg-neutral-50/80 px-2.5 py-1 text-[10px] text-neutral-700 hover:border-purple-500 hover:text-purple-600 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-200 dark:hover:border-purple-500 dark:hover:text-purple-200"
582
+ onclick={() => addGroupToGroup(group.id)}
583
+ >
584
+ + Subgrupo
585
+ </button>
586
+ </div>
587
+ </div>
588
+ {/if}
589
+ </div>
590
+ {/snippet}
591
+
592
+ <div
593
+ class="flex flex-col gap-1 border-b border-neutral-200/80 bg-gradient-to-r from-neutral-50/95 via-white/95 to-neutral-50/95 px-3 py-2 text-[11px] text-neutral-600 shadow-[0_8px_24px_rgba(15,23,42,0.04)] backdrop-blur-xl dark:border-neutral-800/80 dark:from-neutral-950/90 dark:via-neutral-950/85 dark:to-neutral-900/85 dark:text-neutral-300"
594
+ >
595
+ <div class="flex items-center justify-between gap-3">
596
+ <div class="flex min-w-0 flex-1 items-center gap-3">
597
+ <div class="relative max-w-xs flex-1">
598
+ <input
599
+ type="search"
600
+ placeholder="Buscar…"
601
+ bind:value={search}
602
+ class="w-full rounded-xl border border-neutral-200/80 bg-white/80 py-1.5 pr-7 pl-7 text-[11px] text-neutral-800 outline-none placeholder:text-neutral-400 focus:border-purple-400 focus:ring-2 focus:ring-purple-400/60 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-50 dark:placeholder:text-neutral-500"
603
+ />
604
+ <div class="pointer-events-none absolute inset-y-0 left-2 flex items-center">
605
+ <Search class="h-3.5 w-3.5 text-neutral-400 dark:text-neutral-500" />
606
+ </div>
607
+ {#if search}
608
+ <button
609
+ type="button"
610
+ class="absolute inset-y-0 right-1 flex items-center justify-center rounded-full p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
611
+ onclick={clearSearch}
612
+ >
613
+ <X class="h-3 w-3" />
614
+ </button>
615
+ {/if}
616
+ </div>
617
+ </div>
618
+
619
+ <div class="flex items-center gap-2">
620
+ <div
621
+ class="hidden items-center gap-0.5 rounded-full border border-neutral-200/80 bg-white/80 p-0.5 text-[10px] text-neutral-500 shadow-sm sm:inline-flex dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-300"
622
+ >
623
+ <button
624
+ type="button"
625
+ class={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${
626
+ density === 'comfortable'
627
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
628
+ : 'hover:text-neutral-800 dark:hover:text-neutral-100'
629
+ }`}
630
+ onclick={() => handleDensityClick('comfortable')}
631
+ >
632
+ <span class="hidden md:inline">Cómodo</span>
633
+ <span class="md:hidden">C</span>
634
+ </button>
635
+ <button
636
+ type="button"
637
+ class={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${
638
+ density === 'compact'
639
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
640
+ : 'hover:text-neutral-800 dark:hover:text-neutral-100'
641
+ }`}
642
+ onclick={() => handleDensityClick('compact')}
643
+ >
644
+ <span class="hidden md:inline">Compacto</span>
645
+ <span class="md:hidden">X</span>
646
+ </button>
647
+ </div>
648
+
649
+ <div
650
+ class="inline-flex items-center gap-0.5 rounded-full border border-neutral-200/80 bg-white/80 p-0.5 text-[10px] text-neutral-500 shadow-sm dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-300"
651
+ >
652
+ <button
653
+ type="button"
654
+ class={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${
655
+ viewMode === 'list'
656
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
657
+ : 'hover:text-neutral-800 dark:hover:text-neutral-100'
658
+ }`}
659
+ onclick={() => handleViewModeClick('list')}
660
+ >
661
+ <LayoutList class="h-3 w-3" />
662
+ <span class="hidden sm:inline">Lista</span>
663
+ </button>
664
+ <button
665
+ type="button"
666
+ class={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${
667
+ viewMode === 'grid'
668
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
669
+ : 'hover:text-neutral-800 dark:hover:text-neutral-100'
670
+ }`}
671
+ onclick={() => handleViewModeClick('grid')}
672
+ >
673
+ <LayoutGrid class="h-3 w-3" />
674
+ <span class="hidden sm:inline">Grid</span>
675
+ </button>
676
+ </div>
677
+
678
+ <button
679
+ type="button"
680
+ class="inline-flex cursor-pointer items-center gap-1 rounded-full border border-neutral-200/80 bg-white/80 px-2.5 py-1.5 text-[10px] font-medium text-neutral-600 shadow-sm transition-colors hover:border-purple-300 hover:text-purple-600 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-300 dark:hover:border-purple-500/60 dark:hover:text-purple-100"
681
+ popovertarget="filters-dialog-{id}"
682
+ style="anchor-name: --filters-dialog-{id};"
683
+ >
684
+ <SlidersHorizontal class="h-3.5 w-3.5" />
685
+ <span class="hidden sm:inline">Filtros</span>
686
+ {#if activeFilterCount}
687
+ <span
688
+ class="ml-1 rounded-full bg-purple-600 px-1.5 py-[1px] text-[9px] font-semibold text-white shadow-sm dark:bg-purple-500"
689
+ >
690
+ {activeFilterCount}
691
+ </span>
692
+ {/if}
693
+ </button>
694
+ </div>
695
+ </div>
696
+
697
+ {#if filterChips.length}
698
+ <div class="flex min-w-0 flex-wrap items-center gap-1.5">
699
+ {#each filterChips as chip}
700
+ <button
701
+ type="button"
702
+ class="group inline-flex max-w-xs items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
703
+ onclick={() => removeChip(chip.id)}
704
+ >
705
+ <span class="truncate">
706
+ {chip.label}
707
+ </span>
708
+ <span
709
+ class="flex h-4 w-4 items-center justify-center rounded-full bg-neutral-300/70 text-[9px] text-neutral-700 group-hover:bg-neutral-400 dark:bg-neutral-700/70 dark:text-neutral-100 dark:group-hover:bg-neutral-600"
710
+ >
711
+ <X class="h-2.5 w-2.5" />
712
+ </span>
713
+ </button>
714
+ {/each}
715
+ </div>
716
+ {/if}
717
+ </div>
718
+
719
+ <div
720
+ popover
721
+ id="filters-dialog-{id}"
722
+ bind:this={filtersDialog}
723
+ style={styles_sidebar}
724
+ class="overflow-visible rounded-2xl border border-neutral-200/80 bg-gradient-to-b from-neutral-50/98 via-neutral-50/95 to-neutral-100/95 text-[11px] text-neutral-900 shadow-sm dark:border-neutral-800/80 dark:from-neutral-950/98 dark:via-neutral-950/95 dark:to-neutral-900/95 dark:text-neutral-50"
725
+ >
726
+ <div class="flex max-h-[80vh] w-[min(360px,100vw-16px)] flex-col p-3">
727
+ <header class="mb-2 flex items-center justify-between gap-2">
728
+ <div class="flex flex-col gap-0.5">
729
+ <h3 class="text-[12px] font-semibold text-neutral-900 dark:text-neutral-50">
730
+ Filtros avanzados
731
+ </h3>
732
+ <p class="text-[10px] text-neutral-500 dark:text-neutral-400">
733
+ Combina grupos AND/OR para refinar los resultados.
734
+ </p>
735
+ </div>
736
+ </header>
737
+
738
+ <div
739
+ class="mb-2 flex items-center justify-between gap-2 rounded-2xl border border-neutral-200/80 bg-white/80 px-3 py-2 text-[10px] shadow-sm dark:border-neutral-800/80 dark:bg-neutral-900/80"
740
+ >
741
+ <div class="flex flex-col">
742
+ <span class="font-medium text-neutral-800 dark:text-neutral-100"> Operador raíz </span>
743
+ <span class="text-neutral-500 dark:text-neutral-400">
744
+ Une los grupos superiores con
745
+ <span class="font-semibold"> {rootGroup.joinOperation}</span>
746
+ </span>
747
+ </div>
748
+ <div
749
+ class="inline-flex items-center rounded-full border border-neutral-200/80 bg-neutral-100/80 p-0.5 text-[10px] text-neutral-700 dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-200"
750
+ >
751
+ <button
752
+ type="button"
753
+ class={`inline-flex items-center rounded-full px-2 py-0.5 ${
754
+ rootGroup.joinOperation === 'AND'
755
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
756
+ : 'hover:text-neutral-900 dark:hover:text-neutral-50'
757
+ }`}
758
+ onclick={() => updateGroupJoinOperation(rootGroup.id, 'AND')}
759
+ >
760
+ AND
761
+ </button>
762
+ <button
763
+ type="button"
764
+ class={`inline-flex items-center rounded-full px-2 py-0.5 ${
765
+ rootGroup.joinOperation === 'OR'
766
+ ? 'bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900'
767
+ : 'hover:text-neutral-900 dark:hover:text-neutral-50'
768
+ }`}
769
+ onclick={() => updateGroupJoinOperation(rootGroup.id, 'OR')}
770
+ >
771
+ OR
772
+ </button>
773
+ </div>
774
+ </div>
775
+
776
+ <div class="flex-1 space-y-2 overflow-auto pr-1">
777
+ {#if !columns.length}
778
+ <div
779
+ class="rounded-xl border border-dashed border-neutral-300/80 bg-white/90 px-3 py-2 text-[10px] text-neutral-500 shadow-inner dark:border-neutral-700/80 dark:bg-neutral-900/80 dark:text-neutral-400"
780
+ >
781
+ No hay columnas configuradas para filtrar.
782
+ </div>
783
+ {/if}
784
+
785
+ {@render FilterGroup({ group: rootGroup, isRoot: true })}
786
+ </div>
787
+
788
+ <footer
789
+ class="mt-3 flex items-center justify-between gap-2 border-t border-neutral-200/80 pt-2 dark:border-neutral-800/80"
790
+ >
791
+ <button
792
+ type="button"
793
+ class="rounded-xl px-2.5 py-1.5 text-[10px] text-neutral-500 hover:bg-neutral-200/80 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
794
+ onclick={clearFilters}
795
+ >
796
+ Limpiar filtros
797
+ </button>
798
+ <div class="flex items-center gap-2">
799
+ {#if activeFilterCount}
800
+ <div
801
+ class="hidden items-center gap-1 rounded-full bg-neutral-900/90 px-2 py-1 text-[9px] text-neutral-100 shadow-sm md:inline-flex dark:bg-neutral-800/90"
802
+ >
803
+ <span>{activeFilterCount} condición{activeFilterCount === 1 ? '' : 'es'}</span>
804
+ </div>
805
+ {/if}
806
+ <button
807
+ type="button"
808
+ class="inline-flex items-center gap-2 rounded-xl bg-purple-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm hover:bg-purple-500 disabled:opacity-40"
809
+ onclick={applyFilters}
810
+ disabled={!activeFilterCount}
811
+ >
812
+ <span>Aplicar</span>
813
+ {#if activeFilterCount}
814
+ <span class="rounded-full bg-white/15 px-1.5 py-[1px] text-[9px]">
815
+ {activeFilterCount}
816
+ </span>
817
+ {/if}
818
+ </button>
819
+ </div>
820
+ </footer>
821
+ </div>
822
+ </div>