@moni-labs/moni-ui 0.4.14 → 0.4.16

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.
@@ -153,11 +153,32 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
153
153
  this._originalSibling = null;
154
154
  this._isDragging = false;
155
155
  this._startY = 0;
156
- this._currentTranslationY = 0;
156
+ this._lastDeltaY = 0;
157
157
  this._sheetHeight = 0;
158
158
  this._defaultHeight = 0;
159
159
  this._justDragged = false;
160
160
  this._skipRequestClose = false;
161
+ this._isClosing = false;
162
+ }
163
+ /**
164
+ * Abre el bottom sheet.
165
+ */
166
+ show() {
167
+ this._isClosing = false;
168
+ this._isDragging = false;
169
+ this._lastDeltaY = 0;
170
+ if (this._dialog) {
171
+ this._dialog.classList.remove('dragging', 'expanded');
172
+ this._dialog.style.transform = '';
173
+ this._dialog.style.height = '';
174
+ }
175
+ this.open = true;
176
+ }
177
+ /**
178
+ * Cierra el bottom sheet ejecutando la animación fluida hacia abajo.
179
+ */
180
+ close() {
181
+ return this._animateAndClose();
161
182
  }
162
183
  willUpdate(changed) {
163
184
  if (changed.has('open') && this.open !== changed.get('open')) {
@@ -177,12 +198,18 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
177
198
  }
178
199
  }
179
200
  async _animateAndClose() {
201
+ if (this._isClosing)
202
+ return false;
203
+ if (!this.open)
204
+ return false;
205
+ this._isClosing = true;
180
206
  this._skipRequestClose = true;
181
207
  const allowed = emitMoniEvent(this, 'moni-request-close', { cancelable: true });
182
208
  this._skipRequestClose = false;
183
- if (!allowed)
209
+ if (!allowed) {
210
+ this._isClosing = false;
184
211
  return false;
185
- this.dispatchEvent(new Event('close', { bubbles: true, composed: true }));
212
+ }
186
213
  emitMoniEvent(this, 'moni-close');
187
214
  this._dialog.classList.remove('expanded');
188
215
  this._dialog.style.transform = 'translateY(100%)';
@@ -195,10 +222,18 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
195
222
  this._dialog.addEventListener('transitionend', onEnd);
196
223
  setTimeout(onEnd, 300);
197
224
  });
198
- this._dialog.style.transform = '';
225
+ this._isClosing = false;
226
+ this._isDragging = false;
227
+ this._lastDeltaY = 0;
228
+ if (this._dialog) {
229
+ this._dialog.classList.remove('dragging', 'expanded');
230
+ this._dialog.style.transform = '';
231
+ this._dialog.style.height = '';
232
+ }
199
233
  this._skipRequestClose = true;
200
234
  this.open = false;
201
235
  this._skipRequestClose = false;
236
+ this.dispatchEvent(new Event('close', { bubbles: true, composed: true }));
202
237
  emitMoniEvent(this, 'moni-closed');
203
238
  return true;
204
239
  }
@@ -234,14 +269,22 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
234
269
  this._isDragging = true;
235
270
  this._startY = e.clientY; // Almacenamos la coordenada inicial Y para calcular el delta luego
236
271
  this._sheetHeight = this._dialog.getBoundingClientRect().height; // Capturamos la altura actual
237
- this._currentTranslationY = 0;
272
+ this._lastDeltaY = 0;
238
273
  const isExpanded = this._dialog.classList.contains('expanded');
239
274
  // Si no estaba expandido previamente, guardamos su altura por defecto para usarla como punto de retorno.
240
275
  if (!isExpanded) {
241
276
  this._defaultHeight = this._sheetHeight;
242
277
  }
278
+ else if (!this._defaultHeight) {
279
+ this._defaultHeight = Math.min(this._sheetHeight * 0.6, 384);
280
+ }
243
281
  // setPointerCapture enruta todos los eventos subsecuentes a este target
244
- target.setPointerCapture(e.pointerId);
282
+ try {
283
+ target.setPointerCapture(e.pointerId);
284
+ }
285
+ catch {
286
+ // Ignorar si el navegador ya maneja el puntero de forma nativa
287
+ }
245
288
  this._dialog.classList.add('dragging'); // Desactiva las transiciones CSS para un seguimiento suave 1:1
246
289
  }
247
290
  /**
@@ -254,6 +297,7 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
254
297
  return;
255
298
  // Diferencia en píxeles desde donde se inició el click
256
299
  const deltaY = e.clientY - this._startY;
300
+ this._lastDeltaY = deltaY;
257
301
  const isExpanded = this._dialog.classList.contains('expanded');
258
302
  const maxHeight = this._getMaxHeightPx();
259
303
  if (isExpanded) {
@@ -273,7 +317,6 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
273
317
  else {
274
318
  if (deltaY > 0) {
275
319
  // Arrastrando hacia abajo desde su estado inicial: lo empujamos visualmente hacia abajo (translate)
276
- this._currentTranslationY = deltaY;
277
320
  this._dialog.style.transform = `translateY(${deltaY}px)`;
278
321
  this._dialog.style.height = '';
279
322
  }
@@ -288,16 +331,25 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
288
331
  /**
289
332
  * Finaliza la interacción de arrastre, evaluando si el bottom-sheet debe
290
333
  * expandirse, regresar a su estado original o cerrarse completamente según la
291
- * inercia o la distancia arrastrada.
334
+ * inercia, la distancia arrastrada o si se soltó fuera/hacia la parte inferior de la pantalla.
292
335
  */
293
336
  _onPointerUp(e) {
294
337
  if (!this._isDragging)
295
338
  return;
296
339
  const target = e.target;
297
- target.releasePointerCapture(e.pointerId); // Liberamos el control del puntero
340
+ try {
341
+ if (target && target.hasPointerCapture?.(e.pointerId)) {
342
+ target.releasePointerCapture(e.pointerId);
343
+ }
344
+ }
345
+ catch {
346
+ // Ignorar si la captura ya fue liberada o perdida por el navegador
347
+ }
298
348
  this._isDragging = false;
299
349
  this._dialog.classList.remove('dragging'); // Reactivamos las transiciones CSS
300
- const deltaY = e.clientY - this._startY;
350
+ // Evaluamos la posición exacta al momento de soltar
351
+ const hasValidY = typeof e.clientY === 'number' && e.clientY > 0;
352
+ const deltaY = hasValidY ? (e.clientY - this._startY) : this._lastDeltaY;
301
353
  // Si se movió más de 5px, bloqueamos clics accidentales para evitar disparar eventos subyacentes
302
354
  if (Math.abs(deltaY) > 5) {
303
355
  this._justDragged = true;
@@ -307,11 +359,18 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
307
359
  }
308
360
  const isExpanded = this._dialog.classList.contains('expanded');
309
361
  const maxHeight = this._getMaxHeightPx();
362
+ const defaultHeight = this._defaultHeight || (this._sheetHeight * 0.6) || 300;
363
+ // Solo se considera off-screen si al SOLTAR el puntero se encuentra en o más allá del límite inferior
364
+ const isOffScreenBottom = hasValidY && typeof window !== 'undefined' && e.clientY >= (window.innerHeight - 30);
310
365
  if (isExpanded) {
311
366
  // Evaluamos la altura final tras soltar el ratón/dedo
312
367
  const finalHeight = this._sheetHeight - deltaY;
313
- if (finalHeight < this._defaultHeight - 80) {
314
- // Si se arrastró significativamente por debajo de la altura por defecto, lo cerramos
368
+ const shouldClose = isOffScreenBottom ||
369
+ deltaY > this._sheetHeight * 0.4 ||
370
+ finalHeight < (defaultHeight - 80) ||
371
+ finalHeight < 100;
372
+ if (shouldClose) {
373
+ // Si se soltó significativamente hacia abajo o fuera de pantalla, lo cerramos
315
374
  this._animateAndClose().then((closed) => {
316
375
  if (!closed) {
317
376
  // Si se canceló el evento, regresamos a su lugar
@@ -320,20 +379,20 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
320
379
  }
321
380
  });
322
381
  }
323
- else if (finalHeight < (maxHeight + this._defaultHeight) / 2) {
382
+ else if (finalHeight < (maxHeight + defaultHeight) / 2) {
324
383
  // Si se soltó a medio camino, lo colapsamos a su altura normal
325
384
  this._dialog.classList.remove('expanded');
326
385
  this._dialog.style.transform = '';
327
386
  this._dialog.style.height = '';
328
387
  }
329
388
  else {
330
- // Si no se arrastró lo suficiente hacia abajo, retorna a estado expandido (snap back)
389
+ // Si se subió de nuevo hacia arriba o no se arrastró suficiente, retorna a estado expandido (snap back)
331
390
  this._dialog.style.transform = '';
332
391
  this._dialog.style.height = '';
333
392
  }
334
393
  }
335
394
  else {
336
- if (deltaY < 0) {
395
+ if (deltaY < 0 && !isOffScreenBottom) {
337
396
  // Evaluamos el arrastre hacia arriba desde el estado colapsado
338
397
  const finalHeight = this._sheetHeight - deltaY;
339
398
  if (finalHeight > this._sheetHeight + 80) {
@@ -344,8 +403,11 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
344
403
  this._dialog.style.height = '';
345
404
  }
346
405
  else {
347
- // Evaluamos el arrastre hacia abajo para cerrar el componente
348
- if (this._currentTranslationY > 80) {
406
+ // Evaluamos si al soltar superó el umbral de descarte o se soltó fuera de la pantalla
407
+ const threshold = Math.min(80, this._sheetHeight * 0.25);
408
+ const shouldClose = isOffScreenBottom ||
409
+ deltaY > threshold;
410
+ if (shouldClose) {
349
411
  this._animateAndClose().then((closed) => {
350
412
  if (!closed) {
351
413
  this._dialog.style.transform = '';
@@ -353,26 +415,31 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
353
415
  });
354
416
  }
355
417
  else {
356
- // Regresa a su estado colapsado (snap back al inicio)
418
+ // Si el usuario bajó pero subió de nuevo antes de soltar (o no superó el umbral), regresa a abierto
357
419
  this._dialog.style.transform = '';
358
420
  }
359
421
  }
360
422
  }
423
+ this._lastDeltaY = 0;
361
424
  }
362
425
  /**
363
- * Aborta limpiamente la interacción de arrastre (Pointer Cancel).
364
- * Se dispara cuando el SO interrumpe el touch (ej: entrada de una llamada)
365
- * o si el navegador toma el control para un scroll nativo imprevisto.
426
+ * Maneja la cancelación del puntero o interrupción del SO.
427
+ * Si el usuario ya lo había arrastrado hacia abajo o hacia fuera de la pantalla,
428
+ * evalúa el cierre en lugar de simplemente cancelar y dejar la hoja colgada.
366
429
  */
367
430
  _onPointerCancel(e) {
368
- if (!this._isDragging)
369
- return;
370
- const target = e.target;
371
- target.releasePointerCapture(e.pointerId);
372
- this._isDragging = false;
373
- this._dialog.classList.remove('dragging');
374
- this._dialog.style.transform = '';
375
- this._dialog.style.height = '';
431
+ if (this._isDragging) {
432
+ this._onPointerUp(e);
433
+ }
434
+ }
435
+ /**
436
+ * Se ejecuta cuando se pierde la captura de puntero (por ejemplo al arrastrar fuera
437
+ * del viewport del navegador o perder el foco de la ventana).
438
+ */
439
+ _onLostPointerCapture(e) {
440
+ if (this._isDragging) {
441
+ this._onPointerUp(e);
442
+ }
376
443
  }
377
444
  /**
378
445
  * Handler de clicks en la superficie del modal (Backdrop).
@@ -410,6 +477,14 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
410
477
  }
411
478
  if (changed.has('open')) {
412
479
  if (this.open) {
480
+ this._isClosing = false;
481
+ this._isDragging = false;
482
+ this._lastDeltaY = 0;
483
+ if (this._dialog) {
484
+ this._dialog.classList.remove('dragging', 'expanded');
485
+ this._dialog.style.transform = '';
486
+ this._dialog.style.height = '';
487
+ }
413
488
  if (changed.get('open') !== undefined) {
414
489
  emitMoniEvent(this, 'moni-open');
415
490
  }
@@ -424,8 +499,13 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
424
499
  }
425
500
  }
426
501
  else {
502
+ this._isClosing = false;
503
+ this._isDragging = false;
504
+ this._lastDeltaY = 0;
427
505
  if (this._dialog) {
428
- this._dialog.classList.remove('expanded');
506
+ this._dialog.classList.remove('expanded', 'dragging');
507
+ this._dialog.style.transform = '';
508
+ this._dialog.style.height = '';
429
509
  }
430
510
  if (this.positioning === 'body' && this._originalParent && this.parentNode === document.body) {
431
511
  this._originalParent.insertBefore(this, this._originalSibling);
@@ -608,6 +688,7 @@ let MoniBottomSheet = class MoniBottomSheet extends MoniElement {
608
688
  @pointermove=${this._onPointerMove}
609
689
  @pointerup=${this._onPointerUp}
610
690
  @pointercancel=${this._onPointerCancel}
691
+ @lostpointercapture=${this._onLostPointerCapture}
611
692
  >
612
693
  ${this.handle ? html `<div class="handle" aria-hidden="true"></div>` : nothing}
613
694
  <header part="header">
package/docs/api.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@moni-labs/moni-ui",
3
- "generatedAt": "2026-08-24T08:08:23.078Z",
3
+ "generatedAt": "2026-08-31T00:05:06.004Z",
4
4
  "componentCount": 49,
5
5
  "components": [
6
6
  {
@@ -327,7 +327,18 @@
327
327
  "reflect": true
328
328
  }
329
329
  ],
330
- "methods": [],
330
+ "methods": [
331
+ {
332
+ "name": "show",
333
+ "signature": "show()",
334
+ "description": "Abre el bottom sheet."
335
+ },
336
+ {
337
+ "name": "close",
338
+ "signature": "close()",
339
+ "description": "Cierra el bottom sheet ejecutando la animación fluida hacia abajo."
340
+ }
341
+ ],
331
342
  "events": [],
332
343
  "slots": [
333
344
  {
@@ -102,7 +102,8 @@ No declara eventos propios.
102
102
 
103
103
  ## Métodos públicos
104
104
 
105
- No expone métodos públicos adicionales.
105
+ - `show()` Abre el bottom sheet.
106
+ - `close()` — Cierra el bottom sheet ejecutando la animación fluida hacia abajo.
106
107
 
107
108
  ## CSS Parts
108
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moni-labs/moni-ui",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system unificado para Moni — Material Design 3 Expressive, mobile-first.",
@@ -432,4 +432,335 @@ describe('moni-bottom-sheet', () => {
432
432
  expect(closeSpy).not.toHaveBeenCalled();
433
433
  expect(el.open).toBe(true);
434
434
  });
435
+
436
+ it('detecta arrastre fuera de la pantalla y marca como cerrado', async () => {
437
+ el.handle = true;
438
+ el.open = true;
439
+ await el.updateComplete;
440
+
441
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
442
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLElement;
443
+
444
+ handle.setPointerCapture = vi.fn();
445
+ handle.releasePointerCapture = vi.fn();
446
+
447
+ vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({
448
+ height: 300,
449
+ width: 375,
450
+ x: 0,
451
+ y: 300,
452
+ top: 300,
453
+ bottom: 600,
454
+ left: 0,
455
+ right: 375,
456
+ toJSON: () => {}
457
+ });
458
+
459
+ const closeSpy = vi.fn();
460
+ el.addEventListener('close', closeSpy);
461
+
462
+ handle.dispatchEvent(
463
+ new PointerEvent('pointerdown', {
464
+ bubbles: true,
465
+ clientX: 100,
466
+ clientY: 400,
467
+ pointerId: 1
468
+ })
469
+ );
470
+
471
+ // Arrastrar hacia el borde inferior/fuera de pantalla
472
+ dialog.dispatchEvent(
473
+ new PointerEvent('pointermove', {
474
+ bubbles: true,
475
+ clientX: 100,
476
+ clientY: window.innerHeight || 800,
477
+ pointerId: 1
478
+ })
479
+ );
480
+
481
+ // Soltar fuera de la pantalla
482
+ handle.dispatchEvent(
483
+ new PointerEvent('pointerup', {
484
+ bubbles: true,
485
+ clientX: 100,
486
+ clientY: (window.innerHeight || 800) + 50,
487
+ pointerId: 1
488
+ })
489
+ );
490
+
491
+ dialog.dispatchEvent(new Event('transitionend'));
492
+ await el.updateComplete;
493
+
494
+ expect(closeSpy).toHaveBeenCalled();
495
+ expect(el.open).toBe(false);
496
+ });
497
+
498
+ it('cierra y marca como open=false cuando ocurre pointercancel tras arrastrar hacia abajo', async () => {
499
+ el.handle = true;
500
+ el.open = true;
501
+ await el.updateComplete;
502
+
503
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
504
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLElement;
505
+
506
+ handle.setPointerCapture = vi.fn();
507
+ handle.releasePointerCapture = vi.fn();
508
+
509
+ vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({
510
+ height: 300,
511
+ width: 375,
512
+ x: 0,
513
+ y: 300,
514
+ top: 300,
515
+ bottom: 600,
516
+ left: 0,
517
+ right: 375,
518
+ toJSON: () => {}
519
+ });
520
+
521
+ const closeSpy = vi.fn();
522
+ el.addEventListener('close', closeSpy);
523
+
524
+ handle.dispatchEvent(
525
+ new PointerEvent('pointerdown', {
526
+ bubbles: true,
527
+ clientX: 100,
528
+ clientY: 400,
529
+ pointerId: 1
530
+ })
531
+ );
532
+
533
+ // Arrastre hacia abajo significativo
534
+ dialog.dispatchEvent(
535
+ new PointerEvent('pointermove', {
536
+ bubbles: true,
537
+ clientX: 100,
538
+ clientY: 550, // 150px
539
+ pointerId: 1
540
+ })
541
+ );
542
+
543
+ // El navegador cancela el puntero (por ejemplo, salida de viewport en móvil/pantalla táctil)
544
+ dialog.dispatchEvent(
545
+ new PointerEvent('pointercancel', {
546
+ bubbles: true,
547
+ clientX: 0,
548
+ clientY: 0,
549
+ pointerId: 1
550
+ })
551
+ );
552
+
553
+ dialog.dispatchEvent(new Event('transitionend'));
554
+ await el.updateComplete;
555
+
556
+ expect(closeSpy).toHaveBeenCalled();
557
+ expect(el.open).toBe(false);
558
+ });
559
+
560
+ it('cierra y marca como open=false cuando ocurre lostpointercapture tras arrastrar hacia abajo', async () => {
561
+ el.handle = true;
562
+ el.open = true;
563
+ await el.updateComplete;
564
+
565
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
566
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLElement;
567
+
568
+ handle.setPointerCapture = vi.fn();
569
+ handle.releasePointerCapture = vi.fn();
570
+
571
+ vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({
572
+ height: 300,
573
+ width: 375,
574
+ x: 0,
575
+ y: 300,
576
+ top: 300,
577
+ bottom: 600,
578
+ left: 0,
579
+ right: 375,
580
+ toJSON: () => {}
581
+ });
582
+
583
+ const closeSpy = vi.fn();
584
+ el.addEventListener('close', closeSpy);
585
+
586
+ handle.dispatchEvent(
587
+ new PointerEvent('pointerdown', {
588
+ bubbles: true,
589
+ clientX: 100,
590
+ clientY: 400,
591
+ pointerId: 1
592
+ })
593
+ );
594
+
595
+ dialog.dispatchEvent(
596
+ new PointerEvent('pointermove', {
597
+ bubbles: true,
598
+ clientX: 100,
599
+ clientY: 520, // 120px
600
+ pointerId: 1
601
+ })
602
+ );
603
+
604
+ dialog.dispatchEvent(
605
+ new PointerEvent('lostpointercapture', {
606
+ bubbles: true,
607
+ clientX: 0,
608
+ clientY: 0,
609
+ pointerId: 1
610
+ })
611
+ );
612
+
613
+ dialog.dispatchEvent(new Event('transitionend'));
614
+ await el.updateComplete;
615
+
616
+ expect(closeSpy).toHaveBeenCalled();
617
+ expect(el.open).toBe(false);
618
+ });
619
+
620
+ it('cierra y marca como open=false cuando se arrastra un bottom sheet expandido hacia abajo', async () => {
621
+ el.handle = true;
622
+ el.open = true;
623
+ await el.updateComplete;
624
+
625
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
626
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLElement;
627
+ dialog.classList.add('expanded');
628
+
629
+ handle.setPointerCapture = vi.fn();
630
+ handle.releasePointerCapture = vi.fn();
631
+
632
+ vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({
633
+ height: 600,
634
+ width: 375,
635
+ x: 0,
636
+ y: 100,
637
+ top: 100,
638
+ bottom: 700,
639
+ left: 0,
640
+ right: 375,
641
+ toJSON: () => {}
642
+ });
643
+
644
+ const closeSpy = vi.fn();
645
+ el.addEventListener('close', closeSpy);
646
+
647
+ handle.dispatchEvent(
648
+ new PointerEvent('pointerdown', {
649
+ bubbles: true,
650
+ clientX: 100,
651
+ clientY: 100,
652
+ pointerId: 1
653
+ })
654
+ );
655
+
656
+ dialog.dispatchEvent(
657
+ new PointerEvent('pointermove', {
658
+ bubbles: true,
659
+ clientX: 100,
660
+ clientY: 550, // Delta de 450px hacia abajo (arrastrado fuera / muy abajo)
661
+ pointerId: 1
662
+ })
663
+ );
664
+
665
+ handle.dispatchEvent(
666
+ new PointerEvent('pointerup', {
667
+ bubbles: true,
668
+ clientX: 100,
669
+ clientY: 550,
670
+ pointerId: 1
671
+ })
672
+ );
673
+
674
+ dialog.dispatchEvent(new Event('transitionend'));
675
+ await el.updateComplete;
676
+
677
+ expect(closeSpy).toHaveBeenCalled();
678
+ expect(el.open).toBe(false);
679
+ });
680
+
681
+ it('no cierra el bottom sheet si el usuario arrastra hacia abajo pero lo sube de nuevo antes de soltar', async () => {
682
+ el.handle = true;
683
+ el.open = true;
684
+ await el.updateComplete;
685
+
686
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
687
+ const handle = el.shadowRoot?.querySelector('.handle') as HTMLElement;
688
+
689
+ handle.setPointerCapture = vi.fn();
690
+ handle.releasePointerCapture = vi.fn();
691
+
692
+ vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({
693
+ height: 300,
694
+ width: 375,
695
+ x: 0,
696
+ y: 300,
697
+ top: 300,
698
+ bottom: 600,
699
+ left: 0,
700
+ right: 375,
701
+ toJSON: () => {}
702
+ });
703
+
704
+ const closeSpy = vi.fn();
705
+ el.addEventListener('close', closeSpy);
706
+
707
+ // Inicia arrastre en Y = 400
708
+ handle.dispatchEvent(
709
+ new PointerEvent('pointerdown', {
710
+ bubbles: true,
711
+ clientX: 100,
712
+ clientY: 400,
713
+ pointerId: 1
714
+ })
715
+ );
716
+
717
+ // Arrastra hacia abajo (Y = 600, delta = 200px)
718
+ dialog.dispatchEvent(
719
+ new PointerEvent('pointermove', {
720
+ bubbles: true,
721
+ clientX: 100,
722
+ clientY: 600,
723
+ pointerId: 1
724
+ })
725
+ );
726
+
727
+ // Cambia de opinión y lo sube de nuevo cerca del origen (Y = 410, delta = 10px)
728
+ dialog.dispatchEvent(
729
+ new PointerEvent('pointermove', {
730
+ bubbles: true,
731
+ clientX: 100,
732
+ clientY: 410,
733
+ pointerId: 1
734
+ })
735
+ );
736
+
737
+ // Suelta en Y = 410
738
+ handle.dispatchEvent(
739
+ new PointerEvent('pointerup', {
740
+ bubbles: true,
741
+ clientX: 100,
742
+ clientY: 410,
743
+ pointerId: 1
744
+ })
745
+ );
746
+
747
+ await el.updateComplete;
748
+
749
+ expect(closeSpy).not.toHaveBeenCalled();
750
+ expect(el.open).toBe(true);
751
+ });
752
+
753
+ it('permite abrir y cerrar mediante los métodos show() y close()', async () => {
754
+ expect(el.open).toBe(false);
755
+ el.show();
756
+ await el.updateComplete;
757
+ expect(el.open).toBe(true);
758
+
759
+ const dialog = el.shadowRoot?.querySelector('dialog') as HTMLDialogElement;
760
+ const closePromise = el.close();
761
+ dialog.dispatchEvent(new Event('transitionend'));
762
+ await closePromise;
763
+ await el.updateComplete;
764
+ expect(el.open).toBe(false);
765
+ });
435
766
  });