@r2digisolutions/ui 0.34.1 → 0.34.2

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.
@@ -61,17 +61,19 @@
61
61
  let contextPopover = $state<HTMLDivElement | null>(null);
62
62
  let contextRow = $state<T | null>(null);
63
63
 
64
- // punto preferido donde se abrió (cursor o botón)
64
+ // Punto preferido (cursor o botón)
65
65
  let contextPos = $state<{ x: number; y: number }>({ x: 0, y: 0 });
66
66
 
67
- // ✅ posición final render (clamp + flip)
67
+ // ✅ Render final (clamp + flip + stick)
68
68
  let contextRender = $state<{ x: number; y: number; transform: string }>({
69
69
  x: 0,
70
70
  y: 0,
71
71
  transform: 'translate(-100%, 8px)'
72
72
  });
73
73
 
74
+ // Ajusta margen si quieres “pegado” más agresivo
74
75
  const CONTEXT_MARGIN = 10;
76
+ const CONTEXT_GAP = 8; // separación visual del cursor/botón
75
77
 
76
78
  let openRows = $state<Set<string>>(new Set());
77
79
 
@@ -87,7 +89,7 @@
87
89
  parts.push(`${w}px`);
88
90
  });
89
91
 
90
- // Columna de acciones
92
+ // Columna acciones
91
93
  parts.push('64px');
92
94
  gridTemplate = parts.join(' ');
93
95
 
@@ -99,51 +101,50 @@
99
101
  let accLeft = controller.multiSelect ? 40 : 0;
100
102
  controller.mainColumns.forEach((col) => {
101
103
  const w = controller.getColumnWidth(col.id as keyof T);
102
- if (col.sticky === 'left') {
103
- offsets[col.id as keyof T] = { left: accLeft };
104
- }
104
+ if (col.sticky === 'left') offsets[col.id as keyof T] = { left: accLeft };
105
105
  accLeft += w;
106
106
  });
107
+
107
108
  stickyOffsets = offsets;
108
109
  });
109
110
 
110
111
  // CHECK ALL
111
112
  $effect(() => {
112
113
  if (!controller.multiSelect || !selectAllEl) return;
114
+
113
115
  if (!controller.currentRows.length) {
114
116
  selectAllEl.checked = false;
115
117
  selectAllEl.indeterminate = false;
116
118
  return;
117
119
  }
120
+
118
121
  selectAllEl.checked = controller.allVisibleSelected;
119
122
  selectAllEl.indeterminate = controller.someVisibleSelected;
120
123
  });
121
124
 
122
- // CERRAR CONTEXT MENU
125
+ // CERRAR CONTEXT MENU al click fuera
123
126
  $effect(() => {
124
127
  function handleDocumentClick(event: MouseEvent) {
125
128
  if (!contextOpen) return;
126
129
  const target = event.target as HTMLElement;
127
- if (!target.closest('[data-context-host="true"]')) {
128
- closeContext();
129
- }
130
+ if (!target.closest('[data-context-host="true"]')) closeContext();
130
131
  }
131
- if (contextOpen) {
132
- document.addEventListener('click', handleDocumentClick);
133
- }
134
- return () => {
135
- document.removeEventListener('click', handleDocumentClick);
136
- };
132
+
133
+ if (contextOpen) document.addEventListener('click', handleDocumentClick);
134
+
135
+ return () => document.removeEventListener('click', handleDocumentClick);
137
136
  });
138
137
 
139
- // ✅ Reposicionar en resize / scroll (capture) mientras esté abierto
138
+ // ✅ Reposicionar en resize + scroll (capture)
140
139
  $effect(() => {
141
140
  if (!contextOpen) return;
142
141
 
143
- const onWin = () => positionContext();
142
+ const onWin = () => {
143
+ // reintenta/ajusta por si cambia viewport, barras móviles, etc.
144
+ positionContext(2);
145
+ };
144
146
 
145
147
  window.addEventListener('resize', onWin);
146
- // capture: true para enterarte del scroll en contenedores overflow-auto
147
148
  window.addEventListener('scroll', onWin, { passive: true, capture: true });
148
149
 
149
150
  return () => {
@@ -152,6 +153,7 @@
152
153
  };
153
154
  });
154
155
 
156
+ // RESIZE COLUMNS
155
157
  let resizingId: keyof T | null = null;
156
158
  let startX = 0;
157
159
  let startWidth = 0;
@@ -169,8 +171,7 @@
169
171
  function onResizeMove(event: MouseEvent) {
170
172
  if (!resizingId) return;
171
173
  const dx = event.clientX - startX;
172
- const width = startWidth + dx;
173
- controller.resizeColumn(resizingId, width);
174
+ controller.resizeColumn(resizingId, startWidth + dx);
174
175
  }
175
176
 
176
177
  function onResizeUp() {
@@ -179,6 +180,7 @@
179
180
  window.removeEventListener('mouseup', onResizeUp);
180
181
  }
181
182
 
183
+ // HELPERS
182
184
  function rowIdFor(row: T, index: number) {
183
185
  return controller.getRowId(row, index);
184
186
  }
@@ -194,35 +196,80 @@
194
196
  return String(value);
195
197
  }
196
198
 
199
+ function toggleRow(row: T, index: number) {
200
+ if (!rowCollapse) return;
201
+ const id = rowIdFor(row, index);
202
+ const next = new Set(openRows);
203
+ if (next.has(id)) next.delete(id);
204
+ else next.add(id);
205
+ openRows = next;
206
+ }
207
+
208
+ function handleRowClick(event: MouseEvent, row: T, index: number) {
209
+ const target = event.target as HTMLElement;
210
+ if (target.closest('[data-stop-row-toggle="true"]')) return;
211
+ toggleRow(row, index);
212
+ }
213
+
214
+ function handleToggleAll(e: Event) {
215
+ const input = e.currentTarget as HTMLInputElement;
216
+ if (input.checked) controller.selectAllCurrentPage();
217
+ else controller.unselectAllCurrentPage();
218
+ }
219
+
197
220
  // =========================
198
- // ✅ Context positioning logic
221
+ // ✅ Context positioning (robusto)
199
222
  // =========================
200
223
  function clamp(n: number, min: number, max: number) {
201
224
  return Math.max(min, Math.min(max, n));
202
225
  }
203
226
 
227
+ function raf() {
228
+ return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
229
+ }
230
+
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
+ */
204
239
  function computeContextPosition(preferred: { x: number; y: number }, pop: DOMRect) {
205
240
  const vw = window.innerWidth;
206
241
  const vh = window.innerHeight;
207
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%, ...))
208
248
  const fitsLeft = preferred.x - pop.width - CONTEXT_MARGIN >= 0;
209
249
  const fitsRight = preferred.x + pop.width + CONTEXT_MARGIN <= vw;
210
- const fitsDown = preferred.y + pop.height + CONTEXT_MARGIN <= vh;
211
- const fitsUp = preferred.y - pop.height - CONTEXT_MARGIN >= 0;
212
250
 
213
- // Horizontal: preferimos “a la izquierda” (como translate(-100%, ...))
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
214
256
  const placeToRight = !fitsLeft && fitsRight;
215
257
 
216
- // Vertical: preferimos “abajo”
217
- const placeUp = !fitsDown && fitsUp;
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;
218
263
 
219
- let transformX = placeToRight ? '0%' : '-100%';
220
- let transformY = placeUp ? 'calc(-100% - 8px)' : '8px';
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`;
221
268
 
222
269
  let x = preferred.x;
223
270
  let y = preferred.y;
224
271
 
225
- // Clamp horizontal según transform
272
+ // Clamp horizontal según transformX
226
273
  if (transformX === '-100%') {
227
274
  // pop ocupa [x - w, x]
228
275
  x = clamp(x, CONTEXT_MARGIN + pop.width, vw - CONTEXT_MARGIN);
@@ -231,13 +278,41 @@
231
278
  x = clamp(x, CONTEXT_MARGIN, vw - CONTEXT_MARGIN - pop.width);
232
279
  }
233
280
 
234
- // Clamp vertical según lado
281
+ // Si tooTall, lo pegamos arriba dentro del viewport y dejamos overflow-auto
282
+ if (tooTall) {
283
+ return {
284
+ x,
285
+ y: CONTEXT_MARGIN,
286
+ transform: `translate(${transformX}, 0px)`
287
+ };
288
+ }
289
+
290
+ // Clamp vertical
235
291
  if (placeUp) {
236
- // pop ocupa [y - 8 - h, y - 8]
237
- y = clamp(y, CONTEXT_MARGIN + pop.height + 8, vh - CONTEXT_MARGIN);
292
+ // pop ocupa [y - gap - h, y - gap]
293
+ y = clamp(y, CONTEXT_MARGIN + pop.height + CONTEXT_GAP, vh - CONTEXT_MARGIN);
238
294
  } else {
239
- // pop ocupa [y + 8, y + 8 + h]
240
- y = clamp(y, CONTEXT_MARGIN - 8, vh - CONTEXT_MARGIN - pop.height - 8);
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
+ }
299
+
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;
304
+
305
+ const bottomIfUp = y - CONTEXT_GAP; // bottom real cuando está arriba
306
+ const topIfUp = bottomIfUp - pop.height;
307
+
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;
311
+ }
312
+
313
+ if (placeUp && topIfUp < CONTEXT_MARGIN) {
314
+ // stick to top: ajusta y
315
+ y = CONTEXT_MARGIN + pop.height + CONTEXT_GAP;
241
316
  }
242
317
 
243
318
  return {
@@ -247,73 +322,63 @@
247
322
  };
248
323
  }
249
324
 
250
- async function positionContext() {
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) {
251
332
  if (!contextPopover || !contextRow) return;
252
333
 
253
- // Si está cerrado, evita medir
254
- // (aunque contextRow sea truthy, por seguridad)
334
+ // RAF suele ser más fiable que tick() con Popover API
335
+ await raf();
336
+
255
337
  const rect = contextPopover.getBoundingClientRect();
338
+
339
+ if ((!rect.width || !rect.height) && retries > 0) {
340
+ return positionContext(retries - 1);
341
+ }
256
342
  if (!rect.width || !rect.height) return;
257
343
 
258
- const next = computeContextPosition(contextPos, rect);
259
- contextRender = next;
344
+ contextRender = computeContextPosition(contextPos, rect);
260
345
  }
261
346
 
262
347
  async function openContextAt(event: MouseEvent, row: T) {
263
348
  event.preventDefault();
264
349
  event.stopPropagation();
350
+
265
351
  contextRow = row;
266
352
  contextPos = { x: event.clientX, y: event.clientY };
353
+
267
354
  if (contextPopover) contextPopover.showPopover();
268
355
 
356
+ // Svelte render + luego RAF retry para layout real
269
357
  await tick();
270
- await positionContext();
358
+ await positionContext(3);
271
359
  }
272
360
 
273
361
  async function openContextFromButton(event: MouseEvent, row: T) {
274
362
  event.preventDefault();
275
363
  event.stopPropagation();
364
+
276
365
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
366
+
277
367
  contextRow = row;
278
368
 
279
- // Punto preferido: esquina inferior derecha del botón
369
+ // punto preferido: esquina inferior derecha del botón
280
370
  contextPos = { x: rect.right, y: rect.bottom };
281
371
 
282
372
  if (contextPopover) contextPopover.showPopover();
283
373
 
284
374
  await tick();
285
- await positionContext();
375
+ await positionContext(3);
286
376
  }
287
377
 
288
378
  function closeContext() {
289
379
  if (contextPopover) contextPopover.hidePopover();
290
380
  contextRow = null;
291
381
  }
292
-
293
- function toggleRow(row: T, index: number) {
294
- if (!rowCollapse) return;
295
- const id = rowIdFor(row, index);
296
- const next = new Set(openRows);
297
- if (next.has(id)) next.delete(id);
298
- else next.add(id);
299
- openRows = next;
300
- }
301
-
302
- function handleRowClick(event: MouseEvent, row: T, index: number) {
303
- const target = event.target as HTMLElement;
304
- if (target.closest('[data-stop-row-toggle="true"]')) return;
305
- toggleRow(row, index);
306
- }
307
-
308
- function handleToggleAll(e: Event) {
309
- const input = e.currentTarget as HTMLInputElement;
310
- const checked = input.checked;
311
- if (checked) {
312
- controller.selectAllCurrentPage();
313
- } else {
314
- controller.unselectAllCurrentPage();
315
- }
316
- }
317
382
  </script>
318
383
 
319
384
  <div
@@ -481,7 +546,7 @@
481
546
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
482
547
  {@const sticky = stickyOffsets[col.id as keyof T]}
483
548
  <div
484
- class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50${
549
+ class={`flex items-center border-r border-neutral-200/60 px-3 text-black dark:text-neutral-50 ${
485
550
  density === 'compact' ? 'py-1.5' : 'py-2.5'
486
551
  } dark:border-neutral-800/70 ${
487
552
  col.sticky === 'left'
@@ -493,12 +558,7 @@
493
558
  : ''}
494
559
  >
495
560
  {#if cell}
496
- {@render cell({
497
- row,
498
- column: col,
499
- value,
500
- index
501
- })}
561
+ {@render cell({ row, column: col, value, index })}
502
562
  {:else}
503
563
  <span
504
564
  class={`line-clamp-2 text-black dark:text-neutral-50 ${
@@ -604,6 +664,7 @@
604
664
  : (row as any)[firstCol.id]
605
665
  : null}
606
666
  {@const restCols = cols.slice(1)}
667
+
607
668
  <!-- svelte-ignore a11y_no_static_element_interactions -->
608
669
  <div
609
670
  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 ${
@@ -629,12 +690,7 @@
629
690
 
630
691
  <div class="mb-2 pr-6 text-black dark:text-neutral-50">
631
692
  {#if cell && firstCol}
632
- {@render cell({
633
- row,
634
- column: firstCol,
635
- value: firstValue,
636
- index
637
- })}
693
+ {@render cell({ row, column: firstCol, value: firstValue, index })}
638
694
  {:else if firstCol}
639
695
  <div
640
696
  class="line-clamp-2 text-[12px] leading-snug font-semibold text-neutral-900 dark:text-neutral-50"
@@ -712,11 +768,12 @@
712
768
 
713
769
  <DataTableFooter />
714
770
 
771
+ <!-- ✅ CONTEXT POPOVER HOST -->
715
772
  <div
716
773
  bind:this={contextPopover}
717
774
  popover="manual"
718
775
  data-context-host="true"
719
- class="z-[1300] max-w-xs min-w-[190px] 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
+ 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"
720
777
  style={`position: fixed; left: ${contextRender.x}px; top: ${contextRender.y}px; transform: ${contextRender.transform};`}
721
778
  onbeforetoggle={(e) => {
722
779
  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.1",
3
+ "version": "0.34.2",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.8",
6
6
  "publishConfig": {