@moni-labs/moni-ui 0.3.0 → 0.3.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.
- package/custom-elements.json +5230 -1007
- package/dist/components/_base/moni-element.d.ts +16 -9
- package/dist/components/_base/moni-element.d.ts.map +1 -1
- package/dist/components/_base/moni-element.js +63 -9
- package/dist/components/index.js +2 -2
- package/dist/components/loading-shapes.d.ts +19 -0
- package/dist/components/loading-shapes.d.ts.map +1 -1
- package/dist/components/loading-shapes.js +19 -0
- package/dist/components/moni-app-bar.d.ts +72 -62
- package/dist/components/moni-app-bar.d.ts.map +1 -1
- package/dist/components/moni-app-bar.js +72 -62
- package/dist/components/moni-badge.d.ts +64 -65
- package/dist/components/moni-badge.d.ts.map +1 -1
- package/dist/components/moni-badge.js +64 -66
- package/dist/components/moni-bottom-sheet.d.ts +129 -61
- package/dist/components/moni-bottom-sheet.d.ts.map +1 -1
- package/dist/components/moni-bottom-sheet.js +217 -131
- package/dist/components/moni-button-group.d.ts +107 -29
- package/dist/components/moni-button-group.d.ts.map +1 -1
- package/dist/components/moni-button-group.js +107 -29
- package/dist/components/moni-button-segment.d.ts +53 -12
- package/dist/components/moni-button-segment.d.ts.map +1 -1
- package/dist/components/moni-button-segment.js +53 -12
- package/dist/components/moni-button.d.ts +116 -38
- package/dist/components/moni-button.d.ts.map +1 -1
- package/dist/components/moni-button.js +128 -38
- package/dist/components/moni-card.d.ts +68 -50
- package/dist/components/moni-card.d.ts.map +1 -1
- package/dist/components/moni-card.js +68 -50
- package/dist/components/moni-carousel.d.ts +484 -53
- package/dist/components/moni-carousel.d.ts.map +1 -1
- package/dist/components/moni-carousel.js +1024 -245
- package/dist/components/moni-checkbox.d.ts +49 -46
- package/dist/components/moni-checkbox.d.ts.map +1 -1
- package/dist/components/moni-checkbox.js +55 -48
- package/dist/components/moni-chip.d.ts +100 -33
- package/dist/components/moni-chip.d.ts.map +1 -1
- package/dist/components/moni-chip.js +104 -34
- package/dist/components/moni-color-field.d.ts +99 -24
- package/dist/components/moni-color-field.d.ts.map +1 -1
- package/dist/components/moni-color-field.js +99 -24
- package/dist/components/moni-context-menu.d.ts +72 -26
- package/dist/components/moni-context-menu.d.ts.map +1 -1
- package/dist/components/moni-context-menu.js +76 -30
- package/dist/components/moni-dialog.d.ts +84 -59
- package/dist/components/moni-dialog.d.ts.map +1 -1
- package/dist/components/moni-dialog.js +105 -58
- package/dist/components/moni-divider.d.ts +39 -27
- package/dist/components/moni-divider.d.ts.map +1 -1
- package/dist/components/moni-divider.js +42 -30
- package/dist/components/moni-expansion.d.ts +32 -22
- package/dist/components/moni-expansion.d.ts.map +1 -1
- package/dist/components/moni-expansion.js +32 -22
- package/dist/components/moni-fab-menu.d.ts +61 -23
- package/dist/components/moni-fab-menu.d.ts.map +1 -1
- package/dist/components/moni-fab-menu.js +69 -31
- package/dist/components/moni-fab.d.ts +86 -28
- package/dist/components/moni-fab.d.ts.map +1 -1
- package/dist/components/moni-fab.js +92 -34
- package/dist/components/moni-file-field.d.ts +118 -30
- package/dist/components/moni-file-field.d.ts.map +1 -1
- package/dist/components/moni-file-field.js +118 -30
- package/dist/components/moni-icon.d.ts +40 -35
- package/dist/components/moni-icon.d.ts.map +1 -1
- package/dist/components/moni-icon.js +43 -38
- package/dist/components/moni-list-item.d.ts +46 -33
- package/dist/components/moni-list-item.d.ts.map +1 -1
- package/dist/components/moni-list-item.js +61 -41
- package/dist/components/moni-list.d.ts +30 -20
- package/dist/components/moni-list.d.ts.map +1 -1
- package/dist/components/moni-list.js +36 -21
- package/dist/components/moni-loading-indicator.d.ts +54 -22
- package/dist/components/moni-loading-indicator.d.ts.map +1 -1
- package/dist/components/moni-loading-indicator.js +54 -22
- package/dist/components/moni-menu-item.d.ts +23 -16
- package/dist/components/moni-menu-item.d.ts.map +1 -1
- package/dist/components/moni-menu-item.js +23 -16
- package/dist/components/moni-menu.d.ts +81 -28
- package/dist/components/moni-menu.d.ts.map +1 -1
- package/dist/components/moni-menu.js +81 -30
- package/dist/components/moni-morph-modal.d.ts +141 -0
- package/dist/components/moni-morph-modal.d.ts.map +1 -1
- package/dist/components/moni-morph-modal.js +295 -58
- package/dist/components/moni-nav-item.d.ts +59 -29
- package/dist/components/moni-nav-item.d.ts.map +1 -1
- package/dist/components/moni-nav-item.js +59 -29
- package/dist/components/moni-nav.d.ts +94 -35
- package/dist/components/moni-nav.d.ts.map +1 -1
- package/dist/components/moni-nav.js +94 -35
- package/dist/components/moni-progress.d.ts +99 -55
- package/dist/components/moni-progress.d.ts.map +1 -1
- package/dist/components/moni-progress.js +99 -55
- package/dist/components/moni-radio.d.ts +62 -50
- package/dist/components/moni-radio.d.ts.map +1 -1
- package/dist/components/moni-radio.js +65 -52
- package/dist/components/moni-ripple.d.ts +74 -59
- package/dist/components/moni-ripple.d.ts.map +1 -1
- package/dist/components/moni-ripple.js +74 -59
- package/dist/components/moni-segmented-button.d.ts +91 -16
- package/dist/components/moni-segmented-button.d.ts.map +1 -1
- package/dist/components/moni-segmented-button.js +94 -17
- package/dist/components/moni-select-option.d.ts +30 -23
- package/dist/components/moni-select-option.d.ts.map +1 -1
- package/dist/components/moni-select-option.js +30 -23
- package/dist/components/moni-select.d.ts +300 -35
- package/dist/components/moni-select.d.ts.map +1 -1
- package/dist/components/moni-select.js +323 -39
- package/dist/components/moni-side-sheet.d.ts +94 -32
- package/dist/components/moni-side-sheet.d.ts.map +1 -1
- package/dist/components/moni-side-sheet.js +94 -32
- package/dist/components/moni-slider.d.ts +152 -41
- package/dist/components/moni-slider.d.ts.map +1 -1
- package/dist/components/moni-slider.js +154 -42
- package/dist/components/moni-snackbar.d.ts +53 -43
- package/dist/components/moni-snackbar.d.ts.map +1 -1
- package/dist/components/moni-snackbar.js +53 -43
- package/dist/components/moni-split-button.d.ts +63 -18
- package/dist/components/moni-split-button.d.ts.map +1 -1
- package/dist/components/moni-split-button.js +63 -18
- package/dist/components/moni-step.d.ts +36 -24
- package/dist/components/moni-step.d.ts.map +1 -1
- package/dist/components/moni-step.js +41 -29
- package/dist/components/moni-stepper.d.ts +64 -27
- package/dist/components/moni-stepper.d.ts.map +1 -1
- package/dist/components/moni-stepper.js +72 -34
- package/dist/components/moni-switch.d.ts +57 -46
- package/dist/components/moni-switch.d.ts.map +1 -1
- package/dist/components/moni-switch.js +59 -47
- package/dist/components/moni-tab.d.ts +26 -18
- package/dist/components/moni-tab.d.ts.map +1 -1
- package/dist/components/moni-tab.js +29 -21
- package/dist/components/moni-tabs.d.ts +34 -28
- package/dist/components/moni-tabs.d.ts.map +1 -1
- package/dist/components/moni-tabs.js +34 -28
- package/dist/components/moni-text-field.d.ts +139 -29
- package/dist/components/moni-text-field.d.ts.map +1 -1
- package/dist/components/moni-text-field.js +157 -31
- package/dist/components/moni-textarea.d.ts +127 -29
- package/dist/components/moni-textarea.d.ts.map +1 -1
- package/dist/components/moni-textarea.js +131 -32
- package/dist/components/moni-time-picker.d.ts +114 -24
- package/dist/components/moni-time-picker.d.ts.map +1 -1
- package/dist/components/moni-time-picker.js +127 -33
- package/dist/components/moni-toolbar.d.ts +37 -23
- package/dist/components/moni-toolbar.d.ts.map +1 -1
- package/dist/components/moni-toolbar.js +37 -23
- package/dist/components/moni-tooltip.d.ts +100 -36
- package/dist/components/moni-tooltip.d.ts.map +1 -1
- package/dist/components/moni-tooltip.js +104 -40
- package/dist/components/moni-typography.d.ts +68 -30
- package/dist/components/moni-typography.d.ts.map +1 -1
- package/dist/components/moni-typography.js +70 -32
- package/dist/styles/tokens.css +7 -7
- package/dist/types/events.d.ts +17 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/events.js +1 -0
- package/dist/utils/event-emitter.d.ts +13 -0
- package/dist/utils/event-emitter.d.ts.map +1 -0
- package/dist/utils/event-emitter.js +13 -0
- package/package.json +2 -1
- package/src/components/_base/moni-element.ts +73 -9
- package/src/components/index.ts +2 -2
- package/src/components/loading-shapes.ts +19 -0
- package/src/components/moni-app-bar.test.ts +10 -10
- package/src/components/moni-app-bar.ts +72 -62
- package/src/components/moni-badge.ts +64 -66
- package/src/components/moni-bottom-sheet.test.ts +39 -45
- package/src/components/moni-bottom-sheet.ts +222 -131
- package/src/components/moni-button-group.test.ts +14 -14
- package/src/components/moni-button-group.ts +107 -29
- package/src/components/moni-button-segment.ts +53 -12
- package/src/components/moni-button.test.ts +28 -26
- package/src/components/moni-button.ts +139 -39
- package/src/components/moni-card.test.ts +6 -6
- package/src/components/moni-card.ts +70 -50
- package/src/components/moni-carousel.test.ts +12 -12
- package/src/components/moni-carousel.ts +1129 -269
- package/src/components/moni-checkbox.test.ts +7 -7
- package/src/components/moni-checkbox.ts +62 -48
- package/src/components/moni-chip.test.ts +17 -17
- package/src/components/moni-chip.ts +112 -34
- package/src/components/moni-color-field.test.ts +5 -5
- package/src/components/moni-color-field.ts +108 -24
- package/src/components/moni-context-menu.test.ts +11 -11
- package/src/components/moni-context-menu.ts +76 -30
- package/src/components/moni-dialog.ts +108 -59
- package/src/components/moni-divider.test.ts +4 -4
- package/src/components/moni-divider.ts +42 -30
- package/src/components/moni-expansion.ts +32 -22
- package/src/components/moni-fab-menu.test.ts +11 -11
- package/src/components/moni-fab-menu.ts +69 -31
- package/src/components/moni-fab.test.ts +16 -16
- package/src/components/moni-fab.ts +100 -34
- package/src/components/moni-file-field.test.ts +8 -8
- package/src/components/moni-file-field.ts +132 -30
- package/src/components/moni-icon.test.ts +11 -11
- package/src/components/moni-icon.ts +43 -38
- package/src/components/moni-list-item.test.ts +10 -10
- package/src/components/moni-list-item.ts +61 -41
- package/src/components/moni-list.ts +37 -21
- package/src/components/moni-loading-indicator.test.ts +3 -3
- package/src/components/moni-loading-indicator.ts +54 -22
- package/src/components/moni-menu-item.ts +23 -16
- package/src/components/moni-menu.test.ts +7 -7
- package/src/components/moni-menu.ts +87 -30
- package/src/components/moni-morph-modal.test.ts +18 -17
- package/src/components/moni-morph-modal.ts +327 -60
- package/src/components/moni-nav-item.ts +59 -29
- package/src/components/moni-nav.test.ts +11 -11
- package/src/components/moni-nav.ts +94 -35
- package/src/components/moni-progress.test.ts +9 -9
- package/src/components/moni-progress.ts +100 -56
- package/src/components/moni-radio.test.ts +8 -8
- package/src/components/moni-radio.ts +71 -51
- package/src/components/moni-ripple.ts +74 -59
- package/src/components/moni-segmented-button.test.ts +4 -4
- package/src/components/moni-segmented-button.ts +94 -17
- package/src/components/moni-select-option.ts +30 -23
- package/src/components/moni-select.test.ts +11 -11
- package/src/components/moni-select.ts +361 -50
- package/src/components/moni-side-sheet.test.ts +7 -7
- package/src/components/moni-side-sheet.ts +94 -32
- package/src/components/moni-slider.test.ts +7 -7
- package/src/components/moni-slider.ts +165 -42
- package/src/components/moni-snackbar.test.ts +8 -8
- package/src/components/moni-snackbar.ts +53 -43
- package/src/components/moni-split-button.ts +63 -18
- package/src/components/moni-step.test.ts +7 -7
- package/src/components/moni-step.ts +41 -29
- package/src/components/moni-stepper.test.ts +7 -7
- package/src/components/moni-stepper.ts +72 -34
- package/src/components/moni-switch.test.ts +12 -12
- package/src/components/moni-switch.ts +66 -47
- package/src/components/moni-tab.test.ts +5 -5
- package/src/components/moni-tab.ts +29 -21
- package/src/components/moni-tabs.ts +34 -28
- package/src/components/moni-text-field.test.ts +13 -13
- package/src/components/moni-text-field.ts +181 -30
- package/src/components/moni-textarea.test.ts +17 -17
- package/src/components/moni-textarea.ts +155 -31
- package/src/components/moni-time-picker.test.ts +5 -5
- package/src/components/moni-time-picker.ts +128 -33
- package/src/components/moni-toolbar.test.ts +8 -8
- package/src/components/moni-toolbar.ts +37 -23
- package/src/components/moni-tooltip.test.ts +15 -15
- package/src/components/moni-tooltip.ts +107 -40
- package/src/components/moni-typography.test.ts +10 -10
- package/src/components/moni-typography.ts +73 -32
- package/src/styles/tokens.css +7 -7
- package/src/types/events.ts +17 -0
- package/src/utils/event-emitter.ts +26 -0
|
@@ -8,126 +8,318 @@
|
|
|
8
8
|
import { html, css } from 'lit';
|
|
9
9
|
import { customElement, property, query, state, queryAll } from 'lit/decorators.js';
|
|
10
10
|
import { MoniElement, sharedStyles } from './_base/index.js';
|
|
11
|
+
import { emitMoniEvent } from '../utils/event-emitter.js';
|
|
11
12
|
import { gsap } from 'gsap';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
+
* Modelo de datos para un solo elemento en un `<moni-carousel>`.
|
|
15
16
|
*/
|
|
16
17
|
export interface CarouselItem {
|
|
17
|
-
/**
|
|
18
|
+
/** Título a mostrar sobre el elemento. */
|
|
18
19
|
title: string;
|
|
19
|
-
/** URL
|
|
20
|
+
/** URL o ruta a la imagen de fondo. */
|
|
20
21
|
img: string;
|
|
21
|
-
/**
|
|
22
|
+
/** Objetivo de enlace opcional. Si se proporciona, el elemento se renderiza como un `<a>`. */
|
|
22
23
|
href?: string;
|
|
23
|
-
/**
|
|
24
|
+
/** Atributo target del enlace (ej. `'_blank'`). Solo se aplica si se establece `href`. */
|
|
24
25
|
target?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Material Design 3 Expressive Carousel
|
|
29
|
+
* Componente Material Design 3 Expressive Carousel.
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* Los carruseles muestran una colección de elementos relacionados en una lista horizontal desplazable.
|
|
32
|
+
* Permiten a los usuarios navegar rápidamente a través de elementos como imágenes, tarjetas, o productos.
|
|
32
33
|
*
|
|
33
|
-
* **
|
|
34
|
+
* **Referencia de la especificación M3:** `m3-docs/components/carousel/specs.md`
|
|
34
35
|
*
|
|
35
|
-
* **
|
|
36
|
-
* - `multi-browse` (
|
|
37
|
-
*
|
|
38
|
-
* - `hero` —
|
|
39
|
-
*
|
|
40
|
-
* - `uncontained` —
|
|
41
|
-
*
|
|
36
|
+
* **Variantes de diseño:**
|
|
37
|
+
* - `multi-browse` (por defecto) — Muestra una mezcla de elementos grandes, medianos y pequeños (asomando).
|
|
38
|
+
* Mejor para explorar un gran número de elementos.
|
|
39
|
+
* - `hero` — Se enfoca en un elemento primario grande mientras muestra una fracción del
|
|
40
|
+
* siguiente elemento. Mejor para destacar contenido importante.
|
|
41
|
+
* - `uncontained` — Diseño estándar donde todos los elementos tienen el mismo ancho y
|
|
42
|
+
* se desbordan por los bordes del contenedor.
|
|
42
43
|
*
|
|
43
|
-
* **
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* **Animación y Gestos:**
|
|
45
|
+
* Este componente usa GSAP para animaciones suaves de arrastre, deslizamiento y ajuste,
|
|
46
|
+
* reflejando las especificaciones de movimiento de alta fidelidad de M3 Expressive. Maneja
|
|
47
|
+
* gestos táctiles para móviles y arrastre del ratón para escritorio.
|
|
47
48
|
*
|
|
48
|
-
* **Auto-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* **Auto-tamaño (modo `auto`):**
|
|
50
|
+
* Cuando `auto=true` (por defecto), el carrusel mide su propio ancho y
|
|
51
|
+
* calcula dinámicamente los tamaños óptimos para los elementos grandes, medianos y pequeños
|
|
52
|
+
* basados en el `layout` activo para asegurar que encajen perfectamente sin huecos incómodos
|
|
53
|
+
* ni recortes en los bordes.
|
|
53
54
|
*
|
|
54
55
|
* @example
|
|
55
56
|
* ```html
|
|
56
|
-
* <!--
|
|
57
|
+
* <!-- Uso declarativo a través de propiedades DOM (recomendado) -->
|
|
57
58
|
* <moni-carousel layout="hero"></moni-carousel>
|
|
58
59
|
* <script>
|
|
59
60
|
* const carousel = document.querySelector('moni-carousel');
|
|
60
61
|
* carousel.items = [
|
|
61
|
-
* { title: '
|
|
62
|
-
* { title: '
|
|
62
|
+
* { title: 'Elemento 1', img: '/img1.jpg', href: '/link1' },
|
|
63
|
+
* { title: 'Elemento 2', img: '/img2.jpg' }
|
|
63
64
|
* ];
|
|
64
65
|
* </script>
|
|
65
66
|
*
|
|
66
|
-
* <!--
|
|
67
|
+
* <!-- Uso basado en slots (para SSR o contenido estático simple) -->
|
|
67
68
|
* <moni-carousel layout="uncontained">
|
|
68
69
|
* <div slot="item">
|
|
69
70
|
* <img src="/img1.jpg" />
|
|
70
|
-
* <h3>
|
|
71
|
+
* <h3>Elemento Estático</h3>
|
|
71
72
|
* </div>
|
|
72
73
|
* </moni-carousel>
|
|
73
74
|
* ```
|
|
74
75
|
*
|
|
75
|
-
* @slot item -
|
|
76
|
-
*
|
|
76
|
+
* @slot item - Alternativa a la propiedad `items`. Inserta elementos HTML individuales
|
|
77
|
+
* en lugar de pasar objetos de datos.
|
|
77
78
|
*
|
|
78
|
-
* @csspart carousel -
|
|
79
|
-
* @csspart track -
|
|
80
|
-
* @csspart item -
|
|
81
|
-
* @csspart img -
|
|
82
|
-
* @csspart title -
|
|
79
|
+
* @csspart carousel - El envoltorio exterior.
|
|
80
|
+
* @csspart track - El elemento de la pista desplazable.
|
|
81
|
+
* @csspart item - Contenedores de elementos del carrusel individuales.
|
|
82
|
+
* @csspart img - Los elementos de imagen dentro de los elementos.
|
|
83
|
+
* @csspart title - Los elementos de texto de título dentro de los elementos.
|
|
83
84
|
*/
|
|
84
85
|
@customElement('moni-carousel')
|
|
85
86
|
export class MoniCarousel extends MoniElement {
|
|
87
|
+
/**
|
|
88
|
+
* Array de elementos a mostrar en el carrusel.
|
|
89
|
+
* Cada elemento requiere al menos `title` e `img`. El opcional `href` renderiza la tarjeta como un enlace.
|
|
90
|
+
* @default []
|
|
91
|
+
*/
|
|
86
92
|
@property({ type: Array }) items: CarouselItem[] = [];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Variante de diseño visual. Determina cuántos elementos son visibles y cómo escalan.
|
|
96
|
+
* - `'multi-browse'` (por defecto) — Elementos grandes + medianos + pequeños (asomando).
|
|
97
|
+
* - `'hero'` — Uno o más elementos grandes + elemento pequeño asomando.
|
|
98
|
+
* - `'uncontained'` — Elementos de igual ancho que se desbordan por el borde.
|
|
99
|
+
* @default 'multi-browse'
|
|
100
|
+
*/
|
|
87
101
|
@property({ reflect: true }) layout: 'multi-browse' | 'hero' | 'uncontained' = 'multi-browse';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cuando es `true`, el carrusel calcula los tamaños óptimos de los elementos automáticamente
|
|
105
|
+
* basado en el ancho del contenedor y la variante de `layout` activa.
|
|
106
|
+
* Cuando es `false`, usa los valores explícitos `largeWidth`, `mediumWidth`, `smallWidth`.
|
|
107
|
+
* @default true
|
|
108
|
+
*/
|
|
88
109
|
@property({ type: Boolean, reflect: true }) auto = true;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ancho en píxeles para el elemento grande (enfoque principal) del carrusel.
|
|
113
|
+
* Solo se usa cuando `auto=false`.
|
|
114
|
+
* @default 220
|
|
115
|
+
*/
|
|
89
116
|
@property({ type: Number, attribute: 'large-width' }) largeWidth = 220;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ancho en píxeles para el elemento de tamaño mediano (secundario) del carrusel.
|
|
120
|
+
* Solo se usa cuando `auto=false` y `layout='multi-browse'`.
|
|
121
|
+
* @default 96
|
|
122
|
+
*/
|
|
90
123
|
@property({ type: Number, attribute: 'medium-width' }) mediumWidth = 96;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ancho en píxeles para el elemento pequeño (asomando) del carrusel.
|
|
127
|
+
* Solo se usa cuando `auto=false`.
|
|
128
|
+
* @default 48
|
|
129
|
+
*/
|
|
91
130
|
@property({ type: Number, attribute: 'small-width' }) smallWidth = 48;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Espacio en píxeles entre los elementos del carrusel.
|
|
134
|
+
* @default 8
|
|
135
|
+
*/
|
|
92
136
|
@property({ type: Number }) gap = 8;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Relleno horizontal en píxeles aplicado al inicio de la pista del carrusel.
|
|
140
|
+
* @default 16
|
|
141
|
+
*/
|
|
93
142
|
@property({ type: Number }) padding = 16;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Radio del borde en píxeles aplicado a cada tarjeta.
|
|
146
|
+
* @default 28
|
|
147
|
+
*/
|
|
94
148
|
@property({ type: Number, attribute: 'border-radius' }) borderRadius = 28;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Cuando es `true`, renderiza un enlace "Mostrar todo" en el encabezado.
|
|
152
|
+
* @default false
|
|
153
|
+
*/
|
|
95
154
|
@property({ type: Boolean, attribute: 'show-all' }) showAll = false;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Texto de etiqueta para el enlace "Mostrar todo".
|
|
158
|
+
* @default 'Show all'
|
|
159
|
+
*/
|
|
96
160
|
@property({ attribute: 'show-all-text' }) showAllText = 'Show all';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Encabezado de sección opcional renderizado sobre la pista del carrusel.
|
|
164
|
+
* @default ''
|
|
165
|
+
*/
|
|
97
166
|
@property({ attribute: 'header-text' }) headerText = '';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Cuando es `true`, oculta los botones de flecha de navegación anterior/siguiente.
|
|
170
|
+
* @default false
|
|
171
|
+
*/
|
|
98
172
|
@property({ type: Boolean, attribute: 'hide-nav' }) hideNav = false;
|
|
99
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Cuando es `true`, habilita el bucle infinito sin interrupciones clonando la lista de elementos
|
|
176
|
+
* a través de un búfer de desplazamiento virtual grande, luego reposicionando silenciosamente
|
|
177
|
+
* el desplazamiento cuando el usuario se acerca a cualquier borde.
|
|
178
|
+
* @default false
|
|
179
|
+
*/
|
|
180
|
+
@property({ type: Boolean, reflect: true }) infinite = false;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Cuando es `true`, avanza automáticamente el carrusel en el intervalo definido
|
|
184
|
+
* por `autoplayInterval`. La reproducción automática se detiene durante las interacciones de arrastre activas.
|
|
185
|
+
* @default false
|
|
186
|
+
*/
|
|
187
|
+
@property({ type: Boolean, reflect: true }) autoplay = false;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Milisegundos entre los avances automáticos de diapositivas cuando `autoplay=true`.
|
|
191
|
+
* @default 3000
|
|
192
|
+
*/
|
|
193
|
+
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ancho medido del elemento host del carrusel en píxeles.
|
|
197
|
+
* Actualizado por el `ResizeObserver` en cada redimensionamiento del contenedor y usado por
|
|
198
|
+
* `computedLayout` para calcular cuántos elementos caben en la pantalla.
|
|
199
|
+
*/
|
|
100
200
|
@state() private _containerWidth = 0;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Elementos analizados desde el slot Light DOM (uso declarativo).
|
|
204
|
+
* Poblado por `_handleSlotChange` cuando los usuarios insertan elementos `<img>` o `<a><img></a>`.
|
|
205
|
+
*/
|
|
101
206
|
@state() private _slottedItems: CarouselItem[] = [];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Si el slot `show-all` contiene contenido proyectado por el usuario.
|
|
210
|
+
* Controla si se renderiza el área "Mostrar todo" del encabezado.
|
|
211
|
+
*/
|
|
102
212
|
@state() private _hasSlottedShowAll = false;
|
|
103
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Devuelve la fuente del elemento activo.
|
|
216
|
+
* La propiedad programática `items` tiene prioridad sobre los hijos Light DOM en slots.
|
|
217
|
+
* Recurre a `_slottedItems` cuando no se proporcionan elementos programáticos.
|
|
218
|
+
*/
|
|
104
219
|
get effectiveItems(): CarouselItem[] {
|
|
105
220
|
return this.items && this.items.length > 0 ? this.items : this._slottedItems;
|
|
106
221
|
}
|
|
107
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Referencia directa al div contenedor de desplazamiento.
|
|
225
|
+
* Usado para leer `scrollLeft`, modificar `scrollBehavior`, y calcular `scrollWidth`.
|
|
226
|
+
*/
|
|
108
227
|
@query('.scroll-container') private _scrollContainer!: HTMLDivElement;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* NodeList en vivo de todos los elementos de tarjeta renderizados.
|
|
231
|
+
* Usado por el motor de diseño para aplicar actualizaciones de transform/width por cuadro a través de GSAP quickSetters.
|
|
232
|
+
*/
|
|
109
233
|
@queryAll('.card') private _cards!: NodeListOf<HTMLDivElement>;
|
|
110
234
|
|
|
235
|
+
/** Instancia ResizeObserver que observa las dimensiones del elemento host. */
|
|
111
236
|
private _resizeObserver: ResizeObserver | null = null;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
private
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
|
|
237
|
+
|
|
238
|
+
/** Estado interno de visibilidad (por índice lógico) para evitar falsos eventos al ciclar el scroll infinito */
|
|
239
|
+
private _logicalItemInViewport: boolean[] = [];
|
|
240
|
+
|
|
241
|
+
/** `true` mientras el usuario arrastra activamente (ratón o toque presionado). */
|
|
242
|
+
private _isDragging = false;
|
|
243
|
+
|
|
244
|
+
/** Coordenada X en el inicio del gesto de arrastre actual (relativa al contenedor de desplazamiento). */
|
|
245
|
+
private _dragStartX = 0;
|
|
246
|
+
|
|
247
|
+
/** Valor `scrollLeft` del contenedor en el momento en que comenzó el arrastre. */
|
|
248
|
+
private _scrollLeftAtDragStart = 0;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Velocidad del puntero en píxeles por milisegundo.
|
|
252
|
+
* Calculada gradualmente durante el arrastre y usada para el impulso/inercia al soltar.
|
|
253
|
+
*/
|
|
254
|
+
private _pointerVelocity = 0;
|
|
255
|
+
|
|
256
|
+
/** Marca de tiempo (de `performance.now()`) del último evento de movimiento del puntero/toque. */
|
|
257
|
+
private _lastPointerTime = 0;
|
|
258
|
+
|
|
259
|
+
/** Coordenada X del puntero en el cuadro del evento de movimiento anterior. */
|
|
260
|
+
private _lastPointerX = 0;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Distancia horizontal absoluta acumulada (en píxeles) recorrida durante el arrastre actual.
|
|
264
|
+
* Usada para distinguir un deslizamiento intencional de un clic accidental (umbral: 8px).
|
|
265
|
+
*/
|
|
266
|
+
private _totalDraggedDistance = 0;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* ID de `requestAnimationFrame` activo del bucle de desplazamiento por inercia.
|
|
270
|
+
* Almacenado para que pueda cancelarse cuando el usuario inicia un nuevo arrastre.
|
|
271
|
+
*/
|
|
272
|
+
private _animationFrameId: number | null = null;
|
|
273
|
+
|
|
274
|
+
/** `true` cuando el ancho de la ventana gráfica coincide con el punto de ruptura móvil (`max-width: 600px`). */
|
|
120
275
|
private _isMobile = false;
|
|
121
276
|
|
|
122
|
-
//
|
|
277
|
+
// ─── Estado de interpolación GSAP Ticker ──────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* La posición de desplazamiento hacia la que el ticker de GSAP está interpolando (lerping) **en cada cuadro**.
|
|
281
|
+
* Expresada en unidades de `itemSize` (no píxeles) para matemáticas independientes del diseño.
|
|
282
|
+
*/
|
|
123
283
|
private _tickerTarget = 0;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* La posición de desplazamiento interpolada actual usada para impulsar el diseño visual.
|
|
287
|
+
* Interpola hacia `_tickerTarget` en cada cuadro, creando el efecto de suavizado.
|
|
288
|
+
*/
|
|
124
289
|
private _tickerCurrent = 0;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `true` mientras el callback del ticker de GSAP (`_tick`) está registrado y en ejecución.
|
|
293
|
+
* Evita añadir el callback del ticker múltiples veces en llamadas concurrentes.
|
|
294
|
+
*/
|
|
125
295
|
private _isTicking = false;
|
|
126
296
|
|
|
127
|
-
//
|
|
297
|
+
// ─── Estado de Bucle Infinito & Reproducción Automática ─────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Protege la inicialización de una sola vez de la posición del búfer de desplazamiento infinito.
|
|
301
|
+
* Se establece en `true` una vez que el contenedor de desplazamiento se ha reposicionado
|
|
302
|
+
* silenciosamente al centro del búfer virtual después del primer renderizado.
|
|
303
|
+
*/
|
|
304
|
+
private _infiniteInitialized = false;
|
|
305
|
+
|
|
306
|
+
/** ID del temporizador devuelto por `setInterval()` para la función de reproducción automática. */
|
|
307
|
+
private _autoplayTimer: any = null;
|
|
308
|
+
|
|
309
|
+
// ─── Cachés de GSAP quickSetter ───────────────────────────────────────────────
|
|
310
|
+
// Las funciones quickSetter evitan la sobrecarga de llamar a `gsap.set()` en cada cuadro.
|
|
311
|
+
// Pre-construidas una vez después de cada ciclo de renderizado; invalidadas cuando cambia el número de tarjetas.
|
|
312
|
+
|
|
313
|
+
/** Array de quickSetters de GSAP para el CSS `transform: translateX()` de cada tarjeta. */
|
|
128
314
|
private _cardSetX: ((value: number) => void)[] = [];
|
|
315
|
+
|
|
316
|
+
/** Array de quickSetters de GSAP para el `width` en línea de cada tarjeta. */
|
|
129
317
|
private _cardSetWidth: ((value: number) => void)[] = [];
|
|
318
|
+
|
|
319
|
+
/** Array de quickSetters de GSAP para el parallax `translateX` del `<img>` de cada tarjeta. */
|
|
130
320
|
private _imgSetX: ((value: number) => void)[] = [];
|
|
321
|
+
|
|
322
|
+
/** Array de quickSetters de GSAP para la `opacity` del elemento `.card-title` de cada tarjeta. */
|
|
131
323
|
private _titleSetOpacity: ((value: number) => void)[] = [];
|
|
132
324
|
|
|
133
325
|
static override styles = [
|
|
@@ -189,6 +381,8 @@ export class MoniCarousel extends MoniElement {
|
|
|
189
381
|
overflow-x: auto;
|
|
190
382
|
overflow-y: hidden;
|
|
191
383
|
scroll-snap-type: x mandatory;
|
|
384
|
+
scroll-padding-left: var(--carousel-padding, 16px);
|
|
385
|
+
scroll-padding-right: var(--carousel-padding, 16px);
|
|
192
386
|
scrollbar-width: none;
|
|
193
387
|
-webkit-overflow-scrolling: touch;
|
|
194
388
|
cursor: grab;
|
|
@@ -357,6 +551,12 @@ export class MoniCarousel extends MoniElement {
|
|
|
357
551
|
`
|
|
358
552
|
];
|
|
359
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Hook del ciclo de vida (Lit).
|
|
556
|
+
* Inicializa el `ResizeObserver` para reaccionar a cambios en el tamaño del contenedor,
|
|
557
|
+
* lo que es crucial para recalcular el `computedLayout` (cuántas cards caben en el viewport).
|
|
558
|
+
* También añade listeners globales pasivos para eventos de redimensionamiento de ventana.
|
|
559
|
+
*/
|
|
360
560
|
override connectedCallback() {
|
|
361
561
|
super.connectedCallback();
|
|
362
562
|
if (typeof ResizeObserver !== 'undefined') {
|
|
@@ -381,14 +581,19 @@ export class MoniCarousel extends MoniElement {
|
|
|
381
581
|
}
|
|
382
582
|
}
|
|
383
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Hook del ciclo de vida (Lit).
|
|
586
|
+
* Limpia y desconecta el `ResizeObserver`, el `IntersectionObserver` y elimina
|
|
587
|
+
* los event listeners globales para evitar fugas de memoria (memory leaks).
|
|
588
|
+
*/
|
|
384
589
|
override disconnectedCallback() {
|
|
385
590
|
if (this._resizeObserver) {
|
|
386
591
|
this._resizeObserver.disconnect();
|
|
387
592
|
}
|
|
388
593
|
// Cancel any pending RAF or ticker
|
|
389
|
-
if (this.
|
|
390
|
-
cancelAnimationFrame(this.
|
|
391
|
-
this.
|
|
594
|
+
if (this._animationFrameId !== null) {
|
|
595
|
+
cancelAnimationFrame(this._animationFrameId);
|
|
596
|
+
this._animationFrameId = null;
|
|
392
597
|
}
|
|
393
598
|
gsap.ticker.remove(this._tick);
|
|
394
599
|
this._isTicking = false;
|
|
@@ -401,6 +606,11 @@ export class MoniCarousel extends MoniElement {
|
|
|
401
606
|
super.disconnectedCallback();
|
|
402
607
|
}
|
|
403
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Hook del ciclo de vida (Lit) ejecutado tras el primer render.
|
|
611
|
+
* Inspecciona el Shadow DOM para ubicar el slot por defecto y procesar
|
|
612
|
+
* las cards iniciales (`_handleSlotChange` invocado manualmente).
|
|
613
|
+
*/
|
|
404
614
|
override firstUpdated() {
|
|
405
615
|
const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])');
|
|
406
616
|
if (defaultSlot) {
|
|
@@ -410,10 +620,24 @@ export class MoniCarousel extends MoniElement {
|
|
|
410
620
|
if (showAllSlot) {
|
|
411
621
|
this._handleShowAllSlotChange({ target: showAllSlot } as any);
|
|
412
622
|
}
|
|
623
|
+
|
|
413
624
|
this._buildQuickSetters();
|
|
414
625
|
this.updateLayout(true);
|
|
626
|
+
|
|
627
|
+
if (this.autoplay) {
|
|
628
|
+
this._startAutoplay();
|
|
629
|
+
}
|
|
415
630
|
}
|
|
416
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Hook del ciclo de vida (Lit). Se invoca tras cada actualización reactiva.
|
|
634
|
+
*
|
|
635
|
+
* Lógica principal:
|
|
636
|
+
* - Si cambian propiedades estructurales (`autoplay`, `infinite`, `gap`), reconstruye el
|
|
637
|
+
* layout y detiene/inicia el timer de autoplay.
|
|
638
|
+
* - Delega en `_checkInfiniteWrap()` para reposicionar el scroll si estamos cerca
|
|
639
|
+
* de los bordes del carrusel infinito.
|
|
640
|
+
*/
|
|
417
641
|
override updated(changedProperties: Map<string, any>) {
|
|
418
642
|
super.updated(changedProperties);
|
|
419
643
|
if (
|
|
@@ -425,179 +649,311 @@ export class MoniCarousel extends MoniElement {
|
|
|
425
649
|
changedProperties.has('padding') ||
|
|
426
650
|
changedProperties.has('items') ||
|
|
427
651
|
changedProperties.has('hideNav') ||
|
|
652
|
+
changedProperties.has('infinite') ||
|
|
653
|
+
changedProperties.has('autoplay') ||
|
|
654
|
+
changedProperties.has('autoplayInterval') ||
|
|
428
655
|
changedProperties.has('_containerWidth') ||
|
|
429
656
|
changedProperties.has('_slottedItems') ||
|
|
430
657
|
changedProperties.has('_hasSlottedShowAll')
|
|
431
658
|
) {
|
|
659
|
+
if (changedProperties.has('infinite') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
|
|
660
|
+
this._infiniteInitialized = false;
|
|
661
|
+
}
|
|
662
|
+
|
|
432
663
|
if (changedProperties.has('layout') && this._scrollContainer) {
|
|
433
664
|
this._scrollContainer.scrollLeft = 0;
|
|
665
|
+
this._infiniteInitialized = false;
|
|
434
666
|
this._scrollContainer.style.scrollSnapType =
|
|
435
667
|
this.layout === 'uncontained' ? 'none' : 'x mandatory';
|
|
436
668
|
}
|
|
437
669
|
this.updateLayout(true);
|
|
670
|
+
|
|
671
|
+
if (changedProperties.has('autoplay') || changedProperties.has('autoplayInterval') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
|
|
672
|
+
if (this.autoplay) {
|
|
673
|
+
this._startAutoplay();
|
|
674
|
+
} else {
|
|
675
|
+
this._stopAutoplay();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
438
678
|
}
|
|
439
679
|
}
|
|
440
680
|
|
|
441
|
-
|
|
442
|
-
|
|
681
|
+
/**
|
|
682
|
+
* Motor de cálculo del Layout.
|
|
683
|
+
*
|
|
684
|
+
* Basado en las proporciones de Material 3 para carruseles:
|
|
685
|
+
* Calcula el ancho disponible (`W_c`) y determina el número de ítems grandes (`L`),
|
|
686
|
+
* medianos (`M`) y pequeños (`S`) que caben en pantalla, usando la fórmula de la
|
|
687
|
+
* especificación (multi-browse, hero o uncontained).
|
|
688
|
+
*
|
|
689
|
+
* Retorna `{ L, M, S, N }` donde `N` es el divisor base para calcular tamaños proporcionales.
|
|
690
|
+
*/
|
|
691
|
+
get computedLayout() {
|
|
692
|
+
/**
|
|
693
|
+
* `containerWidth` — ancho total en píxeles del elemento host del carrusel.
|
|
694
|
+
* Recurre a `getBoundingClientRect()` si el ResizeObserver aún no se ha disparado,
|
|
695
|
+
* y en última instancia por defecto a 360px (un ancho común de ventana gráfica móvil).
|
|
696
|
+
*/
|
|
697
|
+
const containerWidth = this._containerWidth || this.getBoundingClientRect().width || 360;
|
|
443
698
|
const gap = this.gap;
|
|
444
699
|
const padding = this.padding;
|
|
445
|
-
const W_a = W_c - 2 * padding;
|
|
446
700
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
701
|
+
/**
|
|
702
|
+
* `availableWidth` — el ancho de pista utilizable después de restar el padding
|
|
703
|
+
* inicial y final del ancho total del contenedor. Este es el espacio que el algoritmo de diseño
|
|
704
|
+
* divide en ranuras de elementos grandes, medianos y pequeños.
|
|
705
|
+
* Fórmula: availableWidth = containerWidth - 2 × padding
|
|
706
|
+
*/
|
|
707
|
+
const availableWidth = containerWidth - 2 * padding;
|
|
708
|
+
|
|
709
|
+
/** `largeItemWidth` — ancho calculado o configurado (px) de los elementos de enfoque principal. */
|
|
710
|
+
let largeItemWidth = this.largeWidth;
|
|
711
|
+
/** `mediumItemWidth` — ancho calculado o configurado (px) de los elementos secundarios. */
|
|
712
|
+
let mediumItemWidth = this.mediumWidth;
|
|
713
|
+
/** `smallItemWidth` — ancho calculado o configurado (px) de los elementos pequeños/asomando. */
|
|
714
|
+
let smallItemWidth = this.smallWidth;
|
|
715
|
+
/**
|
|
716
|
+
* `largeItemCount` — el número de elementos grandes visibles simultáneamente en la ventana gráfica.
|
|
717
|
+
* La especificación M3 define la fórmula de la ventana gráfica como:
|
|
718
|
+
* availableWidth = largeItemCount × largeItemWidth + mediumItemWidth + smallItemWidth + (largeItemCount + 1) × gap
|
|
719
|
+
*/
|
|
720
|
+
let largeItemCount = 1;
|
|
451
721
|
|
|
452
722
|
if (this.auto) {
|
|
453
723
|
if (this.layout === 'uncontained') {
|
|
454
|
-
//
|
|
455
|
-
// We target
|
|
456
|
-
|
|
724
|
+
// En diseño 'uncontained', todos los elementos tienen el mismo ancho y se desbordan por el borde.
|
|
725
|
+
// We target a comfortable width around 260px, but cap it to availableWidth.
|
|
726
|
+
largeItemWidth = Math.min(availableWidth, 260);
|
|
457
727
|
} else if (this.layout === 'hero') {
|
|
458
|
-
// Hero
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
728
|
+
// M3 Hero layout formula:
|
|
729
|
+
// availableWidth = largeItemCount × largeItemWidth + smallItemWidth + largeItemCount × gap
|
|
730
|
+
// We fix smallItemWidth = 48px (M3 spec minimum peeking width), then solve for largeItemWidth.
|
|
731
|
+
smallItemWidth = 48;
|
|
732
|
+
largeItemCount = Math.max(1, Math.floor((availableWidth - smallItemWidth) / (250 + gap)));
|
|
733
|
+
largeItemWidth = (availableWidth - smallItemWidth - largeItemCount * gap) / largeItemCount;
|
|
734
|
+
|
|
735
|
+
// Límite de seguridad: si el elemento grande fuera menor a 150px, forzar modo de un solo elemento.
|
|
736
|
+
if (largeItemWidth < 150) {
|
|
737
|
+
largeItemCount = 1;
|
|
738
|
+
largeItemWidth = availableWidth - smallItemWidth - gap;
|
|
467
739
|
}
|
|
468
740
|
} else {
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
741
|
+
// M3 Multi-Browse layout formula:
|
|
742
|
+
// availableWidth = largeItemCount × largeItemWidth + mediumItemWidth + smallItemWidth + (largeItemCount + 1) × gap
|
|
743
|
+
// mediumItemWidth = 0.45 × largeItemWidth (derived from M3 spec proportions)
|
|
744
|
+
// Solving: largeItemWidth = (availableWidth - smallItemWidth - (largeItemCount + 1) × gap) / (largeItemCount + 0.45)
|
|
745
|
+
smallItemWidth = 48;
|
|
746
|
+
|
|
747
|
+
// Optimización: probar valores de largeItemCount de 1 a 10 y elegir aquel cuyo
|
|
748
|
+
// largeItemWidth esté más cerca del objetivo ideal de 220px.
|
|
749
|
+
let bestLargeItemCount = 1;
|
|
750
|
+
let bestLargeItemWidth = 220;
|
|
474
751
|
let bestDiff = Infinity;
|
|
475
752
|
|
|
476
|
-
for (let
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
753
|
+
for (let candidateCount = 1; candidateCount <= 10; candidateCount++) {
|
|
754
|
+
// Solve for largeItemWidth given this candidateCount:
|
|
755
|
+
const candidateLargeWidth = (availableWidth - smallItemWidth - (candidateCount + 1) * gap) / (candidateCount + 0.45);
|
|
756
|
+
|
|
757
|
+
// Only accept widths within a reasonable human-perceivable range [140px, 340px].
|
|
758
|
+
if (candidateLargeWidth >= 140 && candidateLargeWidth <= 340) {
|
|
759
|
+
const diff = Math.abs(candidateLargeWidth - 220);
|
|
480
760
|
if (diff < bestDiff) {
|
|
481
761
|
bestDiff = diff;
|
|
482
|
-
|
|
483
|
-
|
|
762
|
+
bestLargeItemCount = candidateCount;
|
|
763
|
+
bestLargeItemWidth = candidateLargeWidth;
|
|
484
764
|
}
|
|
485
765
|
}
|
|
486
766
|
}
|
|
487
767
|
|
|
488
768
|
if (bestDiff === Infinity) {
|
|
489
|
-
|
|
490
|
-
|
|
769
|
+
// Fallback: no valid solution found; use single large item with minimum spacing.
|
|
770
|
+
largeItemCount = 1;
|
|
771
|
+
largeItemWidth = (availableWidth - smallItemWidth - 2 * gap) / 1.45;
|
|
491
772
|
} else {
|
|
492
|
-
|
|
493
|
-
|
|
773
|
+
largeItemCount = bestLargeItemCount;
|
|
774
|
+
largeItemWidth = bestLargeItemWidth;
|
|
494
775
|
}
|
|
495
|
-
|
|
776
|
+
// Especificación M3: el elemento mediano es el 45% del ancho del elemento grande.
|
|
777
|
+
mediumItemWidth = 0.45 * largeItemWidth;
|
|
496
778
|
}
|
|
497
779
|
} else {
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
780
|
+
// ── Manual sizing mode (auto=false) ──────────────────────────────────
|
|
781
|
+
// Usar los valores explícitos de propiedad proporcionados por el consumidor.
|
|
782
|
+
largeItemWidth = this.largeWidth;
|
|
783
|
+
mediumItemWidth = this.mediumWidth;
|
|
784
|
+
smallItemWidth = this.smallWidth;
|
|
502
785
|
|
|
503
786
|
if (this.layout === 'hero') {
|
|
504
|
-
|
|
787
|
+
largeItemCount = 1;
|
|
505
788
|
} else if (this.layout === 'uncontained') {
|
|
506
|
-
|
|
789
|
+
// En modo uncontained, calcular cuántos elementos grandes caben en el ancho disponible.
|
|
790
|
+
largeItemCount = Math.max(1, Math.floor(availableWidth / (largeItemWidth + gap)));
|
|
507
791
|
} else {
|
|
508
|
-
|
|
792
|
+
largeItemCount = 1;
|
|
509
793
|
}
|
|
510
794
|
}
|
|
511
795
|
|
|
512
|
-
|
|
796
|
+
// Retornar los cuatro valores calculados como propiedades nombradas.
|
|
797
|
+
// Callers use destructuring: const { largeItemWidth, mediumItemWidth, smallItemWidth, largeItemCount } = this.computedLayout;
|
|
798
|
+
return { largeItemWidth, mediumItemWidth, smallItemWidth, largeItemCount };
|
|
513
799
|
}
|
|
514
800
|
|
|
515
|
-
|
|
516
|
-
|
|
801
|
+
/**
|
|
802
|
+
* Calcula el ancho total (incluyendo el gap) de un ítem de tamaño 'L' (Large).
|
|
803
|
+
* Este valor se utiliza como unidad de medida (step) para el Scroll Snapping
|
|
804
|
+
* y el cálculo de desplazamiento de páginas (sets).
|
|
805
|
+
*/
|
|
806
|
+
get itemSize() {
|
|
807
|
+
// El paso de ajuste es el ancho completo de la ranura: ancho del elemento más una unidad de espacio.
|
|
808
|
+
// Usado como la unidad de medida para el ajuste de desplazamiento, cálculos de impulso,
|
|
809
|
+
// y el posicionamiento del búfer de desplazamiento infinito.
|
|
810
|
+
return this.computedLayout.largeItemWidth + this.gap;
|
|
517
811
|
}
|
|
518
812
|
|
|
519
|
-
|
|
520
|
-
|
|
813
|
+
/**
|
|
814
|
+
* Algoritmo Central del Carrusel M3.
|
|
815
|
+
*
|
|
816
|
+
* Calcula el ancho visual, la posición horizontal y la opacidad para una sola tarjeta del carrusel
|
|
817
|
+
* basándose en su posición normalizada en relación con la ventana gráfica actualmente visible.
|
|
818
|
+
*
|
|
819
|
+
* Este es el corazón del efecto "Masked Carousel" de M3: a medida que una tarjeta transita de
|
|
820
|
+
* fuera de pantalla a en pantalla (posición -1 → 0 → N), su ancho se interpola de
|
|
821
|
+
* `smallItemWidth` → `largeItemWidth`, y su opacidad aumenta.
|
|
822
|
+
*
|
|
823
|
+
* Semántica de posición:
|
|
824
|
+
* - `normalizedPosition < -1` → tarjeta está fuera de pantalla a la izquierda (oculta)
|
|
825
|
+
* - `normalizedPosition = 0` → tarjeta está en la ranura grande más a la izquierda (enfoque principal)
|
|
826
|
+
* - `normalizedPosition = N-1` → tarjeta está en la ranura grande más a la derecha
|
|
827
|
+
* - `normalizedPosition > N` → tarjeta está fuera de pantalla a la derecha (oculta)
|
|
828
|
+
*
|
|
829
|
+
* @param normalizedPosition - El índice de posición fraccional de la tarjeta en relación con el progreso
|
|
830
|
+
* de desplazamiento actual. Un valor de `0` significa que la tarjeta está perfectamente alineada con la ranura
|
|
831
|
+
* grande inicial; los valores fraccionales representan transiciones parciales.
|
|
832
|
+
* @returns Objeto con `{ width, x, opacity }` para aplicar directamente al elemento de la tarjeta.
|
|
833
|
+
*/
|
|
834
|
+
private _getCardLayout(normalizedPosition: number) {
|
|
835
|
+
const { largeItemWidth, mediumItemWidth, smallItemWidth, largeItemCount } = this.computedLayout;
|
|
521
836
|
const gap = this.gap;
|
|
522
837
|
const padding = this.padding;
|
|
523
838
|
|
|
524
|
-
|
|
525
|
-
let
|
|
839
|
+
// Default: full large-item width, primary slot position, fully visible.
|
|
840
|
+
let width = largeItemWidth;
|
|
841
|
+
let horizontalPosition = padding;
|
|
526
842
|
let opacity = 1;
|
|
843
|
+
let visible = true;
|
|
527
844
|
|
|
528
845
|
if (this.layout === 'uncontained') {
|
|
529
|
-
width
|
|
530
|
-
|
|
846
|
+
// Uncontained: all items same width, positioned linearly without masking.
|
|
847
|
+
width = largeItemWidth;
|
|
848
|
+
horizontalPosition = padding + normalizedPosition * (largeItemWidth + gap);
|
|
531
849
|
opacity = 1;
|
|
850
|
+
const containerWidth = this._containerWidth || this.getBoundingClientRect().width || 360;
|
|
851
|
+
visible = (horizontalPosition + width > 0) && (horizontalPosition < containerWidth);
|
|
532
852
|
} else if (this.layout === 'hero') {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
853
|
+
// ── Anclas de posición para el diseño 'hero' ──────────────────────────
|
|
854
|
+
// `xAfterLastLargeSlot` — la posición X en píxeles inmediatamente después del último elemento grande.
|
|
855
|
+
// Aquí es donde comienza el elemento pequeño asomando.
|
|
856
|
+
const xAfterLastLargeSlot = padding + largeItemCount * (largeItemWidth + gap);
|
|
857
|
+
// `xBeforeFirstLargeSlot` — la posición X en píxeles del elemento pequeño asomando a la izquierda.
|
|
858
|
+
// Alineado al borde negativo exacto para que repose completamente fuera del contenedor.
|
|
859
|
+
const xBeforeFirstLargeSlot = -smallItemWidth;
|
|
860
|
+
|
|
861
|
+
if (normalizedPosition <= -1) {
|
|
862
|
+
// La tarjeta está oculta fuera de la pantalla a la izquierda, apilada detrás del elemento asomando.
|
|
863
|
+
width = smallItemWidth;
|
|
864
|
+
horizontalPosition = xBeforeFirstLargeSlot + (normalizedPosition - -1) * (smallItemWidth + Math.max(gap, padding));
|
|
539
865
|
opacity = 0;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
866
|
+
visible = false;
|
|
867
|
+
} else if (normalizedPosition <= 0) {
|
|
868
|
+
// La tarjeta está en transición de entrada desde la izquierda: morphing de pequeño a grande.
|
|
869
|
+
// `normalizedPosition + 1` reasigna el rango [-1, 0] a [0, 1] para la interpolación.
|
|
870
|
+
width = gsap.utils.interpolate(smallItemWidth, largeItemWidth, normalizedPosition + 1);
|
|
871
|
+
horizontalPosition = gsap.utils.interpolate(xBeforeFirstLargeSlot, padding, normalizedPosition + 1);
|
|
872
|
+
opacity = gsap.utils.interpolate(0, 1, normalizedPosition + 1);
|
|
873
|
+
visible = true;
|
|
874
|
+
} else if (normalizedPosition <= largeItemCount - 1) {
|
|
875
|
+
// La tarjeta está en una de las ranuras grandes (completamente visible).
|
|
876
|
+
width = largeItemWidth;
|
|
877
|
+
horizontalPosition = padding + normalizedPosition * (largeItemWidth + gap);
|
|
547
878
|
opacity = 1;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
879
|
+
visible = true;
|
|
880
|
+
} else if (normalizedPosition <= largeItemCount) {
|
|
881
|
+
// La tarjeta está en transición de salida a la derecha: morphing de grande a pequeño (asomando).
|
|
882
|
+
// `transitionProgress` reasigna [N-1, N] a [0, 1].
|
|
883
|
+
const transitionProgress = normalizedPosition - (largeItemCount - 1);
|
|
884
|
+
const xLastLargeSlot = padding + (largeItemCount - 1) * (largeItemWidth + gap);
|
|
885
|
+
width = gsap.utils.interpolate(largeItemWidth, smallItemWidth, transitionProgress);
|
|
886
|
+
horizontalPosition = gsap.utils.interpolate(xLastLargeSlot, xAfterLastLargeSlot, transitionProgress);
|
|
887
|
+
opacity = gsap.utils.interpolate(1, 0, transitionProgress);
|
|
888
|
+
visible = true;
|
|
554
889
|
} else {
|
|
555
|
-
|
|
556
|
-
|
|
890
|
+
// La tarjeta está completamente fuera de la pantalla a la derecha, más allá de la ranura de asomo.
|
|
891
|
+
const containerWidth = this._containerWidth || this.getBoundingClientRect().width || 360;
|
|
892
|
+
width = smallItemWidth;
|
|
893
|
+
const distanceToClear = Math.max(smallItemWidth + padding, containerWidth - xAfterLastLargeSlot);
|
|
894
|
+
horizontalPosition = xAfterLastLargeSlot + (normalizedPosition - largeItemCount) * distanceToClear;
|
|
557
895
|
opacity = 0;
|
|
896
|
+
visible = false;
|
|
558
897
|
}
|
|
559
898
|
} else {
|
|
560
|
-
//
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
899
|
+
// `xMediumSlot` — X position where the medium item begins (after the last large item).
|
|
900
|
+
const xMediumSlot = padding + largeItemCount * (largeItemWidth + gap);
|
|
901
|
+
// `xSmallSlot` — X position where the small peeking item begins (after the medium item).
|
|
902
|
+
const xSmallSlot = xMediumSlot + mediumItemWidth + gap;
|
|
903
|
+
// `xBeforeFirstLargeSlot` — X position of the peeking small item off the left edge.
|
|
904
|
+
const xBeforeFirstLargeSlot = -smallItemWidth;
|
|
905
|
+
|
|
906
|
+
if (normalizedPosition <= -1) {
|
|
907
|
+
// La tarjeta está oculta fuera de la pantalla a la izquierda.
|
|
908
|
+
width = smallItemWidth;
|
|
909
|
+
horizontalPosition = xBeforeFirstLargeSlot + (normalizedPosition - -1) * (smallItemWidth + Math.max(gap, padding));
|
|
568
910
|
opacity = 0;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
911
|
+
visible = false;
|
|
912
|
+
} else if (normalizedPosition <= 0) {
|
|
913
|
+
// La tarjeta está en transición de entrada desde la izquierda (pequeño → grande).
|
|
914
|
+
width = gsap.utils.interpolate(smallItemWidth, largeItemWidth, normalizedPosition + 1);
|
|
915
|
+
horizontalPosition = gsap.utils.interpolate(xBeforeFirstLargeSlot, padding, normalizedPosition + 1);
|
|
916
|
+
opacity = gsap.utils.interpolate(0, 1, normalizedPosition + 1);
|
|
917
|
+
visible = true;
|
|
918
|
+
} else if (normalizedPosition <= largeItemCount - 1) {
|
|
919
|
+
// La tarjeta está en una de las ranuras grandes (completamente visible).
|
|
920
|
+
width = largeItemWidth;
|
|
921
|
+
horizontalPosition = padding + normalizedPosition * (largeItemWidth + gap);
|
|
576
922
|
opacity = 1;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
923
|
+
visible = true;
|
|
924
|
+
} else if (normalizedPosition <= largeItemCount) {
|
|
925
|
+
// La tarjeta está en transición de grande a mediano (entrando a la ranura mediana).
|
|
926
|
+
const transitionProgress = normalizedPosition - (largeItemCount - 1);
|
|
927
|
+
const xLastLargeSlot = padding + (largeItemCount - 1) * (largeItemWidth + gap);
|
|
928
|
+
width = gsap.utils.interpolate(largeItemWidth, mediumItemWidth, transitionProgress);
|
|
929
|
+
horizontalPosition = gsap.utils.interpolate(xLastLargeSlot, xMediumSlot, transitionProgress);
|
|
930
|
+
// La opacidad se desvanece a 0 a medida que la tarjeta se encoge en la ranura mediana — el efecto de máscara.
|
|
931
|
+
opacity = gsap.utils.interpolate(1, 0, transitionProgress);
|
|
932
|
+
visible = true;
|
|
933
|
+
} else if (normalizedPosition <= largeItemCount + 1) {
|
|
934
|
+
// La tarjeta está en la ranura mediana, en transición hacia la ranura pequeña/asomando.
|
|
935
|
+
const transitionProgress = normalizedPosition - largeItemCount;
|
|
936
|
+
width = gsap.utils.interpolate(mediumItemWidth, smallItemWidth, transitionProgress);
|
|
937
|
+
horizontalPosition = gsap.utils.interpolate(xMediumSlot, xSmallSlot, transitionProgress);
|
|
938
|
+
opacity = 0; // Ya oculta (enmascarada) una vez en la ranura mediana.
|
|
939
|
+
visible = true;
|
|
588
940
|
} else {
|
|
589
|
-
|
|
590
|
-
|
|
941
|
+
// La tarjeta está completamente fuera de la pantalla a la derecha.
|
|
942
|
+
const containerWidth = this._containerWidth || this.getBoundingClientRect().width || 360;
|
|
943
|
+
width = smallItemWidth;
|
|
944
|
+
const distanceToClear = Math.max(smallItemWidth + padding, containerWidth - xSmallSlot);
|
|
945
|
+
horizontalPosition = xSmallSlot + (normalizedPosition - (largeItemCount + 1)) * distanceToClear;
|
|
591
946
|
opacity = 0;
|
|
947
|
+
visible = false;
|
|
592
948
|
}
|
|
593
949
|
}
|
|
594
950
|
|
|
595
|
-
return { width, x, opacity };
|
|
951
|
+
return { width, x: horizontalPosition, opacity, visible };
|
|
596
952
|
}
|
|
597
953
|
|
|
598
954
|
/**
|
|
599
|
-
*
|
|
600
|
-
* quickSetter
|
|
955
|
+
* Reconstruye las cachés de quickSetter después de que cambian las tarjetas.
|
|
956
|
+
* quickSetter evita la sobrecarga de gsap.set() para las propiedades actualizadas en cada cuadro.
|
|
601
957
|
*/
|
|
602
958
|
private _buildQuickSetters() {
|
|
603
959
|
if (!this._cards || this._cards.length === 0) return;
|
|
@@ -606,6 +962,9 @@ export class MoniCarousel extends MoniElement {
|
|
|
606
962
|
this._cardSetWidth = [];
|
|
607
963
|
this._imgSetX = [];
|
|
608
964
|
this._titleSetOpacity = [];
|
|
965
|
+
|
|
966
|
+
// Reset logical viewport state array to match original items count
|
|
967
|
+
this._logicalItemInViewport = new Array(this.effectiveItems.length).fill(false);
|
|
609
968
|
|
|
610
969
|
this._cards.forEach((card) => {
|
|
611
970
|
this._cardSetX.push(gsap.quickSetter(card, 'x', 'px') as (v: number) => void);
|
|
@@ -624,24 +983,57 @@ export class MoniCarousel extends MoniElement {
|
|
|
624
983
|
}
|
|
625
984
|
|
|
626
985
|
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
986
|
+
* Callback del Ticker de GSAP — el bucle de animación principal para el carrusel.
|
|
987
|
+
*
|
|
988
|
+
* Esta función está registrada con `gsap.ticker.add()` y se ejecuta en cada
|
|
989
|
+
* cuadro de animación (sincronizado con `requestAnimationFrame`). Implementa
|
|
990
|
+
* **interpolación exponencial (smoothstep)** para interpolar `_tickerCurrent` hacia
|
|
991
|
+
* `_tickerTarget`, creando una desaceleración de suavizado físicamente plausible.
|
|
992
|
+
*
|
|
993
|
+
* La fórmula de interpolación: `current += (target - current) × easeStrength × deltaTimeRatio`
|
|
994
|
+
*
|
|
995
|
+
* - `easeStrength = 0.08` en móvil: acoplamiento más estrecho para evitar que el carrusel
|
|
996
|
+
* se sienta "lento" o desincronizado con un dedo.
|
|
997
|
+
* - `easeStrength = 0.15` en escritorio: ligeramente más suelto para una sensación más cinematográfica.
|
|
998
|
+
* - `deltaTimeRatio` normaliza la velocidad de animación a través de diferentes velocidades de cuadro
|
|
999
|
+
* (60fps vs 120fps), manteniendo la atenuación percibida constante.
|
|
1000
|
+
*
|
|
1001
|
+
* Una vez que la diferencia entre el objetivo y el actual cae por debajo de 0.0005 (medio
|
|
1002
|
+
* sub-píxel), la animación se considera completa y el ticker se elimina
|
|
1003
|
+
* para conservar los recursos de CPU/GPU entre interacciones.
|
|
629
1004
|
*/
|
|
630
1005
|
private _tick = () => {
|
|
631
|
-
|
|
632
|
-
//
|
|
633
|
-
const
|
|
634
|
-
|
|
1006
|
+
// `deltaTimeRatio` normalizes frame-rate differences. At 60fps it is ≈1;
|
|
1007
|
+
// at 120fps it is ≈0.5, preventing double-speed animations on high-refresh screens.
|
|
1008
|
+
const deltaTimeRatio = gsap.ticker.deltaRatio();
|
|
1009
|
+
|
|
1010
|
+
// Tighter lerp factor on mobile for finger-tracking accuracy;
|
|
1011
|
+
// looser on desktop for cinematic smoothness.
|
|
1012
|
+
const easeStrength = this._isMobile ? 0.08 : 0.15;
|
|
1013
|
+
|
|
1014
|
+
// Lerp exponencial: mueve una fracción de la distancia restante en cada cuadro.
|
|
1015
|
+
this._tickerCurrent += (this._tickerTarget - this._tickerCurrent) * easeStrength * deltaTimeRatio;
|
|
635
1016
|
|
|
1017
|
+
// Umbral de convergencia: detener la animación cuando sea visualmente indistinguible del objetivo.
|
|
1018
|
+
// 0.0005 is well below sub-pixel precision, so no visible jitter will occur.
|
|
636
1019
|
if (Math.abs(this._tickerTarget - this._tickerCurrent) < 0.0005) {
|
|
637
1020
|
this._tickerCurrent = this._tickerTarget;
|
|
638
1021
|
gsap.ticker.remove(this._tick);
|
|
639
1022
|
this._isTicking = false;
|
|
1023
|
+
// Después de que la animación se asiente, comprobar si necesitamos reposicionar
|
|
1024
|
+
// silenciosamente el búfer de desplazamiento para un bucle infinito sin interrupciones.
|
|
1025
|
+
this._checkInfiniteWrap();
|
|
640
1026
|
}
|
|
641
1027
|
|
|
1028
|
+
// Renderizar todas las tarjetas en la nueva posición de desplazamiento interpolada.
|
|
642
1029
|
this._applyLayout(this._tickerCurrent);
|
|
643
1030
|
};
|
|
644
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Lee la posición real del scroll nativo y arranca el ticker de animación si no está corriendo.
|
|
1034
|
+
* Esta separación es crucial para el rendimiento: leemos el DOM aquí (scrollLeft) pero
|
|
1035
|
+
* escribimos los estilos en `_tick` -> `_applyLayout`.
|
|
1036
|
+
*/
|
|
645
1037
|
private _scheduleLayout = () => {
|
|
646
1038
|
if (!this._scrollContainer) return;
|
|
647
1039
|
this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
|
|
@@ -651,16 +1043,48 @@ export class MoniCarousel extends MoniElement {
|
|
|
651
1043
|
}
|
|
652
1044
|
};
|
|
653
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* Función principal de sincronización de estado.
|
|
1048
|
+
* Refresca la lógica del scroll infinito y fuerza una recolección de los setters de GSAP
|
|
1049
|
+
* si la cantidad de tarjetas cambió. Puede saltarse el ticker y ser instantánea (ej. al redimensionar la ventana).
|
|
1050
|
+
*/
|
|
654
1051
|
updateLayout = (instant = false) => {
|
|
655
1052
|
if (!this._scrollContainer || !this._cards || this._cards.length === 0) return;
|
|
656
1053
|
|
|
657
|
-
//
|
|
1054
|
+
// Re-cacheamos las referencias DOM de GSAP si la cantidad de tarjetas varió (e.g., inserción dinámica de nodos)
|
|
658
1055
|
if (this._cardSetX.length !== this._cards.length) {
|
|
659
1056
|
this._buildQuickSetters();
|
|
660
1057
|
}
|
|
661
1058
|
|
|
1059
|
+
// Lógica de montaje inicial para el carrusel infinito
|
|
1060
|
+
if (this.infinite && !this._infiniteInitialized && this.effectiveItems.length > 0) {
|
|
1061
|
+
const C = this.effectiveItems.length;
|
|
1062
|
+
const V = C * this._visualCardsMultiplier;
|
|
1063
|
+
const K = this._snapSetsCount;
|
|
1064
|
+
const centerSetIndex = Math.floor(K / 2);
|
|
1065
|
+
|
|
1066
|
+
// Doble rAF asegura que el navegador completó el reflow de CSS (scrollWidth) antes de inyectar el scroll
|
|
1067
|
+
requestAnimationFrame(() => {
|
|
1068
|
+
setTimeout(() => {
|
|
1069
|
+
if (this._scrollContainer) {
|
|
1070
|
+
// Forzamos el recálculo interno del layout
|
|
1071
|
+
this._scrollContainer.scrollWidth;
|
|
1072
|
+
// Desplazamos silenciosamente al usuario hacia la mitad del contenedor clonado
|
|
1073
|
+
this._scrollContainer.scrollLeft = centerSetIndex * V * this.itemSize;
|
|
1074
|
+
|
|
1075
|
+
// Forzamos al ticker visual a asimilar la posición de golpe, evitando un "flash" de animación desde 0
|
|
1076
|
+
this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
|
|
1077
|
+
this._tickerCurrent = this._tickerTarget;
|
|
1078
|
+
this._applyLayout(this._tickerCurrent);
|
|
1079
|
+
}
|
|
1080
|
+
}, 20);
|
|
1081
|
+
});
|
|
1082
|
+
this._infiniteInitialized = true;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
662
1085
|
this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
|
|
663
1086
|
|
|
1087
|
+
// Si pasamos instant=true, bypassamos el smoothing temporalmente
|
|
664
1088
|
if (instant || (!this._isTicking && this._tickerCurrent === 0)) {
|
|
665
1089
|
this._tickerCurrent = this._tickerTarget;
|
|
666
1090
|
this._applyLayout(this._tickerCurrent);
|
|
@@ -672,92 +1096,386 @@ export class MoniCarousel extends MoniElement {
|
|
|
672
1096
|
}
|
|
673
1097
|
};
|
|
674
1098
|
|
|
675
|
-
|
|
1099
|
+
/**
|
|
1100
|
+
* Bucle de renderizado principal — aplica el diseño calculado a cada tarjeta en el DOM.
|
|
1101
|
+
*
|
|
1102
|
+
* Llamado en cada cuadro del ticker de GSAP a través de `_tick()`. Itera sobre todos los elementos físicos
|
|
1103
|
+
* de las tarjetas y aplica ancho, posición, paralaje y opacidad del título
|
|
1104
|
+
* usando quickSetters de GSAP pre-almacenados en caché para un rendimiento máximo de velocidad de fotogramas.
|
|
1105
|
+
*
|
|
1106
|
+
* **Bucle de desplazamiento infinito:**
|
|
1107
|
+
* Cuando `infinite=true`, la posición normalizada se calcula usando aritmética
|
|
1108
|
+
* modular para que las tarjetas se repitan sin problemas alrededor del centro virtual del búfer.
|
|
1109
|
+
* Una corrección de envoltura asegura que cada tarjeta permanezca dentro de una distancia de medio ancho de búfer
|
|
1110
|
+
* de la posición de desplazamiento actual.
|
|
1111
|
+
*
|
|
1112
|
+
* **Optimización fuera de pantalla:**
|
|
1113
|
+
* Las tarjetas que están a más de 15 posiciones fuera de la pantalla se mueven a `x = -9999` y sus
|
|
1114
|
+
* títulos se ocultan. Esto evita que el navegador gaste el presupuesto de composición
|
|
1115
|
+
* en elementos invisibles.
|
|
1116
|
+
*
|
|
1117
|
+
* @param scrollProgress - La posición de desplazamiento interpolada actual en unidades de `itemSize`.
|
|
1118
|
+
* Un valor de `1.0` significa que el contenedor de desplazamiento ha avanzado exactamente el ancho de un elemento + espacio.
|
|
1119
|
+
*/
|
|
1120
|
+
private _applyLayout(scrollProgress: number) {
|
|
676
1121
|
const isMobile = this._isMobile;
|
|
677
1122
|
const cardCount = this._cards.length;
|
|
678
|
-
for (let i = 0; i < cardCount; i++) {
|
|
679
|
-
const p = i - t;
|
|
680
|
-
const layout = this._getCardLayout(p);
|
|
681
1123
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1124
|
+
const visibleLogicalItems = new Map<number, HTMLDivElement>();
|
|
1125
|
+
const allLogicalItems = new Map<number, HTMLDivElement>();
|
|
1126
|
+
|
|
1127
|
+
for (let cardIndex = 0; cardIndex < cardCount; cardIndex++) {
|
|
1128
|
+
// `normalizedPosition` representa cuántas ranuras de elementos esta tarjeta está
|
|
1129
|
+
// desplazada desde el punto focal actual (0 = ranura grande primaria).
|
|
1130
|
+
let normalizedPosition = cardIndex - scrollProgress;
|
|
1131
|
+
|
|
1132
|
+
const card = this._cards[cardIndex];
|
|
1133
|
+
const dataIndex = Number(card.dataset.index || 0);
|
|
1134
|
+
const logicalIndex = this.infinite ? dataIndex % this.effectiveItems.length : dataIndex;
|
|
1135
|
+
|
|
1136
|
+
// Mapear cada índice lógico a un nodo físico para garantizar que tengamos
|
|
1137
|
+
// una referencia DOM incluso cuando la tarjeta esté saliendo (leave event).
|
|
1138
|
+
if (!allLogicalItems.has(logicalIndex)) {
|
|
1139
|
+
allLogicalItems.set(logicalIndex, card);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (this.infinite) {
|
|
1143
|
+
// Envolver la posición usando aritmética modular para mantener cada tarjeta dentro de
|
|
1144
|
+
// una distancia de ±medio-búfer de la posición de desplazamiento actual.
|
|
1145
|
+
normalizedPosition = cardIndex - (scrollProgress % cardCount);
|
|
1146
|
+
if (normalizedPosition > cardCount / 2) normalizedPosition -= cardCount;
|
|
1147
|
+
if (normalizedPosition < -cardCount / 2) normalizedPosition += cardCount;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Omitir renderizado para tarjetas muy fuera del viewport visible para reducir
|
|
1151
|
+
// la carga de composición del navegador (un margen de seguridad de ~30 tarjetas alrededor de la pantalla).
|
|
1152
|
+
if (normalizedPosition < -15 || normalizedPosition > 15) {
|
|
1153
|
+
// Move the card completely off-screen and hide its title.
|
|
1154
|
+
this._cardSetX[cardIndex](-9999);
|
|
1155
|
+
this._titleSetOpacity[cardIndex](0);
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Get the precise pixel width and position for this card at its current normalized position.
|
|
1160
|
+
const cardLayoutResult = this._getCardLayout(normalizedPosition);
|
|
1161
|
+
|
|
1162
|
+
// Map logical visibility
|
|
1163
|
+
if (cardLayoutResult.visible) {
|
|
1164
|
+
if (!visibleLogicalItems.has(logicalIndex)) {
|
|
1165
|
+
visibleLogicalItems.set(logicalIndex, card);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Apply position and width via quickSetters (~3× faster than gsap.set() per call).
|
|
1170
|
+
this._cardSetX[cardIndex](cardLayoutResult.x);
|
|
1171
|
+
this._cardSetWidth[cardIndex](cardLayoutResult.width);
|
|
685
1172
|
|
|
686
|
-
//
|
|
1173
|
+
// Parallax effect: shift the image slightly in the opposite direction of scroll
|
|
1174
|
+
// to create depth. Clamped to ±1 to prevent over-shifting at extreme positions.
|
|
1175
|
+
// Skipped on mobile to conserve per-frame computation budget.
|
|
687
1176
|
if (!isMobile) {
|
|
688
|
-
const
|
|
689
|
-
|
|
1177
|
+
const clampedParallaxPosition = Math.max(-1, Math.min(1, normalizedPosition));
|
|
1178
|
+
// Image shifts up to ±25px to create the parallax depth illusion.
|
|
1179
|
+
this._imgSetX[cardIndex](clampedParallaxPosition * -25);
|
|
690
1180
|
}
|
|
691
1181
|
|
|
692
|
-
this._titleSetOpacity[
|
|
1182
|
+
this._titleSetOpacity[cardIndex](cardLayoutResult.opacity);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Calculate diff over logical items to dispatch exact events globally without jump glitches
|
|
1186
|
+
for (let i = 0; i < this.effectiveItems.length; i++) {
|
|
1187
|
+
const isNowVisible = visibleLogicalItems.has(i);
|
|
1188
|
+
if (this._logicalItemInViewport[i] !== isNowVisible) {
|
|
1189
|
+
this._logicalItemInViewport[i] = isNowVisible;
|
|
1190
|
+
const eventName = isNowVisible ? 'moni-item-entered-viewport' : 'moni-item-left-viewport';
|
|
1191
|
+
// Usamos el nodo físico visible si entra, o el nodo físico de respaldo si sale
|
|
1192
|
+
const physicalCard = visibleLogicalItems.get(i) || allLogicalItems.get(i) || null;
|
|
1193
|
+
emitMoniEvent<CarouselItem>(this, eventName, {
|
|
1194
|
+
detail: { item: this.effectiveItems[i], index: i, card: physicalCard }
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
693
1197
|
}
|
|
694
1198
|
}
|
|
695
1199
|
|
|
696
|
-
|
|
1200
|
+
/**
|
|
1201
|
+
* Calcula el multiplicador necesario para rellenar visualmente el carrusel infinito.
|
|
1202
|
+
* Si la lista original de ítems es corta (ej: 3 ítems) pero el viewport puede
|
|
1203
|
+
* mostrar 5, necesitamos multiplicar la lista original para cubrir el ancho de
|
|
1204
|
+
* la pantalla e interactuar de forma ininterrumpida (Infinity Wrap).
|
|
1205
|
+
*/
|
|
1206
|
+
private get _visualCardsMultiplier() {
|
|
1207
|
+
if (!this.infinite || this.effectiveItems.length === 0) return 1;
|
|
1208
|
+
const C = this.effectiveItems.length;
|
|
1209
|
+
// We need at least ~15 cards to fill the screen seamlessly for small datasets
|
|
1210
|
+
return Math.max(1, Math.ceil(15 / C));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Calcula cuántos "sets" completos de las cards originales se deben clonar
|
|
1215
|
+
* hacia los lados para habilitar el Infinite Scroll.
|
|
1216
|
+
* Usualmente es de 3 a 5 "sets" multiplicados para crear un buffer circular
|
|
1217
|
+
* invisible para el usuario final.
|
|
1218
|
+
*/
|
|
1219
|
+
private get _snapSetsCount() {
|
|
1220
|
+
if (!this.infinite || this.effectiveItems.length === 0) return 1;
|
|
1221
|
+
const C = this.effectiveItems.length;
|
|
1222
|
+
const V = C * this._visualCardsMultiplier;
|
|
1223
|
+
// We want enough snap items so the user NEVER reaches the end in a single fast swipe.
|
|
1224
|
+
// A total of ~1000 items is extremely cheap as empty divs, and provides a massive buffer.
|
|
1225
|
+
let K = Math.ceil(1000 / V);
|
|
1226
|
+
if (K % 2 === 0) K += 1; // Keep it odd for a perfect center
|
|
1227
|
+
return Math.max(3, K);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Determina el conteo total físico de cards (`li`) que serán montados en el DOM.
|
|
1232
|
+
* Es el producto de `effectiveItems.length` multiplicado por `_snapSetsCount`.
|
|
1233
|
+
* Para carruseles finitos (no infinite), esto simplemente retorna la cantidad original.
|
|
1234
|
+
*/
|
|
1235
|
+
private get _snapItemsCount() {
|
|
1236
|
+
if (this.infinite) {
|
|
1237
|
+
const C = this.effectiveItems.length;
|
|
1238
|
+
const V_mult = this._visualCardsMultiplier;
|
|
1239
|
+
const K = this._snapSetsCount;
|
|
1240
|
+
return K * (C * V_mult);
|
|
1241
|
+
}
|
|
1242
|
+
return this.effectiveItems.length;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Lógica de Bucle Infinito (Infinite Wrap).
|
|
1247
|
+
*
|
|
1248
|
+
* Analiza el valor actual del `scrollLeft` del contenedor. Si el usuario se
|
|
1249
|
+
* acerca demasiado al límite del buffer (físico izquierdo o derecho) de los ítems
|
|
1250
|
+
* clonados, este método reposiciona instantáneamente (saltándose animaciones/scroll-behavior)
|
|
1251
|
+
* el scrollLeft hacia el bloque central equivalente.
|
|
1252
|
+
* El usuario nunca nota el salto visual porque los ítems en esas coordenadas lucen idénticos.
|
|
1253
|
+
*/
|
|
1254
|
+
private _checkInfiniteWrap() {
|
|
1255
|
+
if (!this.infinite || this.effectiveItems.length === 0 || !this._scrollContainer) return;
|
|
1256
|
+
|
|
1257
|
+
const C = this.effectiveItems.length;
|
|
1258
|
+
const V = C * this._visualCardsMultiplier;
|
|
1259
|
+
const K = this._snapSetsCount;
|
|
1260
|
+
const centerSetIndex = Math.floor(K / 2);
|
|
1261
|
+
|
|
1262
|
+
// The scroll track is divided into K sets, each containing V items.
|
|
1263
|
+
const setWidth = V * this.itemSize;
|
|
1264
|
+
|
|
1265
|
+
const currentScroll = this._scrollContainer.scrollLeft;
|
|
1266
|
+
const currentSet = Math.floor(currentScroll / setWidth);
|
|
1267
|
+
|
|
1268
|
+
if (currentSet !== centerSetIndex) {
|
|
1269
|
+
const delta = (centerSetIndex - currentSet) * setWidth;
|
|
1270
|
+
|
|
1271
|
+
const oldSnap = this._scrollContainer.style.scrollSnapType;
|
|
1272
|
+
const oldBehavior = this._scrollContainer.style.scrollBehavior;
|
|
1273
|
+
|
|
1274
|
+
// Temporarily disable snap and smooth behavior so the wrap is instantaneous
|
|
1275
|
+
this._scrollContainer.style.scrollSnapType = 'none';
|
|
1276
|
+
this._scrollContainer.style.scrollBehavior = 'auto';
|
|
1277
|
+
|
|
1278
|
+
this._scrollContainer.scrollLeft += delta;
|
|
1279
|
+
|
|
1280
|
+
// INSTANTLY update the GSAP visual ticker so it doesn't animate the jump
|
|
1281
|
+
this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
|
|
1282
|
+
this._tickerCurrent = this._tickerTarget;
|
|
1283
|
+
this._applyLayout(this._tickerCurrent);
|
|
1284
|
+
|
|
1285
|
+
// Restore original scroll behavior
|
|
1286
|
+
this._scrollContainer.style.scrollBehavior = oldBehavior;
|
|
1287
|
+
|
|
1288
|
+
if (this._isDragging) {
|
|
1289
|
+
this._scrollLeftAtDragStart += delta;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
if (oldSnap && oldSnap !== 'none') {
|
|
1293
|
+
// Re-enable snap on the next frame to avoid jumping
|
|
1294
|
+
requestAnimationFrame(() => {
|
|
1295
|
+
if (this._scrollContainer && !this._isDragging) {
|
|
1296
|
+
this._scrollContainer.style.scrollSnapType = oldSnap;
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Inicia el temporizador de reproducción automática (Autoplay).
|
|
1305
|
+
* Evalúa si el usuario no está interactuando activamente (`_isDown`)
|
|
1306
|
+
* antes de forzar el scroll hacia la siguiente vista.
|
|
1307
|
+
*/
|
|
1308
|
+
private _startAutoplay() {
|
|
1309
|
+
this._stopAutoplay();
|
|
1310
|
+
if (this.autoplay && this.effectiveItems.length > 0) {
|
|
1311
|
+
this._autoplayTimer = setInterval(() => {
|
|
1312
|
+
// We removed _isInteracting to make autoplay more robust. It only pauses on active drag.
|
|
1313
|
+
if (!this._isDragging) {
|
|
1314
|
+
this._scrollNext();
|
|
1315
|
+
}
|
|
1316
|
+
}, this.autoplayInterval) as unknown as number;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Detiene y limpia el temporizador de reproducción automática (Autoplay).
|
|
1322
|
+
* Invocado durante interacciones del usuario (hover, drag) o al desmontar.
|
|
1323
|
+
*/
|
|
1324
|
+
private _stopAutoplay() {
|
|
1325
|
+
if (this._autoplayTimer) {
|
|
1326
|
+
clearInterval(this._autoplayTimer);
|
|
1327
|
+
this._autoplayTimer = null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Manejador de evento: `mouseenter`.
|
|
1333
|
+
* Expuesto por si se requiere pausar el autoplay al hacer hover (actualmente en desuso).
|
|
1334
|
+
*/
|
|
1335
|
+
private _handleMouseEnter() {
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Manejador de evento: `mouseleave`.
|
|
1340
|
+
* Finaliza forzosamente cualquier secuencia de drag o interacción táctil en progreso
|
|
1341
|
+
* si el puntero abandona el área de bounding box del carrusel.
|
|
1342
|
+
*/
|
|
1343
|
+
private _handleMouseLeave() {
|
|
1344
|
+
this._handleMouseUp();
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Inicia la secuencia de arrastre (Pointer/Mouse Down).
|
|
1349
|
+
* - Elimina las animaciones GSAP en vuelo para evitar conflictos elásticos.
|
|
1350
|
+
* - Captura la posición de inicio X y resetea el acumulador de fricción y velocidad.
|
|
1351
|
+
* - Realiza una comprobación de Infinite Wrap preliminar por si el usuario
|
|
1352
|
+
* comienza a arrastrar desde el límite del buffer.
|
|
1353
|
+
*/
|
|
1354
|
+
private _handleMouseDown(e: MouseEvent) {
|
|
1355
|
+
// Kill any in-flight GSAP tween on the scroll container (e.g. momentum scroll
|
|
1356
|
+
// from a previous release) to prevent fighting with the new drag gesture.
|
|
697
1357
|
gsap.killTweensOf(this._scrollContainer);
|
|
698
|
-
|
|
699
|
-
|
|
1358
|
+
|
|
1359
|
+
// Check for infinite wrap before starting drag, in case the user
|
|
1360
|
+
// starts a new drag while near the buffer edge.
|
|
1361
|
+
this._checkInfiniteWrap();
|
|
1362
|
+
|
|
1363
|
+
this._isDragging = true;
|
|
1364
|
+
this._totalDraggedDistance = 0;
|
|
1365
|
+
|
|
1366
|
+
// Disable native scroll-snap and smooth behavior during drag so the
|
|
1367
|
+
// scroll position tracks the pointer pixel-perfectly.
|
|
700
1368
|
this._scrollContainer.style.scrollBehavior = 'auto';
|
|
701
1369
|
this._scrollContainer.style.scrollSnapType = 'none';
|
|
702
1370
|
|
|
703
|
-
|
|
704
|
-
this.
|
|
705
|
-
this.
|
|
706
|
-
|
|
707
|
-
|
|
1371
|
+
// Capture the starting position relative to the scroll container's left edge.
|
|
1372
|
+
this._dragStartX = e.pageX - this._scrollContainer.offsetLeft;
|
|
1373
|
+
this._scrollLeftAtDragStart = this._scrollContainer.scrollLeft;
|
|
1374
|
+
|
|
1375
|
+
// Reset velocity tracking for fresh momentum calculation.
|
|
1376
|
+
this._pointerVelocity = 0;
|
|
1377
|
+
this._lastPointerX = e.pageX;
|
|
1378
|
+
this._lastPointerTime = performance.now();
|
|
708
1379
|
}
|
|
709
1380
|
|
|
710
|
-
|
|
711
|
-
|
|
1381
|
+
/**
|
|
1382
|
+
* Maneja el evento de movimiento del puntero durante un arrastre (Drag).
|
|
1383
|
+
*
|
|
1384
|
+
* @logic
|
|
1385
|
+
* 1. Calcula el delta (`walk`) respecto a la posición original X.
|
|
1386
|
+
* 2. Si excede el threshold de arrastre (3px), comienza a inyectar el scroll.
|
|
1387
|
+
* 3. En carruseles finitos (no infinite), si el usuario sobrepasa el inicio o el final,
|
|
1388
|
+
* aplica una fricción exponencial al `walk` para simular la "resistencia elástica" (Overscroll).
|
|
1389
|
+
* 4. Captura la velocidad (`_velocity`) calculando el delta dividido por el lapso de tiempo.
|
|
1390
|
+
*/
|
|
1391
|
+
private _handleMouseMove(e: MouseEvent) {
|
|
1392
|
+
if (!this._isDragging) return;
|
|
712
1393
|
e.preventDefault();
|
|
713
1394
|
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
1395
|
+
const currentPointerX = e.pageX;
|
|
1396
|
+
// `pointerOffsetInContainer` is the pointer's X relative to the scroll container's left edge.
|
|
1397
|
+
const pointerOffsetInContainer = currentPointerX - this._scrollContainer.offsetLeft;
|
|
1398
|
+
|
|
1399
|
+
// Apply a 1.25× multiplier for a faster-feeling drag that closely matches
|
|
1400
|
+
// the M3 Expressive motion spec for carousels.
|
|
1401
|
+
this._scrollContainer.scrollLeft = this._scrollLeftAtDragStart + (this._dragStartX - pointerOffsetInContainer) * 1.25;
|
|
717
1402
|
|
|
1403
|
+
// Calculate instantaneous velocity (px/ms) for the momentum/inertia calculation on release.
|
|
718
1404
|
const currentTime = performance.now();
|
|
719
|
-
const
|
|
720
|
-
if (
|
|
721
|
-
this.
|
|
1405
|
+
const elapsedMs = currentTime - this._lastPointerTime;
|
|
1406
|
+
if (elapsedMs > 0) {
|
|
1407
|
+
this._pointerVelocity = (currentPointerX - this._lastPointerX) / elapsedMs;
|
|
722
1408
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
this.
|
|
1409
|
+
|
|
1410
|
+
// Accumulate total distance to differentiate deliberate swipes from accidental click-drags.
|
|
1411
|
+
this._totalDraggedDistance += Math.abs(currentPointerX - this._lastPointerX);
|
|
1412
|
+
this._lastPointerX = currentPointerX;
|
|
1413
|
+
this._lastPointerTime = currentTime;
|
|
726
1414
|
}
|
|
727
1415
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1416
|
+
/**
|
|
1417
|
+
* Finaliza la secuencia de arrastre (Pointer/Mouse Up).
|
|
1418
|
+
*
|
|
1419
|
+
* @logic
|
|
1420
|
+
* Restaura el "Scroll Snap" que fue desactivado durante el drag (para permitir un
|
|
1421
|
+
* arrastre libre píxel por píxel). Adicionalmente, si el arrastre acumuló suficiente
|
|
1422
|
+
* velocidad (Inercia), inyecta esa inercia desplazando imperativamente el contenedor.
|
|
1423
|
+
*/
|
|
1424
|
+
private _handleMouseUp() {
|
|
1425
|
+
if (!this._isDragging) return;
|
|
1426
|
+
this._isDragging = false;
|
|
731
1427
|
|
|
1428
|
+
// If the last pointer event was more than 100ms ago, the user paused
|
|
1429
|
+
// before releasing — treat it as a zero-velocity drop (no momentum).
|
|
732
1430
|
const currentTime = performance.now();
|
|
733
|
-
if (currentTime - this.
|
|
734
|
-
this.
|
|
1431
|
+
if (currentTime - this._lastPointerTime > 100) {
|
|
1432
|
+
this._pointerVelocity = 0;
|
|
735
1433
|
}
|
|
736
1434
|
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
1435
|
+
// ── Momentum / Inertia calculation ───────────────────────────────────
|
|
1436
|
+
// Dynamic momentum multiplier: faster swipes multiply further.
|
|
1437
|
+
// Base of 350px + an additional 300px per px/ms of velocity.
|
|
1438
|
+
const momentumMultiplier = 350 + Math.abs(this._pointerVelocity) * 300;
|
|
1439
|
+
// Project the target scroll position based on the velocity vector.
|
|
1440
|
+
let projectedScrollLeft = this._scrollContainer.scrollLeft - this._pointerVelocity * momentumMultiplier;
|
|
1441
|
+
|
|
1442
|
+
// Cap the throw distance at 15 items to prevent the user from flying past
|
|
1443
|
+
// the infinite scroll buffer boundaries in a single fast swipe.
|
|
1444
|
+
const maxMomentumThrowDistance = 15 * this.itemSize;
|
|
1445
|
+
if (projectedScrollLeft < this._scrollContainer.scrollLeft - maxMomentumThrowDistance) {
|
|
1446
|
+
projectedScrollLeft = this._scrollContainer.scrollLeft - maxMomentumThrowDistance;
|
|
1447
|
+
} else if (projectedScrollLeft > this._scrollContainer.scrollLeft + maxMomentumThrowDistance) {
|
|
1448
|
+
projectedScrollLeft = this._scrollContainer.scrollLeft + maxMomentumThrowDistance;
|
|
1449
|
+
}
|
|
740
1450
|
|
|
741
1451
|
if (this.layout !== 'uncontained') {
|
|
1452
|
+
// Disable snap while GSAP animates to the snap point to allow smooth fly-through.
|
|
742
1453
|
this._scrollContainer.style.scrollSnapType = 'none';
|
|
743
1454
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
1455
|
+
// Snap to the nearest item index in the direction of momentum.
|
|
1456
|
+
let targetSnapIndex = Math.round(projectedScrollLeft / this.itemSize);
|
|
1457
|
+
const maxSnapIndex = this.effectiveItems.length > 0 ? this._snapItemsCount - 1 : 0;
|
|
1458
|
+
// Clamp to valid index range.
|
|
1459
|
+
targetSnapIndex = Math.max(0, Math.min(maxSnapIndex, targetSnapIndex));
|
|
1460
|
+
const snapTargetScrollLeft = targetSnapIndex * this.itemSize;
|
|
747
1461
|
|
|
748
|
-
// Dynamic duration: longer distance = more time,
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
const
|
|
1462
|
+
// Dynamic duration: longer distance = more time, capped at 0.85s.
|
|
1463
|
+
// This gives a natural "lighter = faster, heavier = slower" momentum feel.
|
|
1464
|
+
const currentSnapIndex = Math.round(this._scrollContainer.scrollLeft / this.itemSize);
|
|
1465
|
+
const cardsTraveled = Math.abs(targetSnapIndex - currentSnapIndex);
|
|
1466
|
+
const snapDuration = Math.min(0.85, 0.25 + cardsTraveled * 0.04);
|
|
752
1467
|
|
|
753
1468
|
this._scrollContainer.style.scrollBehavior = 'auto';
|
|
754
|
-
|
|
1469
|
+
|
|
1470
|
+
// Animate the scroll to the snap point using GSAP for precise easing control.
|
|
755
1471
|
gsap.to(this._scrollContainer, {
|
|
756
|
-
scrollLeft:
|
|
757
|
-
duration:
|
|
758
|
-
ease: 'power3.out',
|
|
1472
|
+
scrollLeft: snapTargetScrollLeft,
|
|
1473
|
+
duration: snapDuration,
|
|
1474
|
+
ease: 'power3.out', // Decelerating ease matching M3 Expressive spring curves.
|
|
759
1475
|
onComplete: () => {
|
|
760
|
-
|
|
1476
|
+
// Restore native CSS scroll-snap after the animation completes
|
|
1477
|
+
// so keyboard and touch-based scrolling can snap again.
|
|
1478
|
+
if (!this._isDragging && this._scrollContainer) {
|
|
761
1479
|
this._scrollContainer.style.scrollSnapType = 'x mandatory';
|
|
762
1480
|
}
|
|
763
1481
|
}
|
|
@@ -765,43 +1483,77 @@ export class MoniCarousel extends MoniElement {
|
|
|
765
1483
|
}
|
|
766
1484
|
}
|
|
767
1485
|
|
|
768
|
-
|
|
1486
|
+
/**
|
|
1487
|
+
* Inicia la secuencia de arrastre táctil (Touch Start).
|
|
1488
|
+
* Funciona análogamente a `_handleMouseDown`: detiene animaciones GSAP, evalúa el Infinite Wrap
|
|
1489
|
+
* y captura las coordenadas táctiles iniciales (`e.touches[0]`).
|
|
1490
|
+
*/
|
|
1491
|
+
private _handleTouchStart(e: TouchEvent) {
|
|
1492
|
+
// Mirror the mouse-down setup: kill flying animations, check for wrap,
|
|
1493
|
+
// then capture the starting touch coordinates.
|
|
769
1494
|
gsap.killTweensOf(this._scrollContainer);
|
|
770
|
-
this.
|
|
771
|
-
this.
|
|
1495
|
+
this._checkInfiniteWrap();
|
|
1496
|
+
this._isDragging = true;
|
|
1497
|
+
this._totalDraggedDistance = 0;
|
|
772
1498
|
this._scrollContainer.style.scrollBehavior = 'auto';
|
|
773
1499
|
this._scrollContainer.style.scrollSnapType = 'none';
|
|
774
1500
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
this.
|
|
778
|
-
this.
|
|
779
|
-
this.
|
|
780
|
-
this.
|
|
1501
|
+
// `e.touches[0]` is the first active touch point on the screen.
|
|
1502
|
+
const primaryTouch = e.touches[0];
|
|
1503
|
+
this._dragStartX = primaryTouch.pageX - this._scrollContainer.offsetLeft;
|
|
1504
|
+
this._scrollLeftAtDragStart = this._scrollContainer.scrollLeft;
|
|
1505
|
+
this._pointerVelocity = 0;
|
|
1506
|
+
this._lastPointerX = primaryTouch.pageX;
|
|
1507
|
+
this._lastPointerTime = performance.now();
|
|
781
1508
|
}
|
|
782
1509
|
|
|
783
|
-
|
|
784
|
-
|
|
1510
|
+
/**
|
|
1511
|
+
* Maneja el evento de movimiento táctil (Touch Move).
|
|
1512
|
+
* Actualiza el `scrollLeft` del contenedor imperativamente según el delta de arrastre.
|
|
1513
|
+
* Calcula la velocidad del dedo para la simulación de inercia posterior.
|
|
1514
|
+
*/
|
|
1515
|
+
private _handleTouchMove(e: TouchEvent) {
|
|
1516
|
+
if (!this._isDragging) return;
|
|
1517
|
+
|
|
1518
|
+
const primaryTouch = e.touches[0];
|
|
1519
|
+
const currentTouchX = primaryTouch.pageX;
|
|
1520
|
+
// Compute the touch offset relative to the scroll container's left edge.
|
|
1521
|
+
const touchOffsetInContainer = currentTouchX - this._scrollContainer.offsetLeft;
|
|
785
1522
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const x = currentX - this._scrollContainer.offsetLeft;
|
|
789
|
-
this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x);
|
|
1523
|
+
// Update scroll position directly (no multiplier for touch — native 1:1 feel).
|
|
1524
|
+
this._scrollContainer.scrollLeft = this._scrollLeftAtDragStart + (this._dragStartX - touchOffsetInContainer);
|
|
790
1525
|
|
|
1526
|
+
// Track velocity for momentum calculation on touch end.
|
|
791
1527
|
const currentTime = performance.now();
|
|
792
|
-
const
|
|
793
|
-
if (
|
|
794
|
-
this.
|
|
1528
|
+
const elapsedMs = currentTime - this._lastPointerTime;
|
|
1529
|
+
if (elapsedMs > 0) {
|
|
1530
|
+
this._pointerVelocity = (currentTouchX - this._lastPointerX) / elapsedMs;
|
|
795
1531
|
}
|
|
796
|
-
this.
|
|
797
|
-
this.
|
|
798
|
-
this.
|
|
1532
|
+
this._totalDraggedDistance += Math.abs(currentTouchX - this._lastPointerX);
|
|
1533
|
+
this._lastPointerX = currentTouchX;
|
|
1534
|
+
this._lastPointerTime = currentTime;
|
|
799
1535
|
}
|
|
800
1536
|
|
|
1537
|
+
/**
|
|
1538
|
+
* Finaliza la secuencia de arrastre táctil (Touch End).
|
|
1539
|
+
* Delega en `_handleMouseUp()` para aplicar inercia y restaurar el Snap.
|
|
1540
|
+
*/
|
|
801
1541
|
private _handleTouchEnd() {
|
|
802
1542
|
this._handleMouseUp();
|
|
803
1543
|
}
|
|
804
1544
|
|
|
1545
|
+
/**
|
|
1546
|
+
* Manejador del evento Scroll/Wheel.
|
|
1547
|
+
*
|
|
1548
|
+
* @logic
|
|
1549
|
+
* 1. Filtra para procesar únicamente el scroll vertical (`deltaY > deltaX`), típico de ruedas de ratón.
|
|
1550
|
+
* 2. Si el usuario está en los bordes extremos del carrusel, permite que el evento de scroll
|
|
1551
|
+
* haga bubble hacia arriba para hacer scroll a la página completa, logrando una interacción fluida.
|
|
1552
|
+
* 3. Si no, previene el scroll de la página (`e.preventDefault`) y lo convierte en scroll horizontal
|
|
1553
|
+
* para el carrusel.
|
|
1554
|
+
* 4. Desactiva temporalmente el "CSS Scroll Snap" para que el movimiento sea suave, restaurándolo
|
|
1555
|
+
* tras 150ms de inactividad (`_wheelTimeout`).
|
|
1556
|
+
*/
|
|
805
1557
|
private _handleWheel(e: WheelEvent) {
|
|
806
1558
|
// Solo interceptar scroll vertical puro (rueda de mouse típica)
|
|
807
1559
|
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
|
@@ -831,55 +1583,91 @@ export class MoniCarousel extends MoniElement {
|
|
|
831
1583
|
// Restablecer el snap después de un pequeño retraso
|
|
832
1584
|
if ((this as any)._wheelTimeout) clearTimeout((this as any)._wheelTimeout);
|
|
833
1585
|
(this as any)._wheelTimeout = setTimeout(() => {
|
|
834
|
-
|
|
1586
|
+
// Handle uncontained snapping explicitly
|
|
1587
|
+
if (this.layout !== 'uncontained' && !this._isDragging) {
|
|
1588
|
+
// Fallback to CSS snap if not uncontained and not dragging
|
|
835
1589
|
this._scrollContainer.style.scrollSnapType = 'x mandatory';
|
|
836
1590
|
}
|
|
837
1591
|
}, 150);
|
|
838
1592
|
}
|
|
839
1593
|
}
|
|
840
1594
|
|
|
841
|
-
|
|
842
|
-
|
|
1595
|
+
/**
|
|
1596
|
+
* Maneja los eventos de clic en las tarjetas individuales del carrusel.
|
|
1597
|
+
*
|
|
1598
|
+
* Distingue entre toques/clics genuinos y eventos accidentales de levantar el puntero
|
|
1599
|
+
* que ocurrieron después de un gesto de arrastre. Si la distancia total arrastrada excede
|
|
1600
|
+
* los 8px (el umbral mínimo de desplazamiento intencional de M3), el clic se suprime
|
|
1601
|
+
* para evitar una navegación involuntaria o el disparo de acciones.
|
|
1602
|
+
*
|
|
1603
|
+
* Despacha `'item-click'` con `{ item, index }` para que los consumidores puedan
|
|
1604
|
+
* responder a la selección de la tarjeta (ej. navegar a una página de detalles).
|
|
1605
|
+
*
|
|
1606
|
+
* @param e - El evento de clic del elemento de la tarjeta.
|
|
1607
|
+
* @param item - Los datos `CarouselItem` asociados con la tarjeta clicada.
|
|
1608
|
+
* @param cardDomIndex - El índice de la tarjeta en el DOM renderizado (puede diferir
|
|
1609
|
+
* del índice lógico en modo infinito debido a la clonación de elementos).
|
|
1610
|
+
*/
|
|
1611
|
+
private _handleCardClick(e: Event, item: CarouselItem, cardDomIndex: number) {
|
|
1612
|
+
// Suppress click if the gesture traveled more than 8px — it was a swipe, not a tap.
|
|
1613
|
+
if (this._totalDraggedDistance > 8) {
|
|
843
1614
|
e.preventDefault();
|
|
844
1615
|
e.stopPropagation();
|
|
845
1616
|
return;
|
|
846
1617
|
}
|
|
847
1618
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1619
|
+
// In infinite mode, the DOM index may be a multiple of the original list length.
|
|
1620
|
+
// Modulo maps it back to the logical item index.
|
|
1621
|
+
const logicalItemIndex = cardDomIndex % this.effectiveItems.length;
|
|
1622
|
+
this.dispatchEvent(new CustomEvent('item-click', {
|
|
1623
|
+
bubbles: true,
|
|
1624
|
+
composed: true,
|
|
1625
|
+
detail: { item, index: logicalItemIndex }
|
|
1626
|
+
}));
|
|
1627
|
+
emitMoniEvent<CarouselItem>(this, 'moni-item-click', {
|
|
1628
|
+
detail: { item, index: logicalItemIndex }
|
|
1629
|
+
});
|
|
855
1630
|
}
|
|
856
1631
|
|
|
1632
|
+
/**
|
|
1633
|
+
* Acción Imperativa: Scroll hacia el elemento anterior.
|
|
1634
|
+
* Desplaza el contenedor una distancia equivalente al tamaño exacto de un ítem `L` (Large) + gap.
|
|
1635
|
+
* Restaura el comportamiento `smooth` de CSS nativo.
|
|
1636
|
+
*/
|
|
857
1637
|
private _scrollPrevious() {
|
|
858
|
-
const target =
|
|
1638
|
+
const target = this._scrollContainer.scrollLeft - this.itemSize;
|
|
859
1639
|
this._scrollContainer.style.scrollBehavior = 'smooth';
|
|
860
1640
|
this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
|
|
861
1641
|
}
|
|
862
1642
|
|
|
1643
|
+
/**
|
|
1644
|
+
* Acción Imperativa: Scroll hacia el elemento siguiente.
|
|
1645
|
+
* Desplaza el contenedor una distancia equivalente al tamaño exacto de un ítem `L` (Large) + gap.
|
|
1646
|
+
*/
|
|
863
1647
|
private _scrollNext() {
|
|
864
|
-
const
|
|
865
|
-
const target = Math.min(maxScroll, this._scrollContainer.scrollLeft + this.itemSize);
|
|
1648
|
+
const target = this._scrollContainer.scrollLeft + this.itemSize;
|
|
866
1649
|
this._scrollContainer.style.scrollBehavior = 'smooth';
|
|
867
1650
|
this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
|
|
868
1651
|
}
|
|
869
1652
|
|
|
1653
|
+
/**
|
|
1654
|
+
* Manejador del botón o enlace "Mostrar Todo" (Show All).
|
|
1655
|
+
* Intercepta clics en enlaces nulos (`#`) y despacha el evento CustomEvent `show-all-click`.
|
|
1656
|
+
*/
|
|
870
1657
|
private _handleShowAllClick(e: Event) {
|
|
871
1658
|
const target = e.target as HTMLElement;
|
|
872
1659
|
if (target.tagName.toLowerCase() === 'a' && target.getAttribute('href') === '#') {
|
|
873
1660
|
e.preventDefault();
|
|
874
1661
|
}
|
|
875
|
-
this.dispatchEvent(
|
|
876
|
-
|
|
877
|
-
bubbles: true,
|
|
878
|
-
composed: true
|
|
879
|
-
})
|
|
880
|
-
);
|
|
1662
|
+
this.dispatchEvent(new CustomEvent('show-all-click', { bubbles: true, composed: true }));
|
|
1663
|
+
emitMoniEvent(this, 'moni-show-all-click');
|
|
881
1664
|
}
|
|
882
1665
|
|
|
1666
|
+
/**
|
|
1667
|
+
* Hook invocado por Lit cuando el contenido del slot `"show-all"` muta en el Light DOM.
|
|
1668
|
+
* Detecta si hay contenido proyectado para ocultar/mostrar la interfaz correspondiente,
|
|
1669
|
+
* proveyendo un fallback manual para entornos DOM limitados (como JSDOM en test unitarios).
|
|
1670
|
+
*/
|
|
883
1671
|
private _handleShowAllSlotChange(e: Event) {
|
|
884
1672
|
const slot = e.target as HTMLSlotElement;
|
|
885
1673
|
let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : [];
|
|
@@ -892,6 +1680,15 @@ export class MoniCarousel extends MoniElement {
|
|
|
892
1680
|
this._hasSlottedShowAll = nodes.length > 0;
|
|
893
1681
|
}
|
|
894
1682
|
|
|
1683
|
+
/**
|
|
1684
|
+
* Hook invocado por Lit cuando los hijos directos proyectados en el `<slot>` por defecto cambian.
|
|
1685
|
+
*
|
|
1686
|
+
* @logic
|
|
1687
|
+
* Parsea el Light DOM proporcionado por el usuario (ej: lista de `<img>` o `<a><img></a>`).
|
|
1688
|
+
* Extrae los atributos `src`, `title`, y `href` de esos nodos nativos y los convierte en
|
|
1689
|
+
* un array estructurado interno de objetos `CarouselItem`, que el componente usará
|
|
1690
|
+
* subsecuentemente para renderizar su propio DOM Shadow infinito.
|
|
1691
|
+
*/
|
|
895
1692
|
private _handleSlotChange(e: Event) {
|
|
896
1693
|
const slot = e.target as HTMLSlotElement;
|
|
897
1694
|
let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : [];
|
|
@@ -933,45 +1730,106 @@ export class MoniCarousel extends MoniElement {
|
|
|
933
1730
|
|
|
934
1731
|
void this.updateComplete.then(() => {
|
|
935
1732
|
this.updateLayout(true);
|
|
1733
|
+
emitMoniEvent(this, 'moni-slot-items-changed', { detail: { items: this._slottedItems } });
|
|
936
1734
|
});
|
|
937
1735
|
}
|
|
938
1736
|
|
|
939
|
-
|
|
940
|
-
|
|
1737
|
+
/**
|
|
1738
|
+
* Ensambla la estructura del Shadow DOM del carrusel para cada ciclo de renderizado de Lit.
|
|
1739
|
+
*
|
|
1740
|
+
* **Extracción de `computedLayout`:**
|
|
1741
|
+
* `largeItemWidth`, `mediumItemWidth` y `smallItemWidth` se desestructuran
|
|
1742
|
+
* del getter `computedLayout` (que devuelve tamaños según la especificación M3 basados en
|
|
1743
|
+
* el ancho del contenedor a través de `_containerWidth`). Se inyectan como propiedades personalizadas
|
|
1744
|
+
* CSS en la pista para que el CSS de tamaño de ranura pueda referenciarlos.
|
|
1745
|
+
*
|
|
1746
|
+
* **Puerta `showHeader`:**
|
|
1747
|
+
* El elemento `.header` solo se renderiza cuando hay algo que mostrar:
|
|
1748
|
+
* un `headerText`, un atributo `showAll`, o un elemento `[slot="show-all"]`.
|
|
1749
|
+
* Esto evita un espaciador de encabezado vacío cuando el carrusel no tiene fila de título.
|
|
1750
|
+
*
|
|
1751
|
+
* **Cálculo de `rightPadding`:**
|
|
1752
|
+
* Asegura que la última tarjeta pueda desplazarse a ras de la posición focal del elemento grande:
|
|
1753
|
+
* - Diseño `hero`: sin relleno adicional (el elemento derecho que asoma proporciona espacio natural).
|
|
1754
|
+
* - Diseños de ancho completo / lista: también relleno mínimo.
|
|
1755
|
+
* - Todos los demás diseños: `128px` (≈ ancho de la tarjeta pequeña) para que la última tarjeta pueda
|
|
1756
|
+
* alcanzar la ranura de enfoque principal sin que el usuario se desplace demasiado.
|
|
1757
|
+
*
|
|
1758
|
+
* **Ciclo de vida del ticker de GSAP:**
|
|
1759
|
+
* El ticker de GSAP (`gsap.ticker.add(_tick)`) se inicia en `firstUpdated()`
|
|
1760
|
+
* y se detiene en `disconnectedCallback()`. Este método de renderizado NO
|
|
1761
|
+
* interactúa con el ticker; el ticker impulsa las actualizaciones de posición por cuadro
|
|
1762
|
+
* independientemente del ciclo de actualización reactiva de Lit.
|
|
1763
|
+
*/
|
|
1764
|
+
override render() {
|
|
1765
|
+
const { largeItemWidth, mediumItemWidth, smallItemWidth } = this.computedLayout;
|
|
1766
|
+
|
|
1767
|
+
// Determine whether there are items to render (from either source).
|
|
941
1768
|
const hasItems = this.effectiveItems && this.effectiveItems.length > 0;
|
|
1769
|
+
|
|
1770
|
+
// Check for a slotted "Show All" button or the declarative `show-all` attribute.
|
|
942
1771
|
const hasSlottedShowAll = this._hasSlottedShowAll || !!this.querySelector('[slot="show-all"]');
|
|
1772
|
+
// Only render the header section if there is something to display in it.
|
|
943
1773
|
const showHeader = this.headerText || this.showAll || hasSlottedShowAll;
|
|
944
1774
|
|
|
1775
|
+
// `rightPadding` determines the trailing space in the scroll track so the
|
|
1776
|
+
// last item can scroll all the way to the primary (leftmost large) focus slot.
|
|
945
1777
|
let rightPadding = 128;
|
|
946
1778
|
if (this.layout === 'hero') {
|
|
947
|
-
|
|
1779
|
+
// Hero: the peeking item on the right already provides natural trailing space.
|
|
1780
|
+
rightPadding = smallItemWidth + this.gap;
|
|
948
1781
|
} else if (this.layout === 'uncontained') {
|
|
1782
|
+
// Uncontained: minimal trailing padding; items bleed off the edge.
|
|
949
1783
|
rightPadding = 16;
|
|
950
1784
|
} else {
|
|
951
|
-
|
|
1785
|
+
// Multi-browse: both the medium and small slots provide the trailing buffer.
|
|
1786
|
+
rightPadding = mediumItemWidth + smallItemWidth + 2 * this.gap;
|
|
952
1787
|
}
|
|
953
1788
|
|
|
954
1789
|
// Ensure the scroll track is long enough to allow the last item to scroll to the start (p = 0 focus).
|
|
955
1790
|
// Safety margin of 400px avoids fractional rounding/zoom-scale snapping blockages in Firefox.
|
|
956
|
-
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
1791
|
+
// Measure the container width for the scroll track overflow calculation.
|
|
1792
|
+
const containerWidth = this._containerWidth || this.getBoundingClientRect().width || 360;
|
|
1793
|
+
|
|
1794
|
+
// Minimum trailing padding needed so the last item can scroll to position 0 (primary focus).
|
|
1795
|
+
// The +400px safety margin prevents fractional zoom-scale snapping blockages in Firefox.
|
|
1796
|
+
const minRequiredRightPadding = containerWidth - largeItemWidth - this.padding + 400;
|
|
1797
|
+
if (rightPadding < minRequiredRightPadding) {
|
|
1798
|
+
rightPadding = minRequiredRightPadding;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// Total number of snap items in the track (includes cloned sets for infinite mode).
|
|
1802
|
+
let snapItemsCount = hasItems ? this._snapItemsCount : 0;
|
|
1803
|
+
|
|
1804
|
+
// `visualItems` is the array of items actually rendered as card elements in the Shadow DOM.
|
|
1805
|
+
// For infinite mode, this is a repeated version of `effectiveItems` scaled by `_visualCardsMultiplier`.
|
|
1806
|
+
let visualItems = this.effectiveItems;
|
|
1807
|
+
|
|
1808
|
+
if (this.infinite && hasItems) {
|
|
1809
|
+
const visualMultiplier = this._visualCardsMultiplier;
|
|
1810
|
+
|
|
1811
|
+
visualItems = [];
|
|
1812
|
+
// Concatenate the original item list `visualMultiplier` times to create
|
|
1813
|
+
// enough cards to fill the visual viewport without visible gaps.
|
|
1814
|
+
for (let repeatIndex = 0; repeatIndex < visualMultiplier; repeatIndex++) {
|
|
1815
|
+
visualItems = visualItems.concat(this.effectiveItems);
|
|
1816
|
+
}
|
|
960
1817
|
}
|
|
961
1818
|
|
|
1819
|
+
// Total pixel width of the snap track (sum of all item widths + all gaps between them).
|
|
962
1820
|
const trackWidth = hasItems
|
|
963
|
-
?
|
|
1821
|
+
? snapItemsCount * largeItemWidth + (snapItemsCount - 1) * this.gap
|
|
964
1822
|
: 0;
|
|
965
1823
|
|
|
966
1824
|
const isScrollable = hasItems && this._scrollContainer &&
|
|
967
1825
|
(this._scrollContainer.scrollWidth > this._scrollContainer.clientWidth);
|
|
968
|
-
const showPrevArrow = isScrollable && this._scrollContainer.scrollLeft > 5;
|
|
969
|
-
const showNextArrow = isScrollable &&
|
|
970
|
-
(this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5);
|
|
1826
|
+
const showPrevArrow = this.infinite || (isScrollable && this._scrollContainer.scrollLeft > 5);
|
|
1827
|
+
const showNextArrow = this.infinite || (isScrollable &&
|
|
1828
|
+
(this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5));
|
|
971
1829
|
|
|
972
1830
|
return html`
|
|
973
1831
|
<div class="carousel-container" style="
|
|
974
|
-
--carousel-snap-width: ${
|
|
1832
|
+
--carousel-snap-width: ${largeItemWidth}px;
|
|
975
1833
|
--carousel-gap: ${this.gap}px;
|
|
976
1834
|
--carousel-padding: ${this.padding}px;
|
|
977
1835
|
--carousel-right-padding: ${rightPadding}px;
|
|
@@ -1015,35 +1873,37 @@ export class MoniCarousel extends MoniElement {
|
|
|
1015
1873
|
@mousedown=${this._handleMouseDown}
|
|
1016
1874
|
@mousemove=${this._handleMouseMove}
|
|
1017
1875
|
@mouseup=${this._handleMouseUp}
|
|
1018
|
-
@
|
|
1876
|
+
@mouseenter=${this._handleMouseEnter}
|
|
1877
|
+
@mouseleave=${this._handleMouseLeave}
|
|
1019
1878
|
@touchstart=${this._handleTouchStart}
|
|
1020
1879
|
@touchmove=${this._handleTouchMove}
|
|
1021
1880
|
@touchend=${this._handleTouchEnd}
|
|
1022
1881
|
@wheel=${this._handleWheel}
|
|
1023
1882
|
>
|
|
1024
1883
|
<div class="snap-track">
|
|
1025
|
-
${hasItems ?
|
|
1884
|
+
${hasItems ? Array.from({ length: snapItemsCount }).map(() => html`<div class="snap-item"></div>`) : ''}
|
|
1026
1885
|
</div>
|
|
1027
1886
|
<div class="visual-track">
|
|
1028
1887
|
${hasItems
|
|
1029
|
-
?
|
|
1888
|
+
? visualItems.map(
|
|
1030
1889
|
(item, idx) => html`
|
|
1031
1890
|
<div
|
|
1032
1891
|
class="card"
|
|
1892
|
+
data-index="${idx}"
|
|
1033
1893
|
@click=${(e: Event) => this._handleCardClick(e, item, idx)}
|
|
1034
1894
|
>
|
|
1035
1895
|
${item.href
|
|
1036
1896
|
? html`
|
|
1037
|
-
<a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;">
|
|
1897
|
+
<a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;" draggable="false">
|
|
1038
1898
|
<div class="img-parallax-container">
|
|
1039
|
-
<img src="${item.img}" alt="${item.title}" draggable="false" />
|
|
1899
|
+
<img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
|
|
1040
1900
|
</div>
|
|
1041
1901
|
<h2 class="card-title">${item.title}</h2>
|
|
1042
1902
|
</a>
|
|
1043
1903
|
`
|
|
1044
1904
|
: html`
|
|
1045
1905
|
<div class="img-parallax-container">
|
|
1046
|
-
<img src="${item.img}" alt="${item.title}" draggable="false" />
|
|
1906
|
+
<img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
|
|
1047
1907
|
</div>
|
|
1048
1908
|
<h2 class="card-title">${item.title}</h2>
|
|
1049
1909
|
`}
|