@r2digisolutions/ui 0.34.5 → 0.34.7

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.
@@ -57,29 +57,26 @@
57
57
  {} as Record<keyof T, { left?: number; right?: number }>
58
58
  );
59
59
 
60
- // =========================
61
- // CONTEXT MENU
62
- // =========================
60
+ // ✅ el contenedor que scrollea (tu overflow-auto)
61
+ let scrollEl = $state<HTMLDivElement | null>(null);
62
+
63
+ // CONTEXT
63
64
  let contextPopover = $state<HTMLDivElement | null>(null);
64
65
  let contextRow = $state<T | null>(null);
65
66
 
66
- // punto preferido (cursor o botón)
67
+ // punto donde “quieres” abrirlo (coords de viewport)
67
68
  let contextPos = $state<{ x: number; y: number }>({ x: 0, y: 0 });
68
69
 
69
- // ✅ left/top reales (sin transform, para que no se salga nunca)
70
+ // ✅ left/top final (sin transform)
70
71
  let contextRender = $state<{ left: number; top: number }>({ left: 0, top: 0 });
71
72
 
72
- // Ajustes
73
- const CONTEXT_MARGIN = 10; // margen mínimo a bordes viewport
74
- const CONTEXT_GAP = 8; // separación visual del cursor/botón
73
+ const CONTEXT_MARGIN = 12;
74
+ const CONTEXT_GAP = 8;
75
75
 
76
76
  let openRows = $state<Set<string>>(new Set());
77
-
78
77
  const contextOpen = $derived(contextRow !== null);
79
78
 
80
- // =========================
81
79
  // GRID / STICKY
82
- // =========================
83
80
  $effect(() => {
84
81
  const parts: string[] = [];
85
82
  if (controller.multiSelect) parts.push('40px');
@@ -89,7 +86,6 @@
89
86
  parts.push(`${w}px`);
90
87
  });
91
88
 
92
- // columna acciones
93
89
  parts.push('64px');
94
90
  gridTemplate = parts.join(' ');
95
91
 
@@ -104,57 +100,50 @@
104
100
  if (col.sticky === 'left') offsets[col.id as keyof T] = { left: accLeft };
105
101
  accLeft += w;
106
102
  });
107
-
108
103
  stickyOffsets = offsets;
109
104
  });
110
105
 
111
106
  // CHECK ALL
112
107
  $effect(() => {
113
108
  if (!controller.multiSelect || !selectAllEl) return;
114
-
115
109
  if (!controller.currentRows.length) {
116
110
  selectAllEl.checked = false;
117
111
  selectAllEl.indeterminate = false;
118
112
  return;
119
113
  }
120
-
121
114
  selectAllEl.checked = controller.allVisibleSelected;
122
115
  selectAllEl.indeterminate = controller.someVisibleSelected;
123
116
  });
124
117
 
125
- // CERRAR CONTEXT MENU al click fuera
118
+ // Cerrar por click fuera
126
119
  $effect(() => {
127
120
  function handleDocumentClick(event: MouseEvent) {
128
121
  if (!contextOpen) return;
129
122
  const target = event.target as HTMLElement;
130
123
  if (!target.closest('[data-context-host="true"]')) closeContext();
131
124
  }
132
-
133
- if (contextOpen) document.addEventListener('click', handleDocumentClick);
134
-
135
- return () => document.removeEventListener('click', handleDocumentClick);
125
+ if (contextOpen) document.addEventListener('mousedown', handleDocumentClick, true);
126
+ return () => document.removeEventListener('mousedown', handleDocumentClick, true);
136
127
  });
137
128
 
138
- // ✅ Reposicionar en resize + scroll (capture)
129
+ // ✅ Reposicionar mientras está abierto: window resize + scroll del contenedor REAL
139
130
  $effect(() => {
140
131
  if (!contextOpen) return;
141
132
 
142
- const onWin = () => {
143
- positionContext(2);
144
- };
133
+ const handler = () => void positionContext();
145
134
 
146
- window.addEventListener('resize', onWin);
147
- window.addEventListener('scroll', onWin, { passive: true, capture: true });
135
+ window.addEventListener('resize', handler);
136
+
137
+ // 👇 ESTE ES el scroll que te estaba faltando
138
+ scrollEl?.addEventListener('scroll', handler, { passive: true });
148
139
 
149
140
  return () => {
150
- window.removeEventListener('resize', onWin);
151
- window.removeEventListener('scroll', onWin, true as any);
141
+ window.removeEventListener('resize', handler);
142
+ scrollEl?.removeEventListener('scroll', handler as any);
152
143
  };
153
144
  });
154
145
 
155
- // =========================
156
- // RESIZE COLUMNS
157
- // =========================
146
+ // RESIZE columns
158
147
  let resizingId: keyof T | null = null;
159
148
  let startX = 0;
160
149
  let startWidth = 0;
@@ -181,9 +170,6 @@
181
170
  window.removeEventListener('mouseup', onResizeUp);
182
171
  }
183
172
 
184
- // =========================
185
- // HELPERS
186
- // =========================
187
173
  function rowIdFor(row: T, index: number) {
188
174
  return controller.getRowId(row, index);
189
175
  }
@@ -221,7 +207,7 @@
221
207
  }
222
208
 
223
209
  // =========================
224
- // ✅ Context positioning (real left/top, sin transform)
210
+ // ✅ POSICIONAMIENTO REAL (sin transform)
225
211
  // =========================
226
212
  function clamp(n: number, min: number, max: number) {
227
213
  return Math.max(min, Math.min(max, n));
@@ -231,70 +217,66 @@
231
217
  return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
232
218
  }
233
219
 
234
- /**
235
- * Devuelve left/top reales (sin transform) para que el popover nunca se salga.
236
- * preferred = punto donde se abrió (clientX/clientY)
237
- * pop = tamaño real del popover (width/height)
238
- */
220
+ function getViewport() {
221
+ const vv = window.visualViewport;
222
+ if (vv) return { left: vv.offsetLeft, top: vv.offsetTop, width: vv.width, height: vv.height };
223
+ return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
224
+ }
225
+
239
226
  function computeContextPosition(preferred: { x: number; y: number }, pop: DOMRect) {
240
- const vw = window.innerWidth;
241
- const vh = window.innerHeight;
242
-
243
- // Si es más alto que el viewport (menos márgenes), lo forzamos pegado arriba
244
- const maxHeightAllowed = vh - CONTEXT_MARGIN * 2;
245
- const tooTall = pop.height > maxHeightAllowed;
246
-
247
- // Preferencias:
248
- // - horizontal: izquierda del punto
249
- // - vertical: abajo del punto
250
- let left = preferred.x - pop.width; // izquierda
251
- let top = preferred.y + CONTEXT_GAP; // abajo
252
-
253
- // Horizontal flip: si no cabe a la izquierda, poner a la derecha
254
- if (left < CONTEXT_MARGIN) {
255
- left = preferred.x + CONTEXT_GAP;
256
- }
227
+ const vp = getViewport();
228
+
229
+ const minLeft = vp.left + CONTEXT_MARGIN;
230
+ const minTop = vp.top + CONTEXT_MARGIN;
231
+
232
+ const maxLeft = vp.left + vp.width - CONTEXT_MARGIN - pop.width;
233
+ const maxTop = vp.top + vp.height - CONTEXT_MARGIN - pop.height;
234
+
235
+ const tooTall = pop.height > vp.height - CONTEXT_MARGIN * 2;
257
236
 
258
- // Vertical flip: si no cabe abajo y NO es tooTall, poner arriba
259
- if (!tooTall) {
237
+ // default: left + down
238
+ let left = preferred.x - pop.width;
239
+ let top = preferred.y + CONTEXT_GAP;
240
+
241
+ // flip horizontal
242
+ if (left < minLeft) left = preferred.x + CONTEXT_GAP;
243
+
244
+ if (tooTall) {
245
+ // si no cabe en altura, pegado arriba
246
+ top = minTop;
247
+ } else {
248
+ // flip vertical si se sale abajo
260
249
  const bottomIfDown = top + pop.height;
261
- const downFits = bottomIfDown <= vh - CONTEXT_MARGIN;
262
- if (!downFits) {
250
+ const bottomLimit = vp.top + vp.height - CONTEXT_MARGIN;
251
+ if (bottomIfDown > bottomLimit) {
263
252
  top = preferred.y - CONTEXT_GAP - pop.height; // arriba
264
253
  }
265
- }
266
254
 
267
- // Si es tooTall, lo pegamos a top margin y listo
268
- if (tooTall) {
269
- top = CONTEXT_MARGIN;
255
+ // si arriba se sale, pegado abajo
256
+ if (top < minTop) top = maxTop;
270
257
  }
271
258
 
272
- // Clamp final dentro del viewport
273
- // (si lo ponemos a la derecha pero no cabe, clamp lo corrige)
274
- left = clamp(left, CONTEXT_MARGIN, vw - CONTEXT_MARGIN - pop.width);
275
- top = clamp(top, CONTEXT_MARGIN, vh - CONTEXT_MARGIN - Math.min(pop.height, maxHeightAllowed));
259
+ left = clamp(left, minLeft, Math.max(minLeft, maxLeft));
260
+ top = clamp(top, minTop, Math.max(minTop, maxTop));
276
261
 
277
262
  return { left, top };
278
263
  }
279
264
 
280
- /**
281
- * Posiciona midiendo tamaño real.
282
- * RAF suele ser más fiable que tick() con Popover API.
283
- */
284
- async function positionContext(retries = 3) {
265
+ async function positionContext() {
285
266
  if (!contextPopover || !contextRow) return;
286
267
 
287
268
  await raf();
269
+ const r1 = contextPopover.getBoundingClientRect();
270
+ if (!r1.width || !r1.height) return;
288
271
 
289
- // OJO: el popover debe estar visible para tener medidas reales
290
- const rect = contextPopover.getBoundingClientRect();
272
+ contextRender = computeContextPosition(contextPos, r1);
291
273
 
292
- if ((!rect.width || !rect.height) && retries > 0) {
293
- return positionContext(retries - 1);
294
- }
295
- if (!rect.width || !rect.height) return;
274
+ // segunda pasada por scrollbar
275
+ await raf();
276
+ const r2 = contextPopover.getBoundingClientRect();
277
+ if (!r2.width || !r2.height) return;
296
278
 
297
- contextRender = computeContextPosition(contextPos, rect);
279
+ contextRender = computeContextPosition(contextPos, r2);
298
280
  }
299
281
 
300
282
  async function openContextAt(event: MouseEvent, row: T) {
@@ -304,10 +286,8 @@
304
286
  contextRow = row;
305
287
  contextPos = { x: event.clientX, y: event.clientY };
306
288
 
307
- if (contextPopover) contextPopover.showPopover();
308
-
309
289
  await tick();
310
- await positionContext(3);
290
+ await positionContext();
311
291
  }
312
292
 
313
293
  async function openContextFromButton(event: MouseEvent, row: T) {
@@ -315,24 +295,23 @@
315
295
  event.stopPropagation();
316
296
 
317
297
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
318
-
319
298
  contextRow = row;
320
- contextPos = { x: rect.right, y: rect.bottom };
321
299
 
322
- if (contextPopover) contextPopover.showPopover();
300
+ // punto: esquina inferior derecha del botón
301
+ contextPos = { x: rect.right, y: rect.bottom };
323
302
 
324
303
  await tick();
325
- await positionContext(3);
304
+ await positionContext();
326
305
  }
327
306
 
328
307
  function closeContext() {
329
- if (contextPopover) contextPopover.hidePopover();
330
308
  contextRow = null;
331
309
  }
332
310
  </script>
333
311
 
312
+ <!-- ✅ wrapper principal relativo (para z-index) -->
334
313
  <div
335
- class="flex flex-col overflow-hidden rounded-2xl border border-neutral-200/80 bg-neutral-50/70 text-xs text-neutral-900 shadow-sm backdrop-blur-2xl dark:border-neutral-800/80 dark:bg-neutral-950/70 dark:text-neutral-50"
314
+ class="relative flex flex-col overflow-hidden rounded-2xl border border-neutral-200/80 bg-neutral-50/70 text-xs text-neutral-900 shadow-sm backdrop-blur-2xl dark:border-neutral-800/80 dark:bg-neutral-950/70 dark:text-neutral-50"
336
315
  >
337
316
  <DataTableToolbar
338
317
  {density}
@@ -352,7 +331,8 @@
352
331
  </div>
353
332
  {/if}
354
333
 
355
- <div class="relative max-h-[70vh] flex-1 overflow-auto">
334
+ <!-- ESTE es el scroll container, le hacemos bind -->
335
+ <div bind:this={scrollEl} class="relative max-h-[70vh] flex-1 overflow-auto">
356
336
  {#if controller.loading}
357
337
  <div class="pointer-events-none absolute inset-0 z-20 bg-neutral-900/30 backdrop-blur-md">
358
338
  <div class="flex h-full items-center justify-center">
@@ -462,7 +442,6 @@
462
442
  {@const id = rowIdFor(row, index)}
463
443
 
464
444
  <div class="group relative">
465
- <!-- svelte-ignore a11y_click_events_have_key_events -->
466
445
  <div
467
446
  role="row"
468
447
  tabindex="0"
@@ -495,7 +474,7 @@
495
474
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
496
475
  {@const sticky = stickyOffsets[col.id as keyof T]}
497
476
  <div
498
- class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50 ${
477
+ class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50${
499
478
  density === 'compact' ? 'py-1.5' : 'py-2.5'
500
479
  } dark:border-neutral-800/70 ${
501
480
  col.sticky === 'left'
@@ -509,15 +488,7 @@
509
488
  {#if cell}
510
489
  {@render cell({ row, column: col, value, index })}
511
490
  {:else}
512
- <span
513
- class={`line-clamp-2 text-black dark:text-neutral-50 ${
514
- col.align === 'right'
515
- ? 'ml-auto text-right'
516
- : col.align === 'center'
517
- ? 'mx-auto text-center'
518
- : ''
519
- }`}
520
- >
491
+ <span class="line-clamp-2 text-black dark:text-neutral-50">
521
492
  {formatValue(col, value, row)}
522
493
  </span>
523
494
  {/if}
@@ -531,33 +502,30 @@
531
502
  data-stop-row-toggle="true"
532
503
  >
533
504
  {#if actions.length}
534
- {#if rowActions}
535
- {@render rowActions(row, actions)}
536
- {:else}
537
- <div class="flex items-center gap-1.5">
538
- {#if rowCollapse}
539
- <button
540
- type="button"
541
- onclick={(e) => {
542
- e.stopPropagation();
543
- toggleRow(row, index);
544
- }}
545
- class={`inline-flex h-6 w-6 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-200/80 hover:text-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-800/80 dark:hover:text-neutral-100 ${
546
- openRows.has(id) ? 'rotate-180' : ''
547
- }`}
548
- >
549
- <ChevronDown class="h-3.5 w-3.5" />
550
- </button>
551
- {/if}
505
+ <div class="flex items-center gap-1.5">
506
+ {#if rowCollapse}
552
507
  <button
553
508
  type="button"
554
- onclick={(e) => openContextFromButton(e, row)}
555
- class="inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-200/80 hover:text-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-800/80 dark:hover:text-neutral-100"
509
+ onclick={(e) => {
510
+ e.stopPropagation();
511
+ toggleRow(row, index);
512
+ }}
513
+ class={`inline-flex h-6 w-6 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-200/80 hover:text-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-800/80 dark:hover:text-neutral-100 ${
514
+ openRows.has(id) ? 'rotate-180' : ''
515
+ }`}
556
516
  >
557
- <EllipsisVertical class="h-4 w-4" />
517
+ <ChevronDown class="h-3.5 w-3.5" />
558
518
  </button>
559
- </div>
560
- {/if}
519
+ {/if}
520
+
521
+ <button
522
+ type="button"
523
+ onclick={(e) => openContextFromButton(e, row)}
524
+ class="inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-200/80 hover:text-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-800/80 dark:hover:text-neutral-100"
525
+ >
526
+ <EllipsisVertical class="h-4 w-4" />
527
+ </button>
528
+ </div>
561
529
  {/if}
562
530
  </div>
563
531
  </div>
@@ -601,10 +569,14 @@
601
569
  {/each}
602
570
  </div>
603
571
  {:else}
604
- <!-- GRID VIEW (igual que tu versión original; si necesitas que lo pegue completo, pégame el final del archivo) -->
605
572
  <div class="grid gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
606
573
  {#each controller.currentRows as row, index (rowIdFor(row, index))}
607
- <!-- … tu bloque grid view … -->
574
+ <div
575
+ 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"
576
+ oncontextmenu={(e) => openContextAt(e, row)}
577
+ >
578
+ <!-- tu grid content -->
579
+ </div>
608
580
  {/each}
609
581
  </div>
610
582
  {/if}
@@ -617,17 +589,14 @@
617
589
  </div>
618
590
 
619
591
  <DataTableFooter />
620
-
621
- <!-- ✅ CONTEXT POPOVER HOST -->
592
+ </div>
593
+ <!-- ✅ HOST fuera del overflow-auto (ya no lo recorta) -->
594
+ {#if contextOpen}
622
595
  <div
623
596
  bind:this={contextPopover}
624
- popover="manual"
625
597
  data-context-host="true"
626
- class="z-[1300] max-h-[calc(100vh-20px)] max-w-xs min-w-[190px] overflow-auto rounded-2xl border border-neutral-200/80 bg-neutral-50/95 p-1.5 text-xs text-neutral-900 shadow-[0_18px_50px_rgba(15,23,42,0.45)] backdrop-blur-2xl dark:border-neutral-700/80 dark:bg-neutral-900/95 dark:text-neutral-50"
627
- style={`position: fixed; left: ${contextRender.left}px; top: ${contextRender.top}px;`}
628
- onbeforetoggle={(e) => {
629
- if ((e as any).newState === 'closed') contextRow = null;
630
- }}
598
+ class="pointer-events-auto fixed z-[999999] max-h-[calc(100vh-24px)] max-w-xs min-w-[190px] overflow-auto rounded-2xl border border-neutral-200/80 bg-neutral-50/95 p-1.5 text-xs text-neutral-900 shadow-[0_18px_50px_rgba(15,23,42,0.45)] backdrop-blur-2xl dark:border-neutral-700/80 dark:bg-neutral-900/95 dark:text-neutral-50"
599
+ style={`left:${contextRender.left}px; top:${contextRender.top}px;`}
631
600
  >
632
601
  {#if contextRow && actions.length}
633
602
  <ContextMenu {actions} row={contextRow} onClose={closeContext} />
@@ -635,4 +604,4 @@
635
604
  <div class="flex flex-col gap-2">No hay acciones disponibles</div>
636
605
  {/if}
637
606
  </div>
638
- </div>
607
+ {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.34.5",
3
+ "version": "0.34.7",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.9",
6
6
  "publishConfig": {