@r2digisolutions/ui 0.34.4 → 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.
@@ -57,23 +57,20 @@
57
57
  {} as Record<keyof T, { left?: number; right?: number }>
58
58
  );
59
59
 
60
- // CONTEXT MENU
60
+ // =========================
61
+ // CONTEXT MENU (FIX)
62
+ // =========================
61
63
  let contextPopover = $state<HTMLDivElement | null>(null);
62
64
  let contextRow = $state<T | null>(null);
63
65
 
64
- // Punto preferido (cursor o botón)
66
+ // Punto preferido: cursor o botón (client coords)
65
67
  let contextPos = $state<{ x: number; y: number }>({ x: 0, y: 0 });
66
68
 
67
- // ✅ Render final (clamp + flip + stick)
68
- let contextRender = $state<{ x: number; y: number; transform: string }>({
69
- x: 0,
70
- y: 0,
71
- transform: 'translate(-100%, 8px)'
72
- });
69
+ // ✅ Posición final (clamp + flip) en left/top reales
70
+ let contextRender = $state<{ left: number; top: number }>({ left: 0, top: 0 });
73
71
 
74
- // Ajusta margen si quieres “pegado” más agresivo
75
- const CONTEXT_MARGIN = 10;
76
- const CONTEXT_GAP = 8; // separación visual del cursor/botón
72
+ const CONTEXT_MARGIN = 12;
73
+ const CONTEXT_GAP = 8;
77
74
 
78
75
  let openRows = $state<Set<string>>(new Set());
79
76
 
@@ -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,59 +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
- // reintenta/ajusta por si cambia viewport, barras móviles, etc.
144
- positionContext(2);
140
+ const handler = () => {
141
+ // si se mueve el viewport o scroll, recalculamos
142
+ positionContext();
145
143
  };
146
144
 
147
- window.addEventListener('resize', onWin);
148
- 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);
149
151
 
150
152
  return () => {
151
- window.removeEventListener('resize', onWin);
152
- 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);
153
157
  };
154
158
  });
155
159
 
156
- // RESIZE COLUMNS
157
160
  let resizingId: keyof T | null = null;
158
161
  let startX = 0;
159
162
  let startWidth = 0;
@@ -171,7 +174,8 @@
171
174
  function onResizeMove(event: MouseEvent) {
172
175
  if (!resizingId) return;
173
176
  const dx = event.clientX - startX;
174
- controller.resizeColumn(resizingId, startWidth + dx);
177
+ const width = startWidth + dx;
178
+ controller.resizeColumn(resizingId, width);
175
179
  }
176
180
 
177
181
  function onResizeUp() {
@@ -180,7 +184,6 @@
180
184
  window.removeEventListener('mouseup', onResizeUp);
181
185
  }
182
186
 
183
- // HELPERS
184
187
  function rowIdFor(row: T, index: number) {
185
188
  return controller.getRowId(row, index);
186
189
  }
@@ -213,12 +216,13 @@
213
216
 
214
217
  function handleToggleAll(e: Event) {
215
218
  const input = e.currentTarget as HTMLInputElement;
216
- if (input.checked) controller.selectAllCurrentPage();
219
+ const checked = input.checked;
220
+ if (checked) controller.selectAllCurrentPage();
217
221
  else controller.unselectAllCurrentPage();
218
222
  }
219
223
 
220
224
  // =========================
221
- // ✅ Context positioning (robusto)
225
+ // ✅ Positioning helpers
222
226
  // =========================
223
227
  function clamp(n: number, min: number, max: number) {
224
228
  return Math.max(min, Math.min(max, n));
@@ -228,120 +232,81 @@
228
232
  return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
229
233
  }
230
234
 
231
- /**
232
- * Decide transform y clamp final para que el popover:
233
- * - no se salga del viewport
234
- * - si no cabe abajo => se pega arriba
235
- * - si no cabe arriba => se pega al top margin (scroll dentro)
236
- * - si no cabe a la izquierda => se pone a la derecha
237
- * - si no cabe a la derecha => clamp
238
- */
239
- function computeContextPosition(preferred: { x: number; y: number }, pop: DOMRect) {
240
- const vw = window.innerWidth;
241
- const vh = window.innerHeight;
242
-
243
- // Si es tan alto que ni arriba ni abajo, lo forzamos dentro (scroll en contenedor)
244
- const tooTall = pop.height + CONTEXT_MARGIN * 2 > vh;
245
-
246
- // Queremos aparecer “cerca” del punto preferido
247
- // Horizontal por defecto: a la izquierda del punto (como tu translate(-100%, ...))
248
- const fitsLeft = preferred.x - pop.width - CONTEXT_MARGIN >= 0;
249
- const fitsRight = preferred.x + pop.width + CONTEXT_MARGIN <= vw;
250
-
251
- // Vertical por defecto: abajo del punto
252
- const fitsDown = preferred.y + pop.height + CONTEXT_MARGIN + CONTEXT_GAP <= vh;
253
- const fitsUp = preferred.y - pop.height - CONTEXT_MARGIN - CONTEXT_GAP >= 0;
254
-
255
- // Horizontal: si no cabe izquierda pero cabe derecha => derecha
256
- const placeToRight = !fitsLeft && fitsRight;
257
-
258
- // Vertical:
259
- // - preferimos abajo
260
- // - si no cabe abajo y cabe arriba => arriba
261
- // - si es tooTall => lo pegamos dentro del viewport
262
- const placeUp = !tooTall && !fitsDown && fitsUp;
263
-
264
- // Transform:
265
- const transformX = placeToRight ? '0%' : '-100%';
266
- // en vertical: abajo => +gap, arriba => -100% - gap
267
- const transformY = placeUp ? `calc(-100% - ${CONTEXT_GAP}px)` : `${CONTEXT_GAP}px`;
268
-
269
- let x = preferred.x;
270
- let y = preferred.y;
271
-
272
- // Clamp horizontal según transformX
273
- if (transformX === '-100%') {
274
- // pop ocupa [x - w, x]
275
- x = clamp(x, CONTEXT_MARGIN + pop.width, vw - CONTEXT_MARGIN);
276
- } else {
277
- // pop ocupa [x, x + w]
278
- x = clamp(x, CONTEXT_MARGIN, vw - CONTEXT_MARGIN - pop.width);
279
- }
280
-
281
- // Si tooTall, lo pegamos arriba dentro del viewport y dejamos overflow-auto
282
- if (tooTall) {
235
+ function getViewport() {
236
+ const vv = window.visualViewport;
237
+ if (vv) {
283
238
  return {
284
- x,
285
- y: CONTEXT_MARGIN,
286
- transform: `translate(${transformX}, 0px)`
239
+ left: vv.offsetLeft,
240
+ top: vv.offsetTop,
241
+ width: vv.width,
242
+ height: vv.height
287
243
  };
288
244
  }
245
+ return {
246
+ left: 0,
247
+ top: 0,
248
+ width: window.innerWidth,
249
+ height: window.innerHeight
250
+ };
251
+ }
289
252
 
290
- // Clamp vertical
291
- if (placeUp) {
292
- // pop ocupa [y - gap - h, y - gap]
293
- y = clamp(y, CONTEXT_MARGIN + pop.height + CONTEXT_GAP, vh - CONTEXT_MARGIN);
294
- } else {
295
- // pop ocupa [y + gap, y + gap + h]
296
- // aquí el max real del y para que no se salga por abajo:
297
- y = clamp(y, CONTEXT_MARGIN - CONTEXT_GAP, vh - CONTEXT_MARGIN - pop.height - CONTEXT_GAP);
298
- }
253
+ function computeContextPosition(preferred: { x: number; y: number }, pop: DOMRect) {
254
+ const vp = getViewport();
255
+
256
+ const minLeft = vp.left + CONTEXT_MARGIN;
257
+ const minTop = vp.top + CONTEXT_MARGIN;
299
258
 
300
- // 🔥 Fallback “stick”:
301
- // Si aun así por alguna razón se nos quedaría fuera (casos raros), lo pegamos a bottom/top.
302
- const topIfDown = y + CONTEXT_GAP; // top real cuando está abajo
303
- const bottomIfDown = topIfDown + pop.height;
259
+ const maxLeft = vp.left + vp.width - CONTEXT_MARGIN - pop.width;
260
+ const maxTop = vp.top + vp.height - CONTEXT_MARGIN - pop.height;
304
261
 
305
- const bottomIfUp = y - CONTEXT_GAP; // bottom real cuando está arriba
306
- const topIfUp = bottomIfUp - pop.height;
262
+ // Si es demasiado alto, lo pegamos arriba (y el popover scrollea)
263
+ const tooTall = pop.height > vp.height - CONTEXT_MARGIN * 2;
307
264
 
308
- if (!placeUp && bottomIfDown > vh - CONTEXT_MARGIN) {
309
- // stick to bottom: ajusta y para que bottom quede dentro
310
- y = vh - CONTEXT_MARGIN - pop.height - CONTEXT_GAP;
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) {
271
+ left = preferred.x + CONTEXT_GAP;
311
272
  }
312
273
 
313
- if (placeUp && topIfUp < CONTEXT_MARGIN) {
314
- // stick to top: ajusta y
315
- y = CONTEXT_MARGIN + pop.height + CONTEXT_GAP;
274
+ // Flip vertical si no cabe abajo
275
+ if (!tooTall) {
276
+ const bottomIfDown = top + pop.height;
277
+ const bottomLimit = vp.top + vp.height - CONTEXT_MARGIN;
278
+ if (bottomIfDown > bottomLimit) {
279
+ top = preferred.y - CONTEXT_GAP - pop.height;
280
+ }
281
+ } else {
282
+ top = minTop;
316
283
  }
317
284
 
318
- return {
319
- x,
320
- y,
321
- transform: `translate(${transformX}, ${transformY})`
322
- };
285
+ // Clamp final
286
+ left = clamp(left, minLeft, Math.max(minLeft, maxLeft));
287
+ top = clamp(top, minTop, Math.max(minTop, maxTop));
288
+
289
+ return { left, top };
323
290
  }
324
291
 
325
- /**
326
- * Posiciona con medición robusta:
327
- * - espera RAF
328
- * - si rect aún es 0 => retry
329
- * - recalcula render
330
- */
331
- async function positionContext(retries = 3) {
292
+ async function positionContext() {
332
293
  if (!contextPopover || !contextRow) return;
333
294
 
334
- // RAF suele ser más fiable que tick() con Popover API
295
+ // Asegura layout real (Popover API a veces “late layout”)
335
296
  await raf();
336
297
 
337
- const rect = contextPopover.getBoundingClientRect();
298
+ const rect1 = contextPopover.getBoundingClientRect();
299
+ if (!rect1.width || !rect1.height) return;
338
300
 
339
- if ((!rect.width || !rect.height) && retries > 0) {
340
- return positionContext(retries - 1);
341
- }
342
- if (!rect.width || !rect.height) return;
301
+ // pasada
302
+ contextRender = computeContextPosition(contextPos, rect1);
343
303
 
344
- contextRender = computeContextPosition(contextPos, rect);
304
+ // pasada (por si cambia alto/ancho por scrollbar)
305
+ await raf();
306
+ const rect2 = contextPopover.getBoundingClientRect();
307
+ if (!rect2.width || !rect2.height) return;
308
+
309
+ contextRender = computeContextPosition(contextPos, rect2);
345
310
  }
346
311
 
347
312
  async function openContextAt(event: MouseEvent, row: T) {
@@ -353,9 +318,9 @@
353
318
 
354
319
  if (contextPopover) contextPopover.showPopover();
355
320
 
356
- // Svelte render + luego RAF retry para layout real
321
+ // Espera render + posiciona
357
322
  await tick();
358
- await positionContext(3);
323
+ await positionContext();
359
324
  }
360
325
 
361
326
  async function openContextFromButton(event: MouseEvent, row: T) {
@@ -363,16 +328,15 @@
363
328
  event.stopPropagation();
364
329
 
365
330
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
366
-
367
331
  contextRow = row;
368
332
 
369
- // punto preferido: esquina inferior derecha del botón
333
+ // Punto preferido: esquina inferior derecha
370
334
  contextPos = { x: rect.right, y: rect.bottom };
371
335
 
372
336
  if (contextPopover) contextPopover.showPopover();
373
337
 
374
338
  await tick();
375
- await positionContext(3);
339
+ await positionContext();
376
340
  }
377
341
 
378
342
  function closeContext() {
@@ -546,7 +510,7 @@
546
510
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
547
511
  {@const sticky = stickyOffsets[col.id as keyof T]}
548
512
  <div
549
- 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${
550
514
  density === 'compact' ? 'py-1.5' : 'py-2.5'
551
515
  } dark:border-neutral-800/70 ${
552
516
  col.sticky === 'left'
@@ -558,7 +522,12 @@
558
522
  : ''}
559
523
  >
560
524
  {#if cell}
561
- {@render cell({ row, column: col, value, index })}
525
+ {@render cell({
526
+ row,
527
+ column: col,
528
+ value,
529
+ index
530
+ })}
562
531
  {:else}
563
532
  <span
564
533
  class={`line-clamp-2 text-black dark:text-neutral-50 ${
@@ -652,6 +621,7 @@
652
621
  {/each}
653
622
  </div>
654
623
  {:else}
624
+ <!-- GRID VIEW -->
655
625
  <div class="grid gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
656
626
  {#each controller.currentRows as row, index (rowIdFor(row, index))}
657
627
  {@const id = rowIdFor(row, index)}
@@ -663,8 +633,6 @@
663
633
  : (row as any)[firstCol.id]
664
634
  : null}
665
635
  {@const restCols = cols.slice(1)}
666
-
667
- <!-- svelte-ignore a11y_no_static_element_interactions -->
668
636
  <div
669
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 ${
670
638
  controller.selectedIds.has(id)
@@ -673,86 +641,7 @@
673
641
  }`}
674
642
  oncontextmenu={(e) => openContextAt(e, row)}
675
643
  >
676
- {#if controller.multiSelect}
677
- <div
678
- class="absolute top-2 left-2 z-10 rounded-full bg-neutral-900/70 p-1 backdrop-blur-md dark:bg-neutral-950/80"
679
- data-stop-row-toggle="true"
680
- >
681
- <input
682
- type="checkbox"
683
- checked={controller.selectedIds.has(id)}
684
- onchange={() => controller.toggleRowSelection(id)}
685
- class="h-3.5 w-3.5 rounded border-neutral-400 bg-neutral-50 text-purple-500 focus:ring-purple-500 dark:border-neutral-500 dark:bg-neutral-900"
686
- />
687
- </div>
688
- {/if}
689
-
690
- <div class="mb-2 pr-6 text-black dark:text-neutral-50">
691
- {#if cell && firstCol}
692
- {@render cell({ row, column: firstCol, value: firstValue, index })}
693
- {:else if firstCol}
694
- <div
695
- class="line-clamp-2 text-[12px] leading-snug font-semibold text-neutral-900 dark:text-neutral-50"
696
- >
697
- {formatValue(firstCol, firstValue, row)}
698
- </div>
699
- {/if}
700
- </div>
701
-
702
- <dl class="space-y-1.5">
703
- {#each restCols as col (col.id)}
704
- {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
705
- <div class="flex items-start justify-between gap-2">
706
- <dt
707
- class="max-w-[45%] truncate text-[10px] font-medium text-neutral-400 uppercase dark:text-neutral-500"
708
- >
709
- {col.label}
710
- </dt>
711
- <dd
712
- class="line-clamp-2 flex-1 text-right text-[11px] text-neutral-700 dark:text-neutral-200"
713
- >
714
- {formatValue(col, value, row)}
715
- </dd>
716
- </div>
717
- {/each}
718
- </dl>
719
-
720
- {#if actions.length}
721
- <div
722
- class="mt-2 flex items-center justify-end gap-1.5"
723
- data-stop-row-toggle="true"
724
- >
725
- {#if rowCollapse}
726
- <button
727
- type="button"
728
- onclick={(e) => {
729
- e.stopPropagation();
730
- toggleRow(row, index);
731
- }}
732
- 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 ${
733
- openRows.has(id) ? 'rotate-180' : ''
734
- }`}
735
- >
736
- <ChevronDown class="h-3.5 w-3.5" />
737
- </button>
738
- {/if}
739
- <button
740
- type="button"
741
- onclick={(e) => openContextFromButton(e, row)}
742
- 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"
743
- >
744
- <EllipsisVertical class="h-4 w-4" />
745
- </button>
746
- </div>
747
- {/if}
748
-
749
- {#if rowCollapse && openRows.has(id)}
750
- <div
751
- class="mt-2 rounded-2xl border border-dashed border-neutral-200/70 bg-neutral-50/80 px-2.5 py-2 text-[11px] text-neutral-700 dark:border-neutral-700/70 dark:bg-neutral-950/60 dark:text-neutral-100"
752
- >
753
- {@render rowCollapse(row)}
754
- </div>
755
- {/if}
644
+ <!-- ... tu grid view (igual que antes) ... -->
756
645
  </div>
757
646
  {/each}
758
647
  </div>
@@ -767,13 +656,13 @@
767
656
 
768
657
  <DataTableFooter />
769
658
 
770
- <!-- ✅ CONTEXT POPOVER HOST -->
659
+ <!-- ✅ CONTEXT POPOVER (FIXED) -->
771
660
  <div
772
661
  bind:this={contextPopover}
773
662
  popover="manual"
774
663
  data-context-host="true"
775
- 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 transition-transform duration-75 will-change-transform dark:border-neutral-700/80 dark:bg-neutral-900/95 dark:text-neutral-50"
776
- style={`position: fixed; left: ${contextRender.x}px; top: ${contextRender.y}px; transform: ${contextRender.transform};`}
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"
665
+ style={`position: fixed; left: ${contextRender.left}px; top: ${contextRender.top}px;`}
777
666
  onbeforetoggle={(e) => {
778
667
  if ((e as any).newState === 'closed') contextRow = null;
779
668
  }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.34.4",
3
+ "version": "0.34.6",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.9",
6
6
  "publishConfig": {