@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.
Files changed (251) hide show
  1. package/custom-elements.json +5230 -1007
  2. package/dist/components/_base/moni-element.d.ts +16 -9
  3. package/dist/components/_base/moni-element.d.ts.map +1 -1
  4. package/dist/components/_base/moni-element.js +63 -9
  5. package/dist/components/index.js +2 -2
  6. package/dist/components/loading-shapes.d.ts +19 -0
  7. package/dist/components/loading-shapes.d.ts.map +1 -1
  8. package/dist/components/loading-shapes.js +19 -0
  9. package/dist/components/moni-app-bar.d.ts +72 -62
  10. package/dist/components/moni-app-bar.d.ts.map +1 -1
  11. package/dist/components/moni-app-bar.js +72 -62
  12. package/dist/components/moni-badge.d.ts +64 -65
  13. package/dist/components/moni-badge.d.ts.map +1 -1
  14. package/dist/components/moni-badge.js +64 -66
  15. package/dist/components/moni-bottom-sheet.d.ts +129 -61
  16. package/dist/components/moni-bottom-sheet.d.ts.map +1 -1
  17. package/dist/components/moni-bottom-sheet.js +217 -131
  18. package/dist/components/moni-button-group.d.ts +107 -29
  19. package/dist/components/moni-button-group.d.ts.map +1 -1
  20. package/dist/components/moni-button-group.js +107 -29
  21. package/dist/components/moni-button-segment.d.ts +53 -12
  22. package/dist/components/moni-button-segment.d.ts.map +1 -1
  23. package/dist/components/moni-button-segment.js +53 -12
  24. package/dist/components/moni-button.d.ts +116 -38
  25. package/dist/components/moni-button.d.ts.map +1 -1
  26. package/dist/components/moni-button.js +128 -38
  27. package/dist/components/moni-card.d.ts +68 -50
  28. package/dist/components/moni-card.d.ts.map +1 -1
  29. package/dist/components/moni-card.js +68 -50
  30. package/dist/components/moni-carousel.d.ts +484 -53
  31. package/dist/components/moni-carousel.d.ts.map +1 -1
  32. package/dist/components/moni-carousel.js +1024 -245
  33. package/dist/components/moni-checkbox.d.ts +49 -46
  34. package/dist/components/moni-checkbox.d.ts.map +1 -1
  35. package/dist/components/moni-checkbox.js +55 -48
  36. package/dist/components/moni-chip.d.ts +100 -33
  37. package/dist/components/moni-chip.d.ts.map +1 -1
  38. package/dist/components/moni-chip.js +104 -34
  39. package/dist/components/moni-color-field.d.ts +99 -24
  40. package/dist/components/moni-color-field.d.ts.map +1 -1
  41. package/dist/components/moni-color-field.js +99 -24
  42. package/dist/components/moni-context-menu.d.ts +72 -26
  43. package/dist/components/moni-context-menu.d.ts.map +1 -1
  44. package/dist/components/moni-context-menu.js +76 -30
  45. package/dist/components/moni-dialog.d.ts +84 -59
  46. package/dist/components/moni-dialog.d.ts.map +1 -1
  47. package/dist/components/moni-dialog.js +105 -58
  48. package/dist/components/moni-divider.d.ts +39 -27
  49. package/dist/components/moni-divider.d.ts.map +1 -1
  50. package/dist/components/moni-divider.js +42 -30
  51. package/dist/components/moni-expansion.d.ts +32 -22
  52. package/dist/components/moni-expansion.d.ts.map +1 -1
  53. package/dist/components/moni-expansion.js +32 -22
  54. package/dist/components/moni-fab-menu.d.ts +61 -23
  55. package/dist/components/moni-fab-menu.d.ts.map +1 -1
  56. package/dist/components/moni-fab-menu.js +69 -31
  57. package/dist/components/moni-fab.d.ts +86 -28
  58. package/dist/components/moni-fab.d.ts.map +1 -1
  59. package/dist/components/moni-fab.js +92 -34
  60. package/dist/components/moni-file-field.d.ts +118 -30
  61. package/dist/components/moni-file-field.d.ts.map +1 -1
  62. package/dist/components/moni-file-field.js +118 -30
  63. package/dist/components/moni-icon.d.ts +40 -35
  64. package/dist/components/moni-icon.d.ts.map +1 -1
  65. package/dist/components/moni-icon.js +43 -38
  66. package/dist/components/moni-list-item.d.ts +46 -33
  67. package/dist/components/moni-list-item.d.ts.map +1 -1
  68. package/dist/components/moni-list-item.js +61 -41
  69. package/dist/components/moni-list.d.ts +30 -20
  70. package/dist/components/moni-list.d.ts.map +1 -1
  71. package/dist/components/moni-list.js +36 -21
  72. package/dist/components/moni-loading-indicator.d.ts +54 -22
  73. package/dist/components/moni-loading-indicator.d.ts.map +1 -1
  74. package/dist/components/moni-loading-indicator.js +54 -22
  75. package/dist/components/moni-menu-item.d.ts +23 -16
  76. package/dist/components/moni-menu-item.d.ts.map +1 -1
  77. package/dist/components/moni-menu-item.js +23 -16
  78. package/dist/components/moni-menu.d.ts +81 -28
  79. package/dist/components/moni-menu.d.ts.map +1 -1
  80. package/dist/components/moni-menu.js +81 -30
  81. package/dist/components/moni-morph-modal.d.ts +141 -0
  82. package/dist/components/moni-morph-modal.d.ts.map +1 -1
  83. package/dist/components/moni-morph-modal.js +295 -58
  84. package/dist/components/moni-nav-item.d.ts +59 -29
  85. package/dist/components/moni-nav-item.d.ts.map +1 -1
  86. package/dist/components/moni-nav-item.js +59 -29
  87. package/dist/components/moni-nav.d.ts +94 -35
  88. package/dist/components/moni-nav.d.ts.map +1 -1
  89. package/dist/components/moni-nav.js +94 -35
  90. package/dist/components/moni-progress.d.ts +99 -55
  91. package/dist/components/moni-progress.d.ts.map +1 -1
  92. package/dist/components/moni-progress.js +99 -55
  93. package/dist/components/moni-radio.d.ts +62 -50
  94. package/dist/components/moni-radio.d.ts.map +1 -1
  95. package/dist/components/moni-radio.js +65 -52
  96. package/dist/components/moni-ripple.d.ts +74 -59
  97. package/dist/components/moni-ripple.d.ts.map +1 -1
  98. package/dist/components/moni-ripple.js +74 -59
  99. package/dist/components/moni-segmented-button.d.ts +91 -16
  100. package/dist/components/moni-segmented-button.d.ts.map +1 -1
  101. package/dist/components/moni-segmented-button.js +94 -17
  102. package/dist/components/moni-select-option.d.ts +30 -23
  103. package/dist/components/moni-select-option.d.ts.map +1 -1
  104. package/dist/components/moni-select-option.js +30 -23
  105. package/dist/components/moni-select.d.ts +300 -35
  106. package/dist/components/moni-select.d.ts.map +1 -1
  107. package/dist/components/moni-select.js +323 -39
  108. package/dist/components/moni-side-sheet.d.ts +94 -32
  109. package/dist/components/moni-side-sheet.d.ts.map +1 -1
  110. package/dist/components/moni-side-sheet.js +94 -32
  111. package/dist/components/moni-slider.d.ts +152 -41
  112. package/dist/components/moni-slider.d.ts.map +1 -1
  113. package/dist/components/moni-slider.js +154 -42
  114. package/dist/components/moni-snackbar.d.ts +53 -43
  115. package/dist/components/moni-snackbar.d.ts.map +1 -1
  116. package/dist/components/moni-snackbar.js +53 -43
  117. package/dist/components/moni-split-button.d.ts +63 -18
  118. package/dist/components/moni-split-button.d.ts.map +1 -1
  119. package/dist/components/moni-split-button.js +63 -18
  120. package/dist/components/moni-step.d.ts +36 -24
  121. package/dist/components/moni-step.d.ts.map +1 -1
  122. package/dist/components/moni-step.js +41 -29
  123. package/dist/components/moni-stepper.d.ts +64 -27
  124. package/dist/components/moni-stepper.d.ts.map +1 -1
  125. package/dist/components/moni-stepper.js +72 -34
  126. package/dist/components/moni-switch.d.ts +57 -46
  127. package/dist/components/moni-switch.d.ts.map +1 -1
  128. package/dist/components/moni-switch.js +59 -47
  129. package/dist/components/moni-tab.d.ts +26 -18
  130. package/dist/components/moni-tab.d.ts.map +1 -1
  131. package/dist/components/moni-tab.js +29 -21
  132. package/dist/components/moni-tabs.d.ts +34 -28
  133. package/dist/components/moni-tabs.d.ts.map +1 -1
  134. package/dist/components/moni-tabs.js +34 -28
  135. package/dist/components/moni-text-field.d.ts +139 -29
  136. package/dist/components/moni-text-field.d.ts.map +1 -1
  137. package/dist/components/moni-text-field.js +157 -31
  138. package/dist/components/moni-textarea.d.ts +127 -29
  139. package/dist/components/moni-textarea.d.ts.map +1 -1
  140. package/dist/components/moni-textarea.js +131 -32
  141. package/dist/components/moni-time-picker.d.ts +114 -24
  142. package/dist/components/moni-time-picker.d.ts.map +1 -1
  143. package/dist/components/moni-time-picker.js +127 -33
  144. package/dist/components/moni-toolbar.d.ts +37 -23
  145. package/dist/components/moni-toolbar.d.ts.map +1 -1
  146. package/dist/components/moni-toolbar.js +37 -23
  147. package/dist/components/moni-tooltip.d.ts +100 -36
  148. package/dist/components/moni-tooltip.d.ts.map +1 -1
  149. package/dist/components/moni-tooltip.js +104 -40
  150. package/dist/components/moni-typography.d.ts +68 -30
  151. package/dist/components/moni-typography.d.ts.map +1 -1
  152. package/dist/components/moni-typography.js +70 -32
  153. package/dist/styles/tokens.css +7 -7
  154. package/dist/types/events.d.ts +17 -0
  155. package/dist/types/events.d.ts.map +1 -0
  156. package/dist/types/events.js +1 -0
  157. package/dist/utils/event-emitter.d.ts +13 -0
  158. package/dist/utils/event-emitter.d.ts.map +1 -0
  159. package/dist/utils/event-emitter.js +13 -0
  160. package/package.json +2 -1
  161. package/src/components/_base/moni-element.ts +73 -9
  162. package/src/components/index.ts +2 -2
  163. package/src/components/loading-shapes.ts +19 -0
  164. package/src/components/moni-app-bar.test.ts +10 -10
  165. package/src/components/moni-app-bar.ts +72 -62
  166. package/src/components/moni-badge.ts +64 -66
  167. package/src/components/moni-bottom-sheet.test.ts +39 -45
  168. package/src/components/moni-bottom-sheet.ts +222 -131
  169. package/src/components/moni-button-group.test.ts +14 -14
  170. package/src/components/moni-button-group.ts +107 -29
  171. package/src/components/moni-button-segment.ts +53 -12
  172. package/src/components/moni-button.test.ts +28 -26
  173. package/src/components/moni-button.ts +139 -39
  174. package/src/components/moni-card.test.ts +6 -6
  175. package/src/components/moni-card.ts +70 -50
  176. package/src/components/moni-carousel.test.ts +12 -12
  177. package/src/components/moni-carousel.ts +1129 -269
  178. package/src/components/moni-checkbox.test.ts +7 -7
  179. package/src/components/moni-checkbox.ts +62 -48
  180. package/src/components/moni-chip.test.ts +17 -17
  181. package/src/components/moni-chip.ts +112 -34
  182. package/src/components/moni-color-field.test.ts +5 -5
  183. package/src/components/moni-color-field.ts +108 -24
  184. package/src/components/moni-context-menu.test.ts +11 -11
  185. package/src/components/moni-context-menu.ts +76 -30
  186. package/src/components/moni-dialog.ts +108 -59
  187. package/src/components/moni-divider.test.ts +4 -4
  188. package/src/components/moni-divider.ts +42 -30
  189. package/src/components/moni-expansion.ts +32 -22
  190. package/src/components/moni-fab-menu.test.ts +11 -11
  191. package/src/components/moni-fab-menu.ts +69 -31
  192. package/src/components/moni-fab.test.ts +16 -16
  193. package/src/components/moni-fab.ts +100 -34
  194. package/src/components/moni-file-field.test.ts +8 -8
  195. package/src/components/moni-file-field.ts +132 -30
  196. package/src/components/moni-icon.test.ts +11 -11
  197. package/src/components/moni-icon.ts +43 -38
  198. package/src/components/moni-list-item.test.ts +10 -10
  199. package/src/components/moni-list-item.ts +61 -41
  200. package/src/components/moni-list.ts +37 -21
  201. package/src/components/moni-loading-indicator.test.ts +3 -3
  202. package/src/components/moni-loading-indicator.ts +54 -22
  203. package/src/components/moni-menu-item.ts +23 -16
  204. package/src/components/moni-menu.test.ts +7 -7
  205. package/src/components/moni-menu.ts +87 -30
  206. package/src/components/moni-morph-modal.test.ts +18 -17
  207. package/src/components/moni-morph-modal.ts +327 -60
  208. package/src/components/moni-nav-item.ts +59 -29
  209. package/src/components/moni-nav.test.ts +11 -11
  210. package/src/components/moni-nav.ts +94 -35
  211. package/src/components/moni-progress.test.ts +9 -9
  212. package/src/components/moni-progress.ts +100 -56
  213. package/src/components/moni-radio.test.ts +8 -8
  214. package/src/components/moni-radio.ts +71 -51
  215. package/src/components/moni-ripple.ts +74 -59
  216. package/src/components/moni-segmented-button.test.ts +4 -4
  217. package/src/components/moni-segmented-button.ts +94 -17
  218. package/src/components/moni-select-option.ts +30 -23
  219. package/src/components/moni-select.test.ts +11 -11
  220. package/src/components/moni-select.ts +361 -50
  221. package/src/components/moni-side-sheet.test.ts +7 -7
  222. package/src/components/moni-side-sheet.ts +94 -32
  223. package/src/components/moni-slider.test.ts +7 -7
  224. package/src/components/moni-slider.ts +165 -42
  225. package/src/components/moni-snackbar.test.ts +8 -8
  226. package/src/components/moni-snackbar.ts +53 -43
  227. package/src/components/moni-split-button.ts +63 -18
  228. package/src/components/moni-step.test.ts +7 -7
  229. package/src/components/moni-step.ts +41 -29
  230. package/src/components/moni-stepper.test.ts +7 -7
  231. package/src/components/moni-stepper.ts +72 -34
  232. package/src/components/moni-switch.test.ts +12 -12
  233. package/src/components/moni-switch.ts +66 -47
  234. package/src/components/moni-tab.test.ts +5 -5
  235. package/src/components/moni-tab.ts +29 -21
  236. package/src/components/moni-tabs.ts +34 -28
  237. package/src/components/moni-text-field.test.ts +13 -13
  238. package/src/components/moni-text-field.ts +181 -30
  239. package/src/components/moni-textarea.test.ts +17 -17
  240. package/src/components/moni-textarea.ts +155 -31
  241. package/src/components/moni-time-picker.test.ts +5 -5
  242. package/src/components/moni-time-picker.ts +128 -33
  243. package/src/components/moni-toolbar.test.ts +8 -8
  244. package/src/components/moni-toolbar.ts +37 -23
  245. package/src/components/moni-tooltip.test.ts +15 -15
  246. package/src/components/moni-tooltip.ts +107 -40
  247. package/src/components/moni-typography.test.ts +10 -10
  248. package/src/components/moni-typography.ts +73 -32
  249. package/src/styles/tokens.css +7 -7
  250. package/src/types/events.ts +17 -0
  251. 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
- * Data model for a single item in a `<moni-carousel>`.
15
+ * Modelo de datos para un solo elemento en un `<moni-carousel>`.
15
16
  */
16
17
  export interface CarouselItem {
17
- /** Display title overlaid on the item. */
18
+ /** Título a mostrar sobre el elemento. */
18
19
  title: string;
19
- /** URL or path to the background image. */
20
+ /** URL o ruta a la imagen de fondo. */
20
21
  img: string;
21
- /** Optional link target. If provided, the item renders as an `<a>` element. */
22
+ /** Objetivo de enlace opcional. Si se proporciona, el elemento se renderiza como un `<a>`. */
22
23
  href?: string;
23
- /** Link target attribute (e.g. `'_blank'`). Only applies if `href` is set. */
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 component.
29
+ * Componente Material Design 3 Expressive Carousel.
29
30
  *
30
- * Carousels display a collection of related items in a scrollable, horizontal list.
31
- * They allow users to quickly browse through items like images, cards, or products.
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
- * **M3 spec reference:** `m3-docs/components/carousel/specs.md`
34
+ * **Referencia de la especificación M3:** `m3-docs/components/carousel/specs.md`
34
35
  *
35
- * **Layout variants:**
36
- * - `multi-browse` (default) — Shows a mix of large, medium, and small (peeking)
37
- * items. Best for exploring a large number of items.
38
- * - `hero` — Focuses on one large primary item while showing a sliver of the
39
- * next item. Best for featuring important content.
40
- * - `uncontained` — Standard layout where all items have the same width and
41
- * bleed off the edges of the container.
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
- * **Animation & Gestures:**
44
- * This component uses GSAP for smooth drag, flick, and snap animations,
45
- * mirroring the high-fidelity M3 Expressive motion specs. It handles touch
46
- * gestures for mobile and mouse-drag for desktop.
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-sizing (`auto` mode):**
49
- * When `auto=true` (default), the carousel measures its own width and
50
- * dynamically calculates the optimal sizes for large, medium, and small items
51
- * based on the active `layout` to ensure they fit perfectly without awkward gaps
52
- * or clipping at the edges.
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
- * <!-- Declarative usage via DOM properties (recommended) -->
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: 'Item 1', img: '/img1.jpg', href: '/link1' },
62
- * { title: 'Item 2', img: '/img2.jpg' }
62
+ * { title: 'Elemento 1', img: '/img1.jpg', href: '/link1' },
63
+ * { title: 'Elemento 2', img: '/img2.jpg' }
63
64
  * ];
64
65
  * </script>
65
66
  *
66
- * <!-- Slot-based usage (for SSR or simple static content) -->
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>Static Item</h3>
71
+ * <h3>Elemento Estático</h3>
71
72
  * </div>
72
73
  * </moni-carousel>
73
74
  * ```
74
75
  *
75
- * @slot item - Alternative to the `items` property. Slot individual HTML elements
76
- * instead of passing data objects.
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 - The outer wrapper.
79
- * @csspart track - The scrolling track element.
80
- * @csspart item - Individual carousel item containers.
81
- * @csspart img - The image elements inside the items.
82
- * @csspart title - The title text elements inside the items.
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
- private _isDown = false;
113
- private _startX = 0;
114
- private _scrollLeftStart = 0;
115
- private _velocity = 0;
116
- private _lastTime = 0;
117
- private _lastX = 0;
118
- private _draggedDistance = 0;
119
- private _rafId: number | null = null;
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
- // Scroll interpolation state
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
- // quickSetter caches avoid gsap.set() overhead per frame
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._rafId !== null) {
390
- cancelAnimationFrame(this._rafId);
391
- this._rafId = null;
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
- get computedLayout() {
442
- const W_c = this._containerWidth || this.getBoundingClientRect().width || 360;
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
- let L = this.largeWidth;
448
- let M = this.mediumWidth;
449
- let S = this.smallWidth;
450
- let N = 1;
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
- // Fits as many L items as possible, letting them bleed off the right edge.
455
- // We target large width around 260px.
456
- L = Math.min(W_a, 260);
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 has N large items and 1 small peeking item.
459
- // W_a = N * L + S + N * gap
460
- S = 48;
461
- N = Math.max(1, Math.floor((W_a - S) / (250 + gap)));
462
- L = (W_a - S - N * gap) / N;
463
-
464
- if (L < 150) {
465
- N = 1;
466
- L = W_a - S - gap;
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
- // multi-browse: N large items, 1 medium, 1 small
470
- // W_a = N * L + M + S + (N + 1) * gap
471
- S = 48;
472
- let bestN = 1;
473
- let bestL = 220;
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 n = 1; n <= 10; n++) {
477
- const l = (W_a - S - (n + 1) * gap) / (n + 0.45);
478
- if (l >= 140 && l <= 340) {
479
- const diff = Math.abs(l - 220);
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
- bestN = n;
483
- bestL = l;
762
+ bestLargeItemCount = candidateCount;
763
+ bestLargeItemWidth = candidateLargeWidth;
484
764
  }
485
765
  }
486
766
  }
487
767
 
488
768
  if (bestDiff === Infinity) {
489
- N = 1;
490
- L = (W_a - S - 2 * gap) / 1.45;
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
- N = bestN;
493
- L = bestL;
773
+ largeItemCount = bestLargeItemCount;
774
+ largeItemWidth = bestLargeItemWidth;
494
775
  }
495
- M = 0.45 * L;
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
- // Non-auto/manual sizing mode
499
- L = this.largeWidth;
500
- M = this.mediumWidth;
501
- S = this.smallWidth;
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
- N = 1;
787
+ largeItemCount = 1;
505
788
  } else if (this.layout === 'uncontained') {
506
- N = Math.max(1, Math.floor(W_a / (L + gap)));
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
- N = 1;
792
+ largeItemCount = 1;
509
793
  }
510
794
  }
511
795
 
512
- return { L, M, S, N };
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
- get itemSize() {
516
- return this.computedLayout.L + this.gap;
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
- private _getCardLayout(p: number) {
520
- const { L, M, S, N } = this.computedLayout;
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
- let width = L;
525
- let x = padding;
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 = L;
530
- x = padding + p * (L + gap);
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
- const X_N = padding + N * (L + gap);
534
- const X_M1 = padding - gap - S;
535
-
536
- if (p <= -1) {
537
- width = S;
538
- x = X_M1 + (p - -1) * (S + gap);
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
- } else if (p <= 0) {
541
- width = gsap.utils.interpolate(S, L, p + 1);
542
- x = gsap.utils.interpolate(X_M1, padding, p + 1);
543
- opacity = gsap.utils.interpolate(0, 1, p + 1);
544
- } else if (p <= N - 1) {
545
- width = L;
546
- x = padding + p * (L + gap);
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
- } else if (p <= N) {
549
- const u = p - (N - 1);
550
- const X_last = padding + (N - 1) * (L + gap);
551
- width = gsap.utils.interpolate(L, S, u);
552
- x = gsap.utils.interpolate(X_last, X_N, u);
553
- opacity = gsap.utils.interpolate(1, 0, u);
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
- width = S;
556
- x = X_N + (p - N) * (S + gap);
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
- // multi-browse
561
- const X_N = padding + N * (L + gap);
562
- const X_N1 = X_N + M + gap;
563
- const X_M1 = padding - gap - S;
564
-
565
- if (p <= -1) {
566
- width = S;
567
- x = X_M1 + (p - -1) * (S + gap);
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
- } else if (p <= 0) {
570
- width = gsap.utils.interpolate(S, L, p + 1);
571
- x = gsap.utils.interpolate(X_M1, padding, p + 1);
572
- opacity = gsap.utils.interpolate(0, 1, p + 1);
573
- } else if (p <= N - 1) {
574
- width = L;
575
- x = padding + p * (L + gap);
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
- } else if (p <= N) {
578
- const u = p - (N - 1);
579
- const X_last = padding + (N - 1) * (L + gap);
580
- width = gsap.utils.interpolate(L, M, u);
581
- x = gsap.utils.interpolate(X_last, X_N, u);
582
- opacity = gsap.utils.interpolate(1, 0, u);
583
- } else if (p <= N + 1) {
584
- const u = p - N;
585
- width = gsap.utils.interpolate(M, S, u);
586
- x = gsap.utils.interpolate(X_N, X_N1, u);
587
- opacity = 0;
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
- width = S;
590
- x = X_N1 + (p - (N + 1)) * (S + gap);
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
- * Rebuild quickSetter caches after cards change.
600
- * quickSetter avoids gsap.set() overhead for properties updated every frame.
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
- * RAF-gated version of updateLayout for scroll events.
628
- * Prevents multiple layout recalculations per frame.
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
- const dt = gsap.ticker.deltaRatio();
632
- // Smoother lerp on mobile to avoid abrupt changes ("brusco")
633
- const ease = this._isMobile ? 0.08 : 0.15;
634
- this._tickerCurrent += (this._tickerTarget - this._tickerCurrent) * ease * dt;
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
- // Rebuild quickSetters if card count changed (e.g. after items update)
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
- private _applyLayout(t: number) {
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
- // Use quickSetters ~3x faster than gsap.set() per call
683
- this._cardSetX[i](layout.x);
684
- this._cardSetWidth[i](layout.width);
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
- // Skip parallax on mobile to save per-frame work
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 clampedP = Math.max(-1, Math.min(1, p));
689
- this._imgSetX[i](clampedP * -25);
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[i](layout.opacity);
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
- private _handleMouseDown(e: MouseEvent) {
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
- this._isDown = true;
699
- this._draggedDistance = 0;
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
- this._startX = e.pageX - this._scrollContainer.offsetLeft;
704
- this._scrollLeftStart = this._scrollContainer.scrollLeft;
705
- this._velocity = 0;
706
- this._lastX = e.pageX;
707
- this._lastTime = performance.now();
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
- private _handleMouseMove(e: MouseEvent) {
711
- if (!this._isDown) return;
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 currentX = e.pageX;
715
- const x = currentX - this._scrollContainer.offsetLeft;
716
- this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x) * 1.25;
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 dt = currentTime - this._lastTime;
720
- if (dt > 0) {
721
- this._velocity = (currentX - this._lastX) / dt;
1405
+ const elapsedMs = currentTime - this._lastPointerTime;
1406
+ if (elapsedMs > 0) {
1407
+ this._pointerVelocity = (currentPointerX - this._lastPointerX) / elapsedMs;
722
1408
  }
723
- this._draggedDistance += Math.abs(currentX - this._lastX);
724
- this._lastX = currentX;
725
- this._lastTime = currentTime;
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
- private _handleMouseUp() {
729
- if (!this._isDown) return;
730
- this._isDown = false;
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._lastTime > 100) {
734
- this._velocity = 0;
1431
+ if (currentTime - this._lastPointerTime > 100) {
1432
+ this._pointerVelocity = 0;
735
1433
  }
736
1434
 
737
- // Dynamic momentum: stronger swipes have a much higher multiplier
738
- const momentumMultiplier = 350 + Math.abs(this._velocity) * 300;
739
- const projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier;
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
- let targetIndex = Math.round(projectedScrollLeft / this.itemSize);
745
- targetIndex = Math.max(0, Math.min(this.effectiveItems.length - 1, targetIndex));
746
- const snapPoint = targetIndex * this.itemSize;
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, max 0.85s for faster feeling
749
- const currentIndex = Math.round(this._scrollContainer.scrollLeft / this.itemSize);
750
- const cardsSkipped = Math.abs(targetIndex - currentIndex);
751
- const duration = Math.min(0.85, 0.25 + cardsSkipped * 0.04);
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: snapPoint,
757
- duration: 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
- if (!this._isDown && this._scrollContainer) {
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
- private _handleTouchStart(e: TouchEvent) {
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._isDown = true;
771
- this._draggedDistance = 0;
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
- const touch = e.touches[0];
776
- this._startX = touch.pageX - this._scrollContainer.offsetLeft;
777
- this._scrollLeftStart = this._scrollContainer.scrollLeft;
778
- this._velocity = 0;
779
- this._lastX = touch.pageX;
780
- this._lastTime = performance.now();
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
- private _handleTouchMove(e: TouchEvent) {
784
- if (!this._isDown) return;
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
- const touch = e.touches[0];
787
- const currentX = touch.pageX;
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 dt = currentTime - this._lastTime;
793
- if (dt > 0) {
794
- this._velocity = (currentX - this._lastX) / dt;
1528
+ const elapsedMs = currentTime - this._lastPointerTime;
1529
+ if (elapsedMs > 0) {
1530
+ this._pointerVelocity = (currentTouchX - this._lastPointerX) / elapsedMs;
795
1531
  }
796
- this._draggedDistance += Math.abs(currentX - this._lastX);
797
- this._lastX = currentX;
798
- this._lastTime = currentTime;
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
- if (this.layout !== 'uncontained') {
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
- private _handleCardClick(e: Event, item: CarouselItem, index: number) {
842
- if (this._draggedDistance > 8) {
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
- this.dispatchEvent(
849
- new CustomEvent('item-click', {
850
- detail: { item, index },
851
- bubbles: true,
852
- composed: true
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 = Math.max(0, this._scrollContainer.scrollLeft - this.itemSize);
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 maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth;
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
- new CustomEvent('show-all-click', {
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
- override render() {
940
- const { L, M, S } = this.computedLayout;
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
- rightPadding = S + this.gap;
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
- rightPadding = M + S + 2 * this.gap;
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
- const W_c = this._containerWidth || this.getBoundingClientRect().width || 360;
957
- const minRightPaddingNeeded = W_c - L - this.padding + 400;
958
- if (rightPadding < minRightPaddingNeeded) {
959
- rightPadding = minRightPaddingNeeded;
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
- ? this.effectiveItems.length * L + (this.effectiveItems.length - 1) * this.gap
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: ${L}px;
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
- @mouseleave=${this._handleMouseUp}
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 ? this.effectiveItems.map(() => html`<div class="snap-item"></div>`) : ''}
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
- ? this.effectiveItems.map(
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
  `}