@r2digisolutions/ui 0.34.0 → 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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" generics="T">
2
2
  import type { Snippet } from 'svelte';
3
+ import { tick } from 'svelte';
3
4
  import { EllipsisVertical, ChevronDown } from 'lucide-svelte';
4
5
  import type { ColumnDef, RowAction } from './core/types.js';
5
6
  import type { DataTableController } from './core/DataTableController.svelte';
@@ -56,10 +57,24 @@
56
57
  {} as Record<keyof T, { left?: number; right?: number }>
57
58
  );
58
59
 
60
+ // CONTEXT MENU
59
61
  let contextPopover = $state<HTMLDivElement | null>(null);
60
62
  let contextRow = $state<T | null>(null);
63
+
64
+ // Punto “preferido” (cursor o botón)
61
65
  let contextPos = $state<{ x: number; y: number }>({ x: 0, y: 0 });
62
66
 
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
+ });
73
+
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
77
+
63
78
  let openRows = $state<Set<string>>(new Set());
64
79
 
65
80
  const contextOpen = $derived(contextRow !== null);
@@ -74,7 +89,7 @@
74
89
  parts.push(`${w}px`);
75
90
  });
76
91
 
77
- // Columna de acciones
92
+ // Columna acciones
78
93
  parts.push('64px');
79
94
  gridTemplate = parts.join(' ');
80
95
 
@@ -86,43 +101,59 @@
86
101
  let accLeft = controller.multiSelect ? 40 : 0;
87
102
  controller.mainColumns.forEach((col) => {
88
103
  const w = controller.getColumnWidth(col.id as keyof T);
89
- if (col.sticky === 'left') {
90
- offsets[col.id as keyof T] = { left: accLeft };
91
- }
104
+ if (col.sticky === 'left') offsets[col.id as keyof T] = { left: accLeft };
92
105
  accLeft += w;
93
106
  });
107
+
94
108
  stickyOffsets = offsets;
95
109
  });
96
110
 
97
111
  // CHECK ALL
98
112
  $effect(() => {
99
113
  if (!controller.multiSelect || !selectAllEl) return;
114
+
100
115
  if (!controller.currentRows.length) {
101
116
  selectAllEl.checked = false;
102
117
  selectAllEl.indeterminate = false;
103
118
  return;
104
119
  }
120
+
105
121
  selectAllEl.checked = controller.allVisibleSelected;
106
122
  selectAllEl.indeterminate = controller.someVisibleSelected;
107
123
  });
108
124
 
109
- // CERRAR CONTEXT MENU
125
+ // CERRAR CONTEXT MENU al click fuera
110
126
  $effect(() => {
111
127
  function handleDocumentClick(event: MouseEvent) {
112
128
  if (!contextOpen) return;
113
129
  const target = event.target as HTMLElement;
114
- if (!target.closest('[data-context-host="true"]')) {
115
- closeContext();
116
- }
117
- }
118
- if (contextOpen) {
119
- document.addEventListener('click', handleDocumentClick);
130
+ if (!target.closest('[data-context-host="true"]')) closeContext();
120
131
  }
132
+
133
+ if (contextOpen) document.addEventListener('click', handleDocumentClick);
134
+
135
+ return () => document.removeEventListener('click', handleDocumentClick);
136
+ });
137
+
138
+ // ✅ Reposicionar en resize + scroll (capture)
139
+ $effect(() => {
140
+ if (!contextOpen) return;
141
+
142
+ const onWin = () => {
143
+ // reintenta/ajusta por si cambia viewport, barras móviles, etc.
144
+ positionContext(2);
145
+ };
146
+
147
+ window.addEventListener('resize', onWin);
148
+ window.addEventListener('scroll', onWin, { passive: true, capture: true });
149
+
121
150
  return () => {
122
- document.removeEventListener('click', handleDocumentClick);
151
+ window.removeEventListener('resize', onWin);
152
+ window.removeEventListener('scroll', onWin, true as any);
123
153
  };
124
154
  });
125
155
 
156
+ // RESIZE COLUMNS
126
157
  let resizingId: keyof T | null = null;
127
158
  let startX = 0;
128
159
  let startWidth = 0;
@@ -140,8 +171,7 @@
140
171
  function onResizeMove(event: MouseEvent) {
141
172
  if (!resizingId) return;
142
173
  const dx = event.clientX - startX;
143
- const width = startWidth + dx;
144
- controller.resizeColumn(resizingId, width);
174
+ controller.resizeColumn(resizingId, startWidth + dx);
145
175
  }
146
176
 
147
177
  function onResizeUp() {
@@ -150,6 +180,7 @@
150
180
  window.removeEventListener('mouseup', onResizeUp);
151
181
  }
152
182
 
183
+ // HELPERS
153
184
  function rowIdFor(row: T, index: number) {
154
185
  return controller.getRowId(row, index);
155
186
  }
@@ -165,28 +196,6 @@
165
196
  return String(value);
166
197
  }
167
198
 
168
- function openContextAt(event: MouseEvent, row: T) {
169
- event.preventDefault();
170
- event.stopPropagation();
171
- contextRow = row;
172
- contextPos = { x: event.clientX, y: event.clientY };
173
- if (contextPopover) contextPopover.showPopover();
174
- }
175
-
176
- function openContextFromButton(event: MouseEvent, row: T) {
177
- event.preventDefault();
178
- event.stopPropagation();
179
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
180
- contextRow = row;
181
- contextPos = { x: rect.right, y: rect.bottom };
182
- if (contextPopover) contextPopover.showPopover();
183
- }
184
-
185
- function closeContext() {
186
- if (contextPopover) contextPopover.hidePopover();
187
- contextRow = null;
188
- }
189
-
190
199
  function toggleRow(row: T, index: number) {
191
200
  if (!rowCollapse) return;
192
201
  const id = rowIdFor(row, index);
@@ -204,12 +213,171 @@
204
213
 
205
214
  function handleToggleAll(e: Event) {
206
215
  const input = e.currentTarget as HTMLInputElement;
207
- const checked = input.checked;
208
- if (checked) {
209
- controller.selectAllCurrentPage();
216
+ if (input.checked) controller.selectAllCurrentPage();
217
+ else controller.unselectAllCurrentPage();
218
+ }
219
+
220
+ // =========================
221
+ // ✅ Context positioning (robusto)
222
+ // =========================
223
+ function clamp(n: number, min: number, max: number) {
224
+ return Math.max(min, Math.min(max, n));
225
+ }
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
+ */
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) {
283
+ return {
284
+ x,
285
+ y: CONTEXT_MARGIN,
286
+ transform: `translate(${transformX}, 0px)`
287
+ };
288
+ }
289
+
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);
210
294
  } else {
211
- controller.unselectAllCurrentPage();
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;
212
311
  }
312
+
313
+ if (placeUp && topIfUp < CONTEXT_MARGIN) {
314
+ // stick to top: ajusta y
315
+ y = CONTEXT_MARGIN + pop.height + CONTEXT_GAP;
316
+ }
317
+
318
+ return {
319
+ x,
320
+ y,
321
+ transform: `translate(${transformX}, ${transformY})`
322
+ };
323
+ }
324
+
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) {
332
+ if (!contextPopover || !contextRow) return;
333
+
334
+ // RAF suele ser más fiable que tick() con Popover API
335
+ await raf();
336
+
337
+ const rect = contextPopover.getBoundingClientRect();
338
+
339
+ if ((!rect.width || !rect.height) && retries > 0) {
340
+ return positionContext(retries - 1);
341
+ }
342
+ if (!rect.width || !rect.height) return;
343
+
344
+ contextRender = computeContextPosition(contextPos, rect);
345
+ }
346
+
347
+ async function openContextAt(event: MouseEvent, row: T) {
348
+ event.preventDefault();
349
+ event.stopPropagation();
350
+
351
+ contextRow = row;
352
+ contextPos = { x: event.clientX, y: event.clientY };
353
+
354
+ if (contextPopover) contextPopover.showPopover();
355
+
356
+ // Svelte render + luego RAF retry para layout real
357
+ await tick();
358
+ await positionContext(3);
359
+ }
360
+
361
+ async function openContextFromButton(event: MouseEvent, row: T) {
362
+ event.preventDefault();
363
+ event.stopPropagation();
364
+
365
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
366
+
367
+ contextRow = row;
368
+
369
+ // punto preferido: esquina inferior derecha del botón
370
+ contextPos = { x: rect.right, y: rect.bottom };
371
+
372
+ if (contextPopover) contextPopover.showPopover();
373
+
374
+ await tick();
375
+ await positionContext(3);
376
+ }
377
+
378
+ function closeContext() {
379
+ if (contextPopover) contextPopover.hidePopover();
380
+ contextRow = null;
213
381
  }
214
382
  </script>
215
383
 
@@ -378,7 +546,7 @@
378
546
  {@const value = col.accessor ? col.accessor(row) : (row as any)[col.id]}
379
547
  {@const sticky = stickyOffsets[col.id as keyof T]}
380
548
  <div
381
- 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 ${
382
550
  density === 'compact' ? 'py-1.5' : 'py-2.5'
383
551
  } dark:border-neutral-800/70 ${
384
552
  col.sticky === 'left'
@@ -390,12 +558,7 @@
390
558
  : ''}
391
559
  >
392
560
  {#if cell}
393
- {@render cell({
394
- row,
395
- column: col,
396
- value,
397
- index
398
- })}
561
+ {@render cell({ row, column: col, value, index })}
399
562
  {:else}
400
563
  <span
401
564
  class={`line-clamp-2 text-black dark:text-neutral-50 ${
@@ -501,6 +664,7 @@
501
664
  : (row as any)[firstCol.id]
502
665
  : null}
503
666
  {@const restCols = cols.slice(1)}
667
+
504
668
  <!-- svelte-ignore a11y_no_static_element_interactions -->
505
669
  <div
506
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 ${
@@ -526,12 +690,7 @@
526
690
 
527
691
  <div class="mb-2 pr-6 text-black dark:text-neutral-50">
528
692
  {#if cell && firstCol}
529
- {@render cell({
530
- row,
531
- column: firstCol,
532
- value: firstValue,
533
- index
534
- })}
693
+ {@render cell({ row, column: firstCol, value: firstValue, index })}
535
694
  {:else if firstCol}
536
695
  <div
537
696
  class="line-clamp-2 text-[12px] leading-snug font-semibold text-neutral-900 dark:text-neutral-50"
@@ -609,12 +768,13 @@
609
768
 
610
769
  <DataTableFooter />
611
770
 
771
+ <!-- ✅ CONTEXT POPOVER HOST -->
612
772
  <div
613
773
  bind:this={contextPopover}
614
774
  popover="manual"
615
775
  data-context-host="true"
616
- 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 dark:border-neutral-700/80 dark:bg-neutral-900/95 dark:text-neutral-50"
617
- style={`position: fixed; left: ${contextPos.x}px; top: ${contextPos.y}px; transform: translate(-100%, 8px);`}
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"
777
+ style={`position: fixed; left: ${contextRender.x}px; top: ${contextRender.y}px; transform: ${contextRender.transform};`}
618
778
  onbeforetoggle={(e) => {
619
779
  if ((e as any).newState === 'closed') contextRow = null;
620
780
  }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/ui",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "private": false,
5
5
  "packageManager": "bun@1.3.8",
6
6
  "publishConfig": {