@r2digisolutions/ui 0.34.5 → 0.34.6

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.
@@ -58,28 +58,25 @@
58
58
  );
59
59
 
60
60
  // =========================
61
- // CONTEXT MENU
61
+ // CONTEXT MENU (FIX)
62
62
  // =========================
63
63
  let contextPopover = $state<HTMLDivElement | null>(null);
64
64
  let contextRow = $state<T | null>(null);
65
65
 
66
- // punto preferido (cursor o botón)
66
+ // Punto preferido: cursor o botón (client coords)
67
67
  let contextPos = $state<{ x: number; y: number }>({ x: 0, y: 0 });
68
68
 
69
- // ✅ left/top reales (sin transform, para que no se salga nunca)
69
+ // ✅ Posición final (clamp + flip) en left/top reales
70
70
  let contextRender = $state<{ left: number; top: number }>({ left: 0, top: 0 });
71
71
 
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
72
+ const CONTEXT_MARGIN = 12;
73
+ const CONTEXT_GAP = 8;
75
74
 
76
75
  let openRows = $state<Set<string>>(new Set());
77
76
 
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,7 @@
89
86
  parts.push(`${w}px`);
90
87
  });
91
88
 
92
- // columna acciones
89
+ // Columna de acciones
93
90
  parts.push('64px');
94
91
  gridTemplate = parts.join(' ');
95
92
 
@@ -101,60 +98,65 @@
101
98
  let accLeft = controller.multiSelect ? 40 : 0;
102
99
  controller.mainColumns.forEach((col) => {
103
100
  const w = controller.getColumnWidth(col.id as keyof T);
104
- if (col.sticky === 'left') offsets[col.id as keyof T] = { left: accLeft };
101
+ if (col.sticky === 'left') {
102
+ offsets[col.id as keyof T] = { left: accLeft };
103
+ }
105
104
  accLeft += w;
106
105
  });
107
-
108
106
  stickyOffsets = offsets;
109
107
  });
110
108
 
111
109
  // CHECK ALL
112
110
  $effect(() => {
113
111
  if (!controller.multiSelect || !selectAllEl) return;
114
-
115
112
  if (!controller.currentRows.length) {
116
113
  selectAllEl.checked = false;
117
114
  selectAllEl.indeterminate = false;
118
115
  return;
119
116
  }
120
-
121
117
  selectAllEl.checked = controller.allVisibleSelected;
122
118
  selectAllEl.indeterminate = controller.someVisibleSelected;
123
119
  });
124
120
 
125
- // CERRAR CONTEXT MENU al click fuera
121
+ // CERRAR CONTEXT MENU (click fuera)
126
122
  $effect(() => {
127
123
  function handleDocumentClick(event: MouseEvent) {
128
124
  if (!contextOpen) return;
129
125
  const target = event.target as HTMLElement;
130
- if (!target.closest('[data-context-host="true"]')) closeContext();
126
+ if (!target.closest('[data-context-host="true"]')) {
127
+ closeContext();
128
+ }
131
129
  }
132
-
133
130
  if (contextOpen) document.addEventListener('click', handleDocumentClick);
134
-
135
- return () => document.removeEventListener('click', handleDocumentClick);
131
+ return () => {
132
+ document.removeEventListener('click', handleDocumentClick);
133
+ };
136
134
  });
137
135
 
138
- // ✅ Reposicionar en resize + scroll (capture)
136
+ // ✅ Reposicionar al hacer scroll/resize mientras está abierto
139
137
  $effect(() => {
140
138
  if (!contextOpen) return;
141
139
 
142
- const onWin = () => {
143
- positionContext(2);
140
+ const handler = () => {
141
+ // si se mueve el viewport o scroll, recalculamos
142
+ positionContext();
144
143
  };
145
144
 
146
- window.addEventListener('resize', onWin);
147
- window.addEventListener('scroll', onWin, { passive: true, capture: true });
145
+ window.addEventListener('resize', handler);
146
+ window.addEventListener('scroll', handler, { passive: true, capture: true });
147
+
148
+ // visualViewport es clave en móviles / zoom / barras
149
+ window.visualViewport?.addEventListener('resize', handler);
150
+ window.visualViewport?.addEventListener('scroll', handler);
148
151
 
149
152
  return () => {
150
- window.removeEventListener('resize', onWin);
151
- window.removeEventListener('scroll', onWin, true as any);
153
+ window.removeEventListener('resize', handler);
154
+ window.removeEventListener('scroll', handler, true as any);
155
+ window.visualViewport?.removeEventListener('resize', handler);
156
+ window.visualViewport?.removeEventListener('scroll', handler);
152
157
  };
153
158
  });
154
159
 
155
- // =========================
156
- // RESIZE COLUMNS
157
- // =========================
158
160
  let resizingId: keyof T | null = null;
159
161
  let startX = 0;
160
162
  let startWidth = 0;
@@ -172,7 +174,8 @@
172
174
  function onResizeMove(event: MouseEvent) {
173
175
  if (!resizingId) return;
174
176
  const dx = event.clientX - startX;
175
- controller.resizeColumn(resizingId, startWidth + dx);
177
+ const width = startWidth + dx;
178
+ controller.resizeColumn(resizingId, width);
176
179
  }
177
180
 
178
181
  function onResizeUp() {
@@ -181,9 +184,6 @@
181
184
  window.removeEventListener('mouseup', onResizeUp);
182
185
  }
183
186
 
184
- // =========================
185
- // HELPERS
186
- // =========================
187
187
  function rowIdFor(row: T, index: number) {
188
188
  return controller.getRowId(row, index);
189
189
  }
@@ -216,12 +216,13 @@
216
216
 
217
217
  function handleToggleAll(e: Event) {
218
218
  const input = e.currentTarget as HTMLInputElement;
219
- if (input.checked) controller.selectAllCurrentPage();
219
+ const checked = input.checked;
220
+ if (checked) controller.selectAllCurrentPage();
220
221
  else controller.unselectAllCurrentPage();
221
222
  }
222
223
 
223
224
  // =========================
224
- // ✅ Context positioning (real left/top, sin transform)
225
+ // ✅ Positioning helpers
225
226
  // =========================
226
227
  function clamp(n: number, min: number, max: number) {
227
228
  return Math.max(min, Math.min(max, n));
@@ -231,70 +232,81 @@
231
232
  return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
232
233
  }
233
234
 
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
- */
235
+ function getViewport() {
236
+ const vv = window.visualViewport;
237
+ if (vv) {
238
+ return {
239
+ left: vv.offsetLeft,
240
+ top: vv.offsetTop,
241
+ width: vv.width,
242
+ height: vv.height
243
+ };
244
+ }
245
+ return {
246
+ left: 0,
247
+ top: 0,
248
+ width: window.innerWidth,
249
+ height: window.innerHeight
250
+ };
251
+ }
252
+
239
253
  function computeContextPosition(preferred: { x: number; y: number }, pop: DOMRect) {
240
- const vw = window.innerWidth;
241
- const vh = window.innerHeight;
254
+ const vp = getViewport();
242
255
 
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;
256
+ const minLeft = vp.left + CONTEXT_MARGIN;
257
+ const minTop = vp.top + CONTEXT_MARGIN;
246
258
 
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
259
+ const maxLeft = vp.left + vp.width - CONTEXT_MARGIN - pop.width;
260
+ const maxTop = vp.top + vp.height - CONTEXT_MARGIN - pop.height;
252
261
 
253
- // Horizontal flip: si no cabe a la izquierda, poner a la derecha
254
- if (left < CONTEXT_MARGIN) {
262
+ // Si es demasiado alto, lo pegamos arriba (y el popover scrollea)
263
+ const tooTall = pop.height > vp.height - CONTEXT_MARGIN * 2;
264
+
265
+ // Preferencia UX original: izquierda + abajo
266
+ let left = preferred.x - pop.width;
267
+ let top = preferred.y + CONTEXT_GAP;
268
+
269
+ // Flip horizontal si no cabe a la izquierda
270
+ if (left < minLeft) {
255
271
  left = preferred.x + CONTEXT_GAP;
256
272
  }
257
273
 
258
- // Vertical flip: si no cabe abajo y NO es tooTall, poner arriba
274
+ // Flip vertical si no cabe abajo
259
275
  if (!tooTall) {
260
276
  const bottomIfDown = top + pop.height;
261
- const downFits = bottomIfDown <= vh - CONTEXT_MARGIN;
262
- if (!downFits) {
263
- top = preferred.y - CONTEXT_GAP - pop.height; // arriba
277
+ const bottomLimit = vp.top + vp.height - CONTEXT_MARGIN;
278
+ if (bottomIfDown > bottomLimit) {
279
+ top = preferred.y - CONTEXT_GAP - pop.height;
264
280
  }
281
+ } else {
282
+ top = minTop;
265
283
  }
266
284
 
267
- // Si es tooTall, lo pegamos a top margin y listo
268
- if (tooTall) {
269
- top = CONTEXT_MARGIN;
270
- }
271
-
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));
285
+ // Clamp final
286
+ left = clamp(left, minLeft, Math.max(minLeft, maxLeft));
287
+ top = clamp(top, minTop, Math.max(minTop, maxTop));
276
288
 
277
289
  return { left, top };
278
290
  }
279
291
 
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) {
292
+ async function positionContext() {
285
293
  if (!contextPopover || !contextRow) return;
286
294
 
295
+ // Asegura layout real (Popover API a veces “late layout”)
287
296
  await raf();
288
297
 
289
- // OJO: el popover debe estar visible para tener medidas reales
290
- const rect = contextPopover.getBoundingClientRect();
298
+ const rect1 = contextPopover.getBoundingClientRect();
299
+ if (!rect1.width || !rect1.height) return;
291
300
 
292
- if ((!rect.width || !rect.height) && retries > 0) {
293
- return positionContext(retries - 1);
294
- }
295
- if (!rect.width || !rect.height) return;
301
+ // pasada
302
+ contextRender = computeContextPosition(contextPos, rect1);
303
+
304
+ // 2ª pasada (por si cambia alto/ancho por scrollbar)
305
+ await raf();
306
+ const rect2 = contextPopover.getBoundingClientRect();
307
+ if (!rect2.width || !rect2.height) return;
296
308
 
297
- contextRender = computeContextPosition(contextPos, rect);
309
+ contextRender = computeContextPosition(contextPos, rect2);
298
310
  }
299
311
 
300
312
  async function openContextAt(event: MouseEvent, row: T) {
@@ -306,8 +318,9 @@
306
318
 
307
319
  if (contextPopover) contextPopover.showPopover();
308
320
 
321
+ // Espera render + posiciona
309
322
  await tick();
310
- await positionContext(3);
323
+ await positionContext();
311
324
  }
312
325
 
313
326
  async function openContextFromButton(event: MouseEvent, row: T) {
@@ -315,14 +328,15 @@
315
328
  event.stopPropagation();
316
329
 
317
330
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
318
-
319
331
  contextRow = row;
332
+
333
+ // Punto preferido: esquina inferior derecha
320
334
  contextPos = { x: rect.right, y: rect.bottom };
321
335
 
322
336
  if (contextPopover) contextPopover.showPopover();
323
337
 
324
338
  await tick();
325
- await positionContext(3);
339
+ await positionContext();
326
340
  }
327
341
 
328
342
  function closeContext() {
@@ -462,6 +476,7 @@
462
476
  {@const id = rowIdFor(row, index)}
463
477
 
464
478
  <div class="group relative">
479
+ <!-- Fila principal -->
465
480
  <!-- svelte-ignore a11y_click_events_have_key_events -->
466
481
  <div
467
482
  role="row"
@@ -495,7 +510,7 @@
495
510
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
496
511
  {@const sticky = stickyOffsets[col.id as keyof T]}
497
512
  <div
498
- class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50 ${
513
+ class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50${
499
514
  density === 'compact' ? 'py-1.5' : 'py-2.5'
500
515
  } dark:border-neutral-800/70 ${
501
516
  col.sticky === 'left'
@@ -507,7 +522,12 @@
507
522
  : ''}
508
523
  >
509
524
  {#if cell}
510
- {@render cell({ row, column: col, value, index })}
525
+ {@render cell({
526
+ row,
527
+ column: col,
528
+ value,
529
+ index
530
+ })}
511
531
  {:else}
512
532
  <span
513
533
  class={`line-clamp-2 text-black dark:text-neutral-50 ${
@@ -601,10 +621,28 @@
601
621
  {/each}
602
622
  </div>
603
623
  {:else}
604
- <!-- GRID VIEW (igual que tu versión original; si necesitas que lo pegue completo, pégame el final del archivo) -->
624
+ <!-- GRID VIEW -->
605
625
  <div class="grid gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
606
626
  {#each controller.currentRows as row, index (rowIdFor(row, index))}
607
- <!-- tu bloque grid view … -->
627
+ {@const id = rowIdFor(row, index)}
628
+ {@const cols = controller.mainColumns as any[]}
629
+ {@const firstCol = cols[0]}
630
+ {@const firstValue = firstCol
631
+ ? firstCol.accessor
632
+ ? firstCol.accessor(row)
633
+ : (row as any)[firstCol.id]
634
+ : null}
635
+ {@const restCols = cols.slice(1)}
636
+ <div
637
+ 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 ${
638
+ controller.selectedIds.has(id)
639
+ ? 'bg-purple-50/70 ring-1 ring-purple-400/70 dark:bg-purple-950/20'
640
+ : ''
641
+ }`}
642
+ oncontextmenu={(e) => openContextAt(e, row)}
643
+ >
644
+ <!-- ... tu grid view (igual que antes) ... -->
645
+ </div>
608
646
  {/each}
609
647
  </div>
610
648
  {/if}
@@ -618,12 +656,12 @@
618
656
 
619
657
  <DataTableFooter />
620
658
 
621
- <!-- ✅ CONTEXT POPOVER HOST -->
659
+ <!-- ✅ CONTEXT POPOVER (FIXED) -->
622
660
  <div
623
661
  bind:this={contextPopover}
624
662
  popover="manual"
625
663
  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"
664
+ class="z-[1300] 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"
627
665
  style={`position: fixed; left: ${contextRender.left}px; top: ${contextRender.top}px;`}
628
666
  onbeforetoggle={(e) => {
629
667
  if ((e as any).newState === 'closed') contextRow = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.34.5",
3
+ "version": "0.34.6",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.9",
6
6
  "publishConfig": {