@moni-labs/moni-ui 0.3.0 → 0.3.1

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.
@@ -2,18 +2,6 @@
2
2
  "schemaVersion": "1.0.0",
3
3
  "readme": "",
4
4
  "modules": [
5
- {
6
- "kind": "javascript-module",
7
- "path": "scripts/add-headers.js",
8
- "declarations": [],
9
- "exports": []
10
- },
11
- {
12
- "kind": "javascript-module",
13
- "path": "scripts/build.js",
14
- "declarations": [],
15
- "exports": []
16
- },
17
5
  {
18
6
  "kind": "javascript-module",
19
7
  "path": "dist/index.js",
@@ -412,6 +400,18 @@
412
400
  }
413
401
  ]
414
402
  },
403
+ {
404
+ "kind": "javascript-module",
405
+ "path": "scripts/add-headers.js",
406
+ "declarations": [],
407
+ "exports": []
408
+ },
409
+ {
410
+ "kind": "javascript-module",
411
+ "path": "scripts/build.js",
412
+ "declarations": [],
413
+ "exports": []
414
+ },
415
415
  {
416
416
  "kind": "javascript-module",
417
417
  "path": "src/index.ts",
@@ -872,12 +872,6 @@
872
872
  "declarations": [],
873
873
  "exports": []
874
874
  },
875
- {
876
- "kind": "javascript-module",
877
- "path": "src/actions/index.ts",
878
- "declarations": [],
879
- "exports": []
880
- },
881
875
  {
882
876
  "kind": "javascript-module",
883
877
  "path": "dist/components/index.js",
@@ -1455,7 +1449,7 @@
1455
1449
  {
1456
1450
  "kind": "variable",
1457
1451
  "name": "MoniCarousel",
1458
- "default": "class MoniCarousel extends MoniElement { constructor() { super(...arguments); this.items = []; this.layout = 'multi-browse'; this.auto = true; this.largeWidth = 220; this.mediumWidth = 96; this.smallWidth = 48; this.gap = 8; this.padding = 16; this.borderRadius = 28; this.showAll = false; this.showAllText = 'Show all'; this.headerText = ''; this.hideNav = false; this._containerWidth = 0; this._slottedItems = []; this._hasSlottedShowAll = false; this._resizeObserver = null; this._isDown = false; this._startX = 0; this._scrollLeftStart = 0; this._velocity = 0; this._lastTime = 0; this._lastX = 0; this._draggedDistance = 0; this._rafId = null; this._isMobile = false; // Scroll interpolation state this._tickerTarget = 0; this._tickerCurrent = 0; this._isTicking = false; // quickSetter caches — avoid gsap.set() overhead per frame this._cardSetX = []; this._cardSetWidth = []; this._imgSetX = []; this._titleSetOpacity = []; /** * RAF-gated version of updateLayout for scroll events. * Prevents multiple layout recalculations per frame. */ this._tick = () => { const dt = gsap.ticker.deltaRatio(); // Smoother lerp on mobile to avoid abrupt changes (\"brusco\") const ease = this._isMobile ? 0.08 : 0.15; this._tickerCurrent += (this._tickerTarget - this._tickerCurrent) * ease * dt; if (Math.abs(this._tickerTarget - this._tickerCurrent) < 0.0005) { this._tickerCurrent = this._tickerTarget; gsap.ticker.remove(this._tick); this._isTicking = false; } this._applyLayout(this._tickerCurrent); }; this._scheduleLayout = () => { if (!this._scrollContainer) return; this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; if (!this._isTicking) { this._isTicking = true; gsap.ticker.add(this._tick); } }; this.updateLayout = (instant = false) => { if (!this._scrollContainer || !this._cards || this._cards.length === 0) return; // Rebuild quickSetters if card count changed (e.g. after items update) if (this._cardSetX.length !== this._cards.length) { this._buildQuickSetters(); } this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; if (instant || (!this._isTicking && this._tickerCurrent === 0)) { this._tickerCurrent = this._tickerTarget; this._applyLayout(this._tickerCurrent); } else { if (!this._isTicking) { this._isTicking = true; gsap.ticker.add(this._tick); } } }; } get effectiveItems() { return this.items && this.items.length > 0 ? this.items : this._slottedItems; } static { this.styles = [ sharedStyles, css ` :host { display: block; width: 100%; user-select: none; -webkit-user-select: none; } .carousel-container { position: relative; width: 100%; display: flex; flex-direction: column; gap: 12px; } .carousel-header { display: flex; justify-content: space-between; align-items: flex-end; padding: 0 var(--carousel-padding, 16px); } .carousel-header h3 { margin: 0; font-family: var(--font-title, serif); font-size: 1.5rem; font-weight: 500; color: var(--on-surface); } .show-all-link { color: var(--primary); font-weight: 500; font-size: 0.875rem; text-decoration: none; cursor: pointer; transition: opacity var(--speed2); } .show-all-link:hover { opacity: 0.8; text-decoration: underline; } .carousel-viewport { position: relative; width: 100%; overflow: hidden; } .scroll-container { width: 100%; height: 296px; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; cursor: grab; } .scroll-container:active { cursor: grabbing; } .scroll-container::-webkit-scrollbar { display: none; } .snap-track { display: flex; width: calc(var(--carousel-track-width, 0px) + var(--carousel-right-padding, 128px)); gap: var(--carousel-gap, 8px); padding: 8px 0 8px var(--carousel-padding, 16px); height: 100%; pointer-events: none; } .snap-item { flex: 0 0 var(--carousel-snap-width, 220px); height: 100%; scroll-snap-align: start; pointer-events: none; } .visual-track { position: absolute; left: 0; top: 8px; width: 0; height: 280px; overflow: visible; pointer-events: none; } .card { position: absolute; top: 0; height: 280px; border-radius: var(--carousel-border-radius, 28px); overflow: hidden; background-color: var(--surface-container-high); pointer-events: auto; box-shadow: var(--elevate1); will-change: transform; cursor: pointer; transition: box-shadow var(--speed2); } .card:hover { box-shadow: var(--elevate2); } .card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(to bottom, transparent 40%, rgba(0, 0, 0, 0.7) 100%); pointer-events: none; z-index: 1; } .img-parallax-container { width: 100%; height: 100%; position: relative; overflow: hidden; } .card img { width: calc(100% + 50px); height: 100%; object-fit: cover; position: absolute; left: -25px; pointer-events: none; user-select: none; -webkit-user-drag: none; will-change: transform; } .card-title { position: absolute; bottom: 20px; left: 20px; right: 20px; color: #ffffff; margin: 0; font-size: 1.1rem; font-weight: 500; pointer-events: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; z-index: 2; } /* Arrow navigation overlays */ .nav-button { position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; width: 48px; height: 48px; border-radius: 50%; background-color: var(--surface-container-highest); color: var(--on-surface); border: none; box-shadow: var(--elevate2); display: flex; justify-content: center; align-items: center; cursor: pointer; opacity: 0; pointer-events: none; transition: opacity var(--speed2), background-color var(--speed2); } .nav-button:hover { background-color: var(--primary-container); color: var(--on-primary-container); } .nav-button.prev { left: 24px; } .nav-button.next { right: 24px; } .carousel-viewport:hover .nav-button.visible { opacity: 1; pointer-events: auto; } @media (max-width: 600px) { .nav-button { display: none !important; } } /* Utility icon style inside shadow dom */ .material-symbols { font-family: var(--font-icon); font-weight: normal; font-style: normal; font-size: 24px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; -webkit-font-smoothing: antialiased; } ` ]; } connectedCallback() { super.connectedCallback(); if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { this._containerWidth = entry.contentRect.width; this.updateLayout(true); // Synchronous update for instant resizing! this.requestUpdate(); } }); this._resizeObserver.observe(this); } // Detect mobile for parallax toggle if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { const mql = window.matchMedia('(max-width: 600px)'); this._isMobile = mql.matches; const handler = (e) => { this._isMobile = e.matches; }; mql.addEventListener('change', handler); // Store for cleanup this.__mqlCleanup = () => mql.removeEventListener('change', handler); } } disconnectedCallback() { if (this._resizeObserver) { this._resizeObserver.disconnect(); } // Cancel any pending RAF or ticker if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } gsap.ticker.remove(this._tick); this._isTicking = false; // Clean up matchMedia listener if (this.__mqlCleanup) { this.__mqlCleanup(); delete this.__mqlCleanup; } super.disconnectedCallback(); } firstUpdated() { const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])'); if (defaultSlot) { this._handleSlotChange({ target: defaultSlot }); } const showAllSlot = this.shadowRoot?.querySelector('slot[name=\"show-all\"]'); if (showAllSlot) { this._handleShowAllSlotChange({ target: showAllSlot }); } this._buildQuickSetters(); this.updateLayout(true); } updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('layout') || changedProperties.has('largeWidth') || changedProperties.has('mediumWidth') || changedProperties.has('smallWidth') || changedProperties.has('gap') || changedProperties.has('padding') || changedProperties.has('items') || changedProperties.has('hideNav') || changedProperties.has('_containerWidth') || changedProperties.has('_slottedItems') || changedProperties.has('_hasSlottedShowAll')) { if (changedProperties.has('layout') && this._scrollContainer) { this._scrollContainer.scrollLeft = 0; this._scrollContainer.style.scrollSnapType = this.layout === 'uncontained' ? 'none' : 'x mandatory'; } this.updateLayout(true); } } get computedLayout() { const W_c = this._containerWidth || this.getBoundingClientRect().width || 360; const gap = this.gap; const padding = this.padding; const W_a = W_c - 2 * padding; let L = this.largeWidth; let M = this.mediumWidth; let S = this.smallWidth; let N = 1; if (this.auto) { if (this.layout === 'uncontained') { // Fits as many L items as possible, letting them bleed off the right edge. // We target large width around 260px. L = Math.min(W_a, 260); } else if (this.layout === 'hero') { // Hero has N large items and 1 small peeking item. // W_a = N * L + S + N * gap S = 48; N = Math.max(1, Math.floor((W_a - S) / (250 + gap))); L = (W_a - S - N * gap) / N; if (L < 150) { N = 1; L = W_a - S - gap; } } else { // multi-browse: N large items, 1 medium, 1 small // W_a = N * L + M + S + (N + 1) * gap S = 48; let bestN = 1; let bestL = 220; let bestDiff = Infinity; for (let n = 1; n <= 10; n++) { const l = (W_a - S - (n + 1) * gap) / (n + 0.45); if (l >= 140 && l <= 340) { const diff = Math.abs(l - 220); if (diff < bestDiff) { bestDiff = diff; bestN = n; bestL = l; } } } if (bestDiff === Infinity) { N = 1; L = (W_a - S - 2 * gap) / 1.45; } else { N = bestN; L = bestL; } M = 0.45 * L; } } else { // Non-auto/manual sizing mode L = this.largeWidth; M = this.mediumWidth; S = this.smallWidth; if (this.layout === 'hero') { N = 1; } else if (this.layout === 'uncontained') { N = Math.max(1, Math.floor(W_a / (L + gap))); } else { N = 1; } } return { L, M, S, N }; } get itemSize() { return this.computedLayout.L + this.gap; } _getCardLayout(p) { const { L, M, S, N } = this.computedLayout; const gap = this.gap; const padding = this.padding; let width = L; let x = padding; let opacity = 1; if (this.layout === 'uncontained') { width = L; x = padding + p * (L + gap); opacity = 1; } else if (this.layout === 'hero') { const X_N = padding + N * (L + gap); const X_M1 = padding - gap - S; if (p <= -1) { width = S; x = X_M1 + (p - -1) * (S + gap); opacity = 0; } else if (p <= 0) { width = gsap.utils.interpolate(S, L, p + 1); x = gsap.utils.interpolate(X_M1, padding, p + 1); opacity = gsap.utils.interpolate(0, 1, p + 1); } else if (p <= N - 1) { width = L; x = padding + p * (L + gap); opacity = 1; } else if (p <= N) { const u = p - (N - 1); const X_last = padding + (N - 1) * (L + gap); width = gsap.utils.interpolate(L, S, u); x = gsap.utils.interpolate(X_last, X_N, u); opacity = gsap.utils.interpolate(1, 0, u); } else { width = S; x = X_N + (p - N) * (S + gap); opacity = 0; } } else { // multi-browse const X_N = padding + N * (L + gap); const X_N1 = X_N + M + gap; const X_M1 = padding - gap - S; if (p <= -1) { width = S; x = X_M1 + (p - -1) * (S + gap); opacity = 0; } else if (p <= 0) { width = gsap.utils.interpolate(S, L, p + 1); x = gsap.utils.interpolate(X_M1, padding, p + 1); opacity = gsap.utils.interpolate(0, 1, p + 1); } else if (p <= N - 1) { width = L; x = padding + p * (L + gap); opacity = 1; } else if (p <= N) { const u = p - (N - 1); const X_last = padding + (N - 1) * (L + gap); width = gsap.utils.interpolate(L, M, u); x = gsap.utils.interpolate(X_last, X_N, u); opacity = gsap.utils.interpolate(1, 0, u); } else if (p <= N + 1) { const u = p - N; width = gsap.utils.interpolate(M, S, u); x = gsap.utils.interpolate(X_N, X_N1, u); opacity = 0; } else { width = S; x = X_N1 + (p - (N + 1)) * (S + gap); opacity = 0; } } return { width, x, opacity }; } /** * Rebuild quickSetter caches after cards change. * quickSetter avoids gsap.set() overhead for properties updated every frame. */ _buildQuickSetters() { if (!this._cards || this._cards.length === 0) return; this._cardSetX = []; this._cardSetWidth = []; this._imgSetX = []; this._titleSetOpacity = []; this._cards.forEach((card) => { this._cardSetX.push(gsap.quickSetter(card, 'x', 'px')); this._cardSetWidth.push(gsap.quickSetter(card, 'width', 'px')); const img = card.querySelector('img'); this._imgSetX.push(img ? gsap.quickSetter(img, 'x', 'px') : (() => { })); const title = card.querySelector('.card-title'); this._titleSetOpacity.push(title ? gsap.quickSetter(title, 'opacity') : (() => { })); }); } _applyLayout(t) { const isMobile = this._isMobile; const cardCount = this._cards.length; for (let i = 0; i < cardCount; i++) { const p = i - t; const layout = this._getCardLayout(p); // Use quickSetters — ~3x faster than gsap.set() per call this._cardSetX[i](layout.x); this._cardSetWidth[i](layout.width); // Skip parallax on mobile to save per-frame work if (!isMobile) { const clampedP = Math.max(-1, Math.min(1, p)); this._imgSetX[i](clampedP * -25); } this._titleSetOpacity[i](layout.opacity); } } _handleMouseDown(e) { gsap.killTweensOf(this._scrollContainer); this._isDown = true; this._draggedDistance = 0; this._scrollContainer.style.scrollBehavior = 'auto'; this._scrollContainer.style.scrollSnapType = 'none'; this._startX = e.pageX - this._scrollContainer.offsetLeft; this._scrollLeftStart = this._scrollContainer.scrollLeft; this._velocity = 0; this._lastX = e.pageX; this._lastTime = performance.now(); } _handleMouseMove(e) { if (!this._isDown) return; e.preventDefault(); const currentX = e.pageX; const x = currentX - this._scrollContainer.offsetLeft; this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x) * 1.25; const currentTime = performance.now(); const dt = currentTime - this._lastTime; if (dt > 0) { this._velocity = (currentX - this._lastX) / dt; } this._draggedDistance += Math.abs(currentX - this._lastX); this._lastX = currentX; this._lastTime = currentTime; } _handleMouseUp() { if (!this._isDown) return; this._isDown = false; const currentTime = performance.now(); if (currentTime - this._lastTime > 100) { this._velocity = 0; } // Dynamic momentum: stronger swipes have a much higher multiplier const momentumMultiplier = 350 + Math.abs(this._velocity) * 300; const projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier; if (this.layout !== 'uncontained') { this._scrollContainer.style.scrollSnapType = 'none'; let targetIndex = Math.round(projectedScrollLeft / this.itemSize); targetIndex = Math.max(0, Math.min(this.effectiveItems.length - 1, targetIndex)); const snapPoint = targetIndex * this.itemSize; // Dynamic duration: longer distance = more time, max 0.85s for faster feeling const currentIndex = Math.round(this._scrollContainer.scrollLeft / this.itemSize); const cardsSkipped = Math.abs(targetIndex - currentIndex); const duration = Math.min(0.85, 0.25 + cardsSkipped * 0.04); this._scrollContainer.style.scrollBehavior = 'auto'; gsap.to(this._scrollContainer, { scrollLeft: snapPoint, duration: duration, ease: 'power3.out', onComplete: () => { if (!this._isDown && this._scrollContainer) { this._scrollContainer.style.scrollSnapType = 'x mandatory'; } } }); } } _handleTouchStart(e) { gsap.killTweensOf(this._scrollContainer); this._isDown = true; this._draggedDistance = 0; this._scrollContainer.style.scrollBehavior = 'auto'; this._scrollContainer.style.scrollSnapType = 'none'; const touch = e.touches[0]; this._startX = touch.pageX - this._scrollContainer.offsetLeft; this._scrollLeftStart = this._scrollContainer.scrollLeft; this._velocity = 0; this._lastX = touch.pageX; this._lastTime = performance.now(); } _handleTouchMove(e) { if (!this._isDown) return; const touch = e.touches[0]; const currentX = touch.pageX; const x = currentX - this._scrollContainer.offsetLeft; this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x); const currentTime = performance.now(); const dt = currentTime - this._lastTime; if (dt > 0) { this._velocity = (currentX - this._lastX) / dt; } this._draggedDistance += Math.abs(currentX - this._lastX); this._lastX = currentX; this._lastTime = currentTime; } _handleTouchEnd() { this._handleMouseUp(); } _handleWheel(e) { // Solo interceptar scroll vertical puro (rueda de mouse típica) if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { const maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth; const currentScroll = this._scrollContainer.scrollLeft; // Comprobar si estamos en los bordes para permitir el scroll natural de la página const isAtStart = currentScroll <= 0; const isAtEnd = currentScroll >= maxScroll - 1; // Margen de 1px por redondeos decimales if ((e.deltaY < 0 && isAtStart) || (e.deltaY > 0 && isAtEnd)) { return; // Dejar que la página haga scroll vertical } e.preventDefault(); // Prevenir el scroll de la página // Desactivar el snap momentáneamente para que el movimiento de rueda sea fluido this._scrollContainer.style.scrollSnapType = 'none'; // Multiplicador si el deltaMode no es pixel (0) let delta = e.deltaY; if (e.deltaMode === 1) delta *= 40; // Líneas else if (e.deltaMode === 2) delta *= 800; // Páginas this._scrollContainer.scrollLeft += delta; // Restablecer el snap después de un pequeño retraso if (this._wheelTimeout) clearTimeout(this._wheelTimeout); this._wheelTimeout = setTimeout(() => { if (this.layout !== 'uncontained') { this._scrollContainer.style.scrollSnapType = 'x mandatory'; } }, 150); } } _handleCardClick(e, item, index) { if (this._draggedDistance > 8) { e.preventDefault(); e.stopPropagation(); return; } this.dispatchEvent(new CustomEvent('item-click', { detail: { item, index }, bubbles: true, composed: true })); } _scrollPrevious() { const target = Math.max(0, this._scrollContainer.scrollLeft - this.itemSize); this._scrollContainer.style.scrollBehavior = 'smooth'; this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' }); } _scrollNext() { const maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth; const target = Math.min(maxScroll, this._scrollContainer.scrollLeft + this.itemSize); this._scrollContainer.style.scrollBehavior = 'smooth'; this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' }); } _handleShowAllClick(e) { const target = e.target; if (target.tagName.toLowerCase() === 'a' && target.getAttribute('href') === '#') { e.preventDefault(); } this.dispatchEvent(new CustomEvent('show-all-click', { bubbles: true, composed: true })); } _handleShowAllSlotChange(e) { const slot = e.target; let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : []; if (nodes.length === 0) { nodes = slot.assignedNodes().filter(n => n.nodeType === 1); } if (nodes.length === 0) { nodes = Array.from(this.querySelectorAll('[slot=\"show-all\"]')); // Fallback for JSDOM } this._hasSlottedShowAll = nodes.length > 0; } _handleSlotChange(e) { const slot = e.target; let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : []; if (nodes.length === 0) { nodes = slot.assignedNodes().filter(n => n.nodeType === 1); } if (nodes.length === 0) { nodes = Array.from(this.children).filter(child => child.getAttribute('slot') !== 'show-all'); // Fallback for JSDOM, ignore show-all slot } const items = []; for (const node of nodes) { if (node.tagName.toLowerCase() === 'img') { const img = node; items.push({ title: img.getAttribute('title') || img.getAttribute('alt') || '', img: img.src }); } else if (node.tagName.toLowerCase() === 'a') { const anchor = node; const img = anchor.querySelector('img'); if (img) { items.push({ title: img.getAttribute('title') || img.getAttribute('alt') || anchor.getAttribute('title') || '', img: img.src, href: anchor.href, target: anchor.target }); } } } this._slottedItems = items; // Check if slotted show-all button presence changed const hasSlottedShowAll = !!this.querySelector('[slot=\"show-all\"]'); if (hasSlottedShowAll !== this._hasSlottedShowAll) { this._hasSlottedShowAll = hasSlottedShowAll; } void this.updateComplete.then(() => { this.updateLayout(true); }); } render() { const { L, M, S } = this.computedLayout; const hasItems = this.effectiveItems && this.effectiveItems.length > 0; const hasSlottedShowAll = this._hasSlottedShowAll || !!this.querySelector('[slot=\"show-all\"]'); const showHeader = this.headerText || this.showAll || hasSlottedShowAll; let rightPadding = 128; if (this.layout === 'hero') { rightPadding = S + this.gap; } else if (this.layout === 'uncontained') { rightPadding = 16; } else { rightPadding = M + S + 2 * this.gap; } // Ensure the scroll track is long enough to allow the last item to scroll to the start (p = 0 focus). // Safety margin of 400px avoids fractional rounding/zoom-scale snapping blockages in Firefox. const W_c = this._containerWidth || this.getBoundingClientRect().width || 360; const minRightPaddingNeeded = W_c - L - this.padding + 400; if (rightPadding < minRightPaddingNeeded) { rightPadding = minRightPaddingNeeded; } const trackWidth = hasItems ? this.effectiveItems.length * L + (this.effectiveItems.length - 1) * this.gap : 0; const isScrollable = hasItems && this._scrollContainer && (this._scrollContainer.scrollWidth > this._scrollContainer.clientWidth); const showPrevArrow = isScrollable && this._scrollContainer.scrollLeft > 5; const showNextArrow = isScrollable && (this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5); return html ` <div class=\"carousel-container\" style=\" --carousel-snap-width: ${L}px; --carousel-gap: ${this.gap}px; --carousel-padding: ${this.padding}px; --carousel-right-padding: ${rightPadding}px; --carousel-border-radius: ${this.borderRadius}px; --carousel-track-width: ${trackWidth}px; \"> <slot @slotchange=${this._handleSlotChange} style=\"display: none;\"></slot> ${showHeader ? html ` <div class=\"carousel-header\"> ${this.headerText ? html `<h3>${this.headerText}</h3>` : ''} <slot name=\"show-all\" @slotchange=${this._handleShowAllSlotChange} @click=${this._handleShowAllClick}> ${this.showAll ? html ` <a href=\"#\" class=\"show-all-link\"> ${this.showAllText} </a> ` : ''} </slot> </div> ` : ''} <div class=\"carousel-viewport\"> ${!this.hideNav ? html ` <button class=\"nav-button prev ${showPrevArrow ? 'visible' : ''}\" @click=${this._scrollPrevious} aria-label=\"Previous items\" > <span class=\"material-symbols\">chevron_left</span> </button> ` : ''} <div class=\"scroll-container\" @scroll=${this._scheduleLayout} @mousedown=${this._handleMouseDown} @mousemove=${this._handleMouseMove} @mouseup=${this._handleMouseUp} @mouseleave=${this._handleMouseUp} @touchstart=${this._handleTouchStart} @touchmove=${this._handleTouchMove} @touchend=${this._handleTouchEnd} @wheel=${this._handleWheel} > <div class=\"snap-track\"> ${hasItems ? this.effectiveItems.map(() => html `<div class=\"snap-item\"></div>`) : ''} </div> <div class=\"visual-track\"> ${hasItems ? this.effectiveItems.map((item, idx) => html ` <div class=\"card\" @click=${(e) => this._handleCardClick(e, item, idx)} > ${item.href ? html ` <a href=\"${item.href}\" target=\"${item.target || '_self'}\" style=\"text-decoration:none; color:inherit;\"> <div class=\"img-parallax-container\"> <img src=\"${item.img}\" alt=\"${item.title}\" draggable=\"false\" /> </div> <h2 class=\"card-title\">${item.title}</h2> </a> ` : html ` <div class=\"img-parallax-container\"> <img src=\"${item.img}\" alt=\"${item.title}\" draggable=\"false\" /> </div> <h2 class=\"card-title\">${item.title}</h2> `} </div> `) : ''} </div> </div> ${!this.hideNav ? html ` <button class=\"nav-button next ${showNextArrow ? 'visible' : ''}\" @click=${this._scrollNext} aria-label=\"Next items\" > <span class=\"material-symbols\">chevron_right</span> </button> ` : ''} </div> </div> `; } }",
1452
+ "default": "class MoniCarousel extends MoniElement { constructor() { super(...arguments); this.items = []; this.layout = 'multi-browse'; this.auto = true; this.largeWidth = 220; this.mediumWidth = 96; this.smallWidth = 48; this.gap = 8; this.padding = 16; this.borderRadius = 28; this.showAll = false; this.showAllText = 'Show all'; this.headerText = ''; this.hideNav = false; this.infinite = false; this.autoplay = false; this.autoplayInterval = 3000; this._containerWidth = 0; this._slottedItems = []; this._hasSlottedShowAll = false; this._resizeObserver = null; this._isDown = false; this._startX = 0; this._scrollLeftStart = 0; this._velocity = 0; this._lastTime = 0; this._lastX = 0; this._draggedDistance = 0; this._rafId = null; this._isMobile = false; // Scroll interpolation state this._tickerTarget = 0; this._tickerCurrent = 0; this._isTicking = false; // Infinite & Autoplay state this._infiniteInitialized = false; this._autoplayTimer = null; // quickSetter caches — avoid gsap.set() overhead per frame this._cardSetX = []; this._cardSetWidth = []; this._imgSetX = []; this._titleSetOpacity = []; /** * RAF-gated version of updateLayout for scroll events. * Prevents multiple layout recalculations per frame. */ this._tick = () => { const dt = gsap.ticker.deltaRatio(); // Smoother lerp on mobile to avoid abrupt changes (\"brusco\") const ease = this._isMobile ? 0.08 : 0.15; this._tickerCurrent += (this._tickerTarget - this._tickerCurrent) * ease * dt; if (Math.abs(this._tickerTarget - this._tickerCurrent) < 0.0005) { this._tickerCurrent = this._tickerTarget; gsap.ticker.remove(this._tick); this._isTicking = false; this._checkInfiniteWrap(); } this._applyLayout(this._tickerCurrent); }; this._scheduleLayout = () => { if (!this._scrollContainer) return; this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; if (!this._isTicking) { this._isTicking = true; gsap.ticker.add(this._tick); } }; this.updateLayout = (instant = false) => { if (!this._scrollContainer || !this._cards || this._cards.length === 0) return; // Rebuild quickSetters if card count changed (e.g. after items update) if (this._cardSetX.length !== this._cards.length) { this._buildQuickSetters(); } if (this.infinite && !this._infiniteInitialized && this.effectiveItems.length > 0) { const C = this.effectiveItems.length; const V = C * this._visualCardsMultiplier; const K = this._snapSetsCount; const centerSetIndex = Math.floor(K / 2); // Wait for the browser to render the new items so scrollWidth is updated // A double-rAF or setTimeout ensures the layout engine has processed the new snap-items requestAnimationFrame(() => { setTimeout(() => { if (this._scrollContainer) { // Force layout calculation this._scrollContainer.scrollWidth; this._scrollContainer.scrollLeft = centerSetIndex * V * this.itemSize; // If the browser still clamped it, it means it's not ready. // But with setTimeout it should be fully ready. // Force the visual ticker to match instantly to avoid an animation zoom from 0 this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; this._tickerCurrent = this._tickerTarget; this._applyLayout(this._tickerCurrent); } }, 20); }); this._infiniteInitialized = true; } this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; if (instant || (!this._isTicking && this._tickerCurrent === 0)) { this._tickerCurrent = this._tickerTarget; this._applyLayout(this._tickerCurrent); } else { if (!this._isTicking) { this._isTicking = true; gsap.ticker.add(this._tick); } } }; } get effectiveItems() { return this.items && this.items.length > 0 ? this.items : this._slottedItems; } static { this.styles = [ sharedStyles, css ` :host { display: block; width: 100%; user-select: none; -webkit-user-select: none; } .carousel-container { position: relative; width: 100%; display: flex; flex-direction: column; gap: 12px; } .carousel-header { display: flex; justify-content: space-between; align-items: flex-end; padding: 0 var(--carousel-padding, 16px); } .carousel-header h3 { margin: 0; font-family: var(--font-title, serif); font-size: 1.5rem; font-weight: 500; color: var(--on-surface); } .show-all-link { color: var(--primary); font-weight: 500; font-size: 0.875rem; text-decoration: none; cursor: pointer; transition: opacity var(--speed2); } .show-all-link:hover { opacity: 0.8; text-decoration: underline; } .carousel-viewport { position: relative; width: 100%; overflow: hidden; } .scroll-container { width: 100%; height: 296px; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; cursor: grab; } .scroll-container:active { cursor: grabbing; } .scroll-container::-webkit-scrollbar { display: none; } .snap-track { display: flex; width: calc(var(--carousel-track-width, 0px) + var(--carousel-right-padding, 128px)); gap: var(--carousel-gap, 8px); padding: 8px 0 8px var(--carousel-padding, 16px); height: 100%; pointer-events: none; } .snap-item { flex: 0 0 var(--carousel-snap-width, 220px); height: 100%; scroll-snap-align: start; pointer-events: none; } .visual-track { position: absolute; left: 0; top: 8px; width: 0; height: 280px; overflow: visible; pointer-events: none; } .card { position: absolute; top: 0; height: 280px; border-radius: var(--carousel-border-radius, 28px); overflow: hidden; background-color: var(--surface-container-high); pointer-events: auto; box-shadow: var(--elevate1); will-change: transform; cursor: pointer; transition: box-shadow var(--speed2); } .card:hover { box-shadow: var(--elevate2); } .card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(to bottom, transparent 40%, rgba(0, 0, 0, 0.7) 100%); pointer-events: none; z-index: 1; } .img-parallax-container { width: 100%; height: 100%; position: relative; overflow: hidden; } .card img { width: calc(100% + 50px); height: 100%; object-fit: cover; position: absolute; left: -25px; pointer-events: none; user-select: none; -webkit-user-drag: none; will-change: transform; } .card-title { position: absolute; bottom: 20px; left: 20px; right: 20px; color: #ffffff; margin: 0; font-size: 1.1rem; font-weight: 500; pointer-events: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; z-index: 2; } /* Arrow navigation overlays */ .nav-button { position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; width: 48px; height: 48px; border-radius: 50%; background-color: var(--surface-container-highest); color: var(--on-surface); border: none; box-shadow: var(--elevate2); display: flex; justify-content: center; align-items: center; cursor: pointer; opacity: 0; pointer-events: none; transition: opacity var(--speed2), background-color var(--speed2); } .nav-button:hover { background-color: var(--primary-container); color: var(--on-primary-container); } .nav-button.prev { left: 24px; } .nav-button.next { right: 24px; } .carousel-viewport:hover .nav-button.visible { opacity: 1; pointer-events: auto; } @media (max-width: 600px) { .nav-button { display: none !important; } } /* Utility icon style inside shadow dom */ .material-symbols { font-family: var(--font-icon); font-weight: normal; font-style: normal; font-size: 24px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; -webkit-font-smoothing: antialiased; } ` ]; } connectedCallback() { super.connectedCallback(); if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { this._containerWidth = entry.contentRect.width; this.updateLayout(true); // Synchronous update for instant resizing! this.requestUpdate(); } }); this._resizeObserver.observe(this); } // Detect mobile for parallax toggle if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { const mql = window.matchMedia('(max-width: 600px)'); this._isMobile = mql.matches; const handler = (e) => { this._isMobile = e.matches; }; mql.addEventListener('change', handler); // Store for cleanup this.__mqlCleanup = () => mql.removeEventListener('change', handler); } } disconnectedCallback() { if (this._resizeObserver) { this._resizeObserver.disconnect(); } // Cancel any pending RAF or ticker if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } gsap.ticker.remove(this._tick); this._isTicking = false; // Clean up matchMedia listener if (this.__mqlCleanup) { this.__mqlCleanup(); delete this.__mqlCleanup; } super.disconnectedCallback(); } firstUpdated() { const defaultSlot = this.shadowRoot?.querySelector('slot:not([name])'); if (defaultSlot) { this._handleSlotChange({ target: defaultSlot }); } const showAllSlot = this.shadowRoot?.querySelector('slot[name=\"show-all\"]'); if (showAllSlot) { this._handleShowAllSlotChange({ target: showAllSlot }); } this._buildQuickSetters(); this.updateLayout(true); if (this.autoplay) { this._startAutoplay(); } } updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('layout') || changedProperties.has('largeWidth') || changedProperties.has('mediumWidth') || changedProperties.has('smallWidth') || changedProperties.has('gap') || changedProperties.has('padding') || changedProperties.has('items') || changedProperties.has('hideNav') || changedProperties.has('infinite') || changedProperties.has('autoplay') || changedProperties.has('autoplayInterval') || changedProperties.has('_containerWidth') || changedProperties.has('_slottedItems') || changedProperties.has('_hasSlottedShowAll')) { if (changedProperties.has('infinite') || changedProperties.has('items') || changedProperties.has('_slottedItems')) { this._infiniteInitialized = false; } if (changedProperties.has('layout') && this._scrollContainer) { this._scrollContainer.scrollLeft = 0; this._infiniteInitialized = false; this._scrollContainer.style.scrollSnapType = this.layout === 'uncontained' ? 'none' : 'x mandatory'; } this.updateLayout(true); if (changedProperties.has('autoplay') || changedProperties.has('autoplayInterval') || changedProperties.has('items') || changedProperties.has('_slottedItems')) { if (this.autoplay) { this._startAutoplay(); } else { this._stopAutoplay(); } } } } get computedLayout() { const W_c = this._containerWidth || this.getBoundingClientRect().width || 360; const gap = this.gap; const padding = this.padding; const W_a = W_c - 2 * padding; let L = this.largeWidth; let M = this.mediumWidth; let S = this.smallWidth; let N = 1; if (this.auto) { if (this.layout === 'uncontained') { // Fits as many L items as possible, letting them bleed off the right edge. // We target large width around 260px. L = Math.min(W_a, 260); } else if (this.layout === 'hero') { // Hero has N large items and 1 small peeking item. // W_a = N * L + S + N * gap S = 48; N = Math.max(1, Math.floor((W_a - S) / (250 + gap))); L = (W_a - S - N * gap) / N; if (L < 150) { N = 1; L = W_a - S - gap; } } else { // multi-browse: N large items, 1 medium, 1 small // W_a = N * L + M + S + (N + 1) * gap S = 48; let bestN = 1; let bestL = 220; let bestDiff = Infinity; for (let n = 1; n <= 10; n++) { const l = (W_a - S - (n + 1) * gap) / (n + 0.45); if (l >= 140 && l <= 340) { const diff = Math.abs(l - 220); if (diff < bestDiff) { bestDiff = diff; bestN = n; bestL = l; } } } if (bestDiff === Infinity) { N = 1; L = (W_a - S - 2 * gap) / 1.45; } else { N = bestN; L = bestL; } M = 0.45 * L; } } else { // Non-auto/manual sizing mode L = this.largeWidth; M = this.mediumWidth; S = this.smallWidth; if (this.layout === 'hero') { N = 1; } else if (this.layout === 'uncontained') { N = Math.max(1, Math.floor(W_a / (L + gap))); } else { N = 1; } } return { L, M, S, N }; } get itemSize() { return this.computedLayout.L + this.gap; } _getCardLayout(p) { const { L, M, S, N } = this.computedLayout; const gap = this.gap; const padding = this.padding; let width = L; let x = padding; let opacity = 1; if (this.layout === 'uncontained') { width = L; x = padding + p * (L + gap); opacity = 1; } else if (this.layout === 'hero') { const X_N = padding + N * (L + gap); const X_M1 = padding - gap - S; if (p <= -1) { width = S; x = X_M1 + (p - -1) * (S + gap); opacity = 0; } else if (p <= 0) { width = gsap.utils.interpolate(S, L, p + 1); x = gsap.utils.interpolate(X_M1, padding, p + 1); opacity = gsap.utils.interpolate(0, 1, p + 1); } else if (p <= N - 1) { width = L; x = padding + p * (L + gap); opacity = 1; } else if (p <= N) { const u = p - (N - 1); const X_last = padding + (N - 1) * (L + gap); width = gsap.utils.interpolate(L, S, u); x = gsap.utils.interpolate(X_last, X_N, u); opacity = gsap.utils.interpolate(1, 0, u); } else { width = S; x = X_N + (p - N) * (S + gap); opacity = 0; } } else { // multi-browse const X_N = padding + N * (L + gap); const X_N1 = X_N + M + gap; const X_M1 = padding - gap - S; if (p <= -1) { width = S; x = X_M1 + (p - -1) * (S + gap); opacity = 0; } else if (p <= 0) { width = gsap.utils.interpolate(S, L, p + 1); x = gsap.utils.interpolate(X_M1, padding, p + 1); opacity = gsap.utils.interpolate(0, 1, p + 1); } else if (p <= N - 1) { width = L; x = padding + p * (L + gap); opacity = 1; } else if (p <= N) { const u = p - (N - 1); const X_last = padding + (N - 1) * (L + gap); width = gsap.utils.interpolate(L, M, u); x = gsap.utils.interpolate(X_last, X_N, u); opacity = gsap.utils.interpolate(1, 0, u); } else if (p <= N + 1) { const u = p - N; width = gsap.utils.interpolate(M, S, u); x = gsap.utils.interpolate(X_N, X_N1, u); opacity = 0; } else { width = S; x = X_N1 + (p - (N + 1)) * (S + gap); opacity = 0; } } return { width, x, opacity }; } /** * Rebuild quickSetter caches after cards change. * quickSetter avoids gsap.set() overhead for properties updated every frame. */ _buildQuickSetters() { if (!this._cards || this._cards.length === 0) return; this._cardSetX = []; this._cardSetWidth = []; this._imgSetX = []; this._titleSetOpacity = []; this._cards.forEach((card) => { this._cardSetX.push(gsap.quickSetter(card, 'x', 'px')); this._cardSetWidth.push(gsap.quickSetter(card, 'width', 'px')); const img = card.querySelector('img'); this._imgSetX.push(img ? gsap.quickSetter(img, 'x', 'px') : (() => { })); const title = card.querySelector('.card-title'); this._titleSetOpacity.push(title ? gsap.quickSetter(title, 'opacity') : (() => { })); }); } _applyLayout(t) { const isMobile = this._isMobile; const cardCount = this._cards.length; for (let i = 0; i < cardCount; i++) { let p = i - t; if (this.infinite) { // Modulo wrap so the cards perfectly loop around the center p = i - (t % cardCount); if (p > cardCount / 2) p -= cardCount; if (p < -cardCount / 2) p += cardCount; } // Optimization: if the card is far off-screen, hide it to save rendering work if (p < -15 || p > 15) { this._cardSetX[i](-9999); this._titleSetOpacity[i](0); continue; } const layout = this._getCardLayout(p); // Use quickSetters — ~3x faster than gsap.set() per call this._cardSetX[i](layout.x); this._cardSetWidth[i](layout.width); // Skip parallax on mobile to save per-frame work if (!isMobile) { const clampedP = Math.max(-1, Math.min(1, p)); this._imgSetX[i](clampedP * -25); } this._titleSetOpacity[i](layout.opacity); } } get _visualCardsMultiplier() { if (!this.infinite || this.effectiveItems.length === 0) return 1; const C = this.effectiveItems.length; // We need at least ~15 cards to fill the screen seamlessly for small datasets return Math.max(1, Math.ceil(15 / C)); } get _snapSetsCount() { if (!this.infinite || this.effectiveItems.length === 0) return 1; const C = this.effectiveItems.length; const V = C * this._visualCardsMultiplier; // We want enough snap items so the user NEVER reaches the end in a single fast swipe. // A total of ~1000 items is extremely cheap as empty divs, and provides a massive buffer. let K = Math.ceil(1000 / V); if (K % 2 === 0) K += 1; // Keep it odd for a perfect center return Math.max(3, K); } get _snapItemsCount() { if (this.infinite) { const C = this.effectiveItems.length; const V_mult = this._visualCardsMultiplier; const K = this._snapSetsCount; return K * (C * V_mult); } return this.effectiveItems.length; } _checkInfiniteWrap() { if (!this.infinite || this.effectiveItems.length === 0 || !this._scrollContainer) return; const C = this.effectiveItems.length; const V = C * this._visualCardsMultiplier; const K = this._snapSetsCount; const centerSetIndex = Math.floor(K / 2); // The scroll track is divided into K sets, each containing V items. const setWidth = V * this.itemSize; const currentScroll = this._scrollContainer.scrollLeft; const currentSet = Math.floor(currentScroll / setWidth); if (currentSet !== centerSetIndex) { const delta = (centerSetIndex - currentSet) * setWidth; const oldSnap = this._scrollContainer.style.scrollSnapType; const oldBehavior = this._scrollContainer.style.scrollBehavior; // Temporarily disable snap and smooth behavior so the wrap is instantaneous this._scrollContainer.style.scrollSnapType = 'none'; this._scrollContainer.style.scrollBehavior = 'auto'; this._scrollContainer.scrollLeft += delta; // INSTANTLY update the GSAP visual ticker so it doesn't animate the jump this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize; this._tickerCurrent = this._tickerTarget; this._applyLayout(this._tickerCurrent); // Restore original scroll behavior this._scrollContainer.style.scrollBehavior = oldBehavior; if (this._isDown) { this._scrollLeftStart += delta; } if (oldSnap && oldSnap !== 'none') { // Re-enable snap on the next frame to avoid jumping requestAnimationFrame(() => { if (this._scrollContainer && !this._isDown) { this._scrollContainer.style.scrollSnapType = oldSnap; } }); } } } _startAutoplay() { this._stopAutoplay(); if (this.autoplay && this.effectiveItems.length > 0) { this._autoplayTimer = setInterval(() => { // We removed _isInteracting to make autoplay more robust. It only pauses on active drag. if (!this._isDown) { this._scrollNext(); } }, this.autoplayInterval); } } _stopAutoplay() { if (this._autoplayTimer) { clearInterval(this._autoplayTimer); this._autoplayTimer = null; } } _handleMouseEnter() { } _handleMouseLeave() { this._handleMouseUp(); } _handleMouseDown(e) { gsap.killTweensOf(this._scrollContainer); this._checkInfiniteWrap(); this._isDown = true; this._draggedDistance = 0; this._scrollContainer.style.scrollBehavior = 'auto'; this._scrollContainer.style.scrollSnapType = 'none'; this._startX = e.pageX - this._scrollContainer.offsetLeft; this._scrollLeftStart = this._scrollContainer.scrollLeft; this._velocity = 0; this._lastX = e.pageX; this._lastTime = performance.now(); } _handleMouseMove(e) { if (!this._isDown) return; e.preventDefault(); const currentX = e.pageX; const x = currentX - this._scrollContainer.offsetLeft; this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x) * 1.25; const currentTime = performance.now(); const dt = currentTime - this._lastTime; if (dt > 0) { this._velocity = (currentX - this._lastX) / dt; } this._draggedDistance += Math.abs(currentX - this._lastX); this._lastX = currentX; this._lastTime = currentTime; } _handleMouseUp() { if (!this._isDown) return; this._isDown = false; const currentTime = performance.now(); if (currentTime - this._lastTime > 100) { this._velocity = 0; } // Dynamic momentum: stronger swipes have a much higher multiplier const momentumMultiplier = 350 + Math.abs(this._velocity) * 300; let projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier; // Cap momentum throw to prevent hitting infinite wrap edges on fast swipes const maxThrowDist = 15 * this.itemSize; if (projectedScrollLeft < this._scrollContainer.scrollLeft - maxThrowDist) { projectedScrollLeft = this._scrollContainer.scrollLeft - maxThrowDist; } else if (projectedScrollLeft > this._scrollContainer.scrollLeft + maxThrowDist) { projectedScrollLeft = this._scrollContainer.scrollLeft + maxThrowDist; } if (this.layout !== 'uncontained') { this._scrollContainer.style.scrollSnapType = 'none'; let targetIndex = Math.round(projectedScrollLeft / this.itemSize); const maxIndex = this.effectiveItems.length > 0 ? this._snapItemsCount - 1 : 0; targetIndex = Math.max(0, Math.min(maxIndex, targetIndex)); const snapPoint = targetIndex * this.itemSize; // Dynamic duration: longer distance = more time, max 0.85s for faster feeling const currentIndex = Math.round(this._scrollContainer.scrollLeft / this.itemSize); const cardsSkipped = Math.abs(targetIndex - currentIndex); const duration = Math.min(0.85, 0.25 + cardsSkipped * 0.04); this._scrollContainer.style.scrollBehavior = 'auto'; gsap.to(this._scrollContainer, { scrollLeft: snapPoint, duration: duration, ease: 'power3.out', onComplete: () => { if (!this._isDown && this._scrollContainer) { this._scrollContainer.style.scrollSnapType = 'x mandatory'; } } }); } } _handleTouchStart(e) { gsap.killTweensOf(this._scrollContainer); this._checkInfiniteWrap(); this._isDown = true; this._draggedDistance = 0; this._scrollContainer.style.scrollBehavior = 'auto'; this._scrollContainer.style.scrollSnapType = 'none'; const touch = e.touches[0]; this._startX = touch.pageX - this._scrollContainer.offsetLeft; this._scrollLeftStart = this._scrollContainer.scrollLeft; this._velocity = 0; this._lastX = touch.pageX; this._lastTime = performance.now(); } _handleTouchMove(e) { if (!this._isDown) return; const touch = e.touches[0]; const currentX = touch.pageX; const x = currentX - this._scrollContainer.offsetLeft; this._scrollContainer.scrollLeft = this._scrollLeftStart + (this._startX - x); const currentTime = performance.now(); const dt = currentTime - this._lastTime; if (dt > 0) { this._velocity = (currentX - this._lastX) / dt; } this._draggedDistance += Math.abs(currentX - this._lastX); this._lastX = currentX; this._lastTime = currentTime; } _handleTouchEnd() { this._handleMouseUp(); } _handleWheel(e) { // Solo interceptar scroll vertical puro (rueda de mouse típica) if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { const maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth; const currentScroll = this._scrollContainer.scrollLeft; // Comprobar si estamos en los bordes para permitir el scroll natural de la página const isAtStart = currentScroll <= 0; const isAtEnd = currentScroll >= maxScroll - 1; // Margen de 1px por redondeos decimales if ((e.deltaY < 0 && isAtStart) || (e.deltaY > 0 && isAtEnd)) { return; // Dejar que la página haga scroll vertical } e.preventDefault(); // Prevenir el scroll de la página // Desactivar el snap momentáneamente para que el movimiento de rueda sea fluido this._scrollContainer.style.scrollSnapType = 'none'; // Multiplicador si el deltaMode no es pixel (0) let delta = e.deltaY; if (e.deltaMode === 1) delta *= 40; // Líneas else if (e.deltaMode === 2) delta *= 800; // Páginas this._scrollContainer.scrollLeft += delta; // Restablecer el snap después de un pequeño retraso if (this._wheelTimeout) clearTimeout(this._wheelTimeout); this._wheelTimeout = setTimeout(() => { if (this.layout !== 'uncontained' && !this._isDown) { this._scrollContainer.style.scrollSnapType = 'x mandatory'; } }, 150); } } _handleCardClick(e, item, index) { if (this._draggedDistance > 8) { e.preventDefault(); e.stopPropagation(); return; } const originalIndex = index % this.effectiveItems.length; this.dispatchEvent(new CustomEvent('item-click', { detail: { item, index: originalIndex }, bubbles: true, composed: true })); } _scrollPrevious() { const target = this._scrollContainer.scrollLeft - this.itemSize; this._scrollContainer.style.scrollBehavior = 'smooth'; this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' }); } _scrollNext() { const target = this._scrollContainer.scrollLeft + this.itemSize; this._scrollContainer.style.scrollBehavior = 'smooth'; this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' }); } _handleShowAllClick(e) { const target = e.target; if (target.tagName.toLowerCase() === 'a' && target.getAttribute('href') === '#') { e.preventDefault(); } this.dispatchEvent(new CustomEvent('show-all-click', { bubbles: true, composed: true })); } _handleShowAllSlotChange(e) { const slot = e.target; let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : []; if (nodes.length === 0) { nodes = slot.assignedNodes().filter(n => n.nodeType === 1); } if (nodes.length === 0) { nodes = Array.from(this.querySelectorAll('[slot=\"show-all\"]')); // Fallback for JSDOM } this._hasSlottedShowAll = nodes.length > 0; } _handleSlotChange(e) { const slot = e.target; let nodes = typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : []; if (nodes.length === 0) { nodes = slot.assignedNodes().filter(n => n.nodeType === 1); } if (nodes.length === 0) { nodes = Array.from(this.children).filter(child => child.getAttribute('slot') !== 'show-all'); // Fallback for JSDOM, ignore show-all slot } const items = []; for (const node of nodes) { if (node.tagName.toLowerCase() === 'img') { const img = node; items.push({ title: img.getAttribute('title') || img.getAttribute('alt') || '', img: img.src }); } else if (node.tagName.toLowerCase() === 'a') { const anchor = node; const img = anchor.querySelector('img'); if (img) { items.push({ title: img.getAttribute('title') || img.getAttribute('alt') || anchor.getAttribute('title') || '', img: img.src, href: anchor.href, target: anchor.target }); } } } this._slottedItems = items; // Check if slotted show-all button presence changed const hasSlottedShowAll = !!this.querySelector('[slot=\"show-all\"]'); if (hasSlottedShowAll !== this._hasSlottedShowAll) { this._hasSlottedShowAll = hasSlottedShowAll; } void this.updateComplete.then(() => { this.updateLayout(true); }); } render() { const { L, M, S } = this.computedLayout; const hasItems = this.effectiveItems && this.effectiveItems.length > 0; const hasSlottedShowAll = this._hasSlottedShowAll || !!this.querySelector('[slot=\"show-all\"]'); const showHeader = this.headerText || this.showAll || hasSlottedShowAll; let rightPadding = 128; if (this.layout === 'hero') { rightPadding = S + this.gap; } else if (this.layout === 'uncontained') { rightPadding = 16; } else { rightPadding = M + S + 2 * this.gap; } // Ensure the scroll track is long enough to allow the last item to scroll to the start (p = 0 focus). // Safety margin of 400px avoids fractional rounding/zoom-scale snapping blockages in Firefox. const W_c = this._containerWidth || this.getBoundingClientRect().width || 360; const minRightPaddingNeeded = W_c - L - this.padding + 400; if (rightPadding < minRightPaddingNeeded) { rightPadding = minRightPaddingNeeded; } let snapItemsCount = hasItems ? this._snapItemsCount : 0; let visualItems = this.effectiveItems; if (this.infinite && hasItems) { const V_mult = this._visualCardsMultiplier; visualItems = []; for (let i = 0; i < V_mult; i++) { visualItems = visualItems.concat(this.effectiveItems); } } const trackWidth = hasItems ? snapItemsCount * L + (snapItemsCount - 1) * this.gap : 0; const isScrollable = hasItems && this._scrollContainer && (this._scrollContainer.scrollWidth > this._scrollContainer.clientWidth); const showPrevArrow = this.infinite || (isScrollable && this._scrollContainer.scrollLeft > 5); const showNextArrow = this.infinite || (isScrollable && (this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5)); return html ` <div class=\"carousel-container\" style=\" --carousel-snap-width: ${L}px; --carousel-gap: ${this.gap}px; --carousel-padding: ${this.padding}px; --carousel-right-padding: ${rightPadding}px; --carousel-border-radius: ${this.borderRadius}px; --carousel-track-width: ${trackWidth}px; \"> <slot @slotchange=${this._handleSlotChange} style=\"display: none;\"></slot> ${showHeader ? html ` <div class=\"carousel-header\"> ${this.headerText ? html `<h3>${this.headerText}</h3>` : ''} <slot name=\"show-all\" @slotchange=${this._handleShowAllSlotChange} @click=${this._handleShowAllClick}> ${this.showAll ? html ` <a href=\"#\" class=\"show-all-link\"> ${this.showAllText} </a> ` : ''} </slot> </div> ` : ''} <div class=\"carousel-viewport\"> ${!this.hideNav ? html ` <button class=\"nav-button prev ${showPrevArrow ? 'visible' : ''}\" @click=${this._scrollPrevious} aria-label=\"Previous items\" > <span class=\"material-symbols\">chevron_left</span> </button> ` : ''} <div class=\"scroll-container\" @scroll=${this._scheduleLayout} @mousedown=${this._handleMouseDown} @mousemove=${this._handleMouseMove} @mouseup=${this._handleMouseUp} @mouseenter=${this._handleMouseEnter} @mouseleave=${this._handleMouseLeave} @touchstart=${this._handleTouchStart} @touchmove=${this._handleTouchMove} @touchend=${this._handleTouchEnd} @wheel=${this._handleWheel} > <div class=\"snap-track\"> ${hasItems ? Array.from({ length: snapItemsCount }).map(() => html `<div class=\"snap-item\"></div>`) : ''} </div> <div class=\"visual-track\"> ${hasItems ? visualItems.map((item, idx) => html ` <div class=\"card\" @click=${(e) => this._handleCardClick(e, item, idx)} > ${item.href ? html ` <a href=\"${item.href}\" target=\"${item.target || '_self'}\" style=\"text-decoration:none; color:inherit;\" draggable=\"false\"> <div class=\"img-parallax-container\"> <img src=\"${item.img}\" alt=\"${item.title}\" draggable=\"false\" loading=\"lazy\" /> </div> <h2 class=\"card-title\">${item.title}</h2> </a> ` : html ` <div class=\"img-parallax-container\"> <img src=\"${item.img}\" alt=\"${item.title}\" draggable=\"false\" loading=\"lazy\" /> </div> <h2 class=\"card-title\">${item.title}</h2> `} </div> `) : ''} </div> </div> ${!this.hideNav ? html ` <button class=\"nav-button next ${showNextArrow ? 'visible' : ''}\" @click=${this._scrollNext} aria-label=\"Next items\" > <span class=\"material-symbols\">chevron_right</span> </button> ` : ''} </div> </div> `; } }",
1459
1453
  "description": "Material Design 3 Expressive Carousel component.\n\nCarousels display a collection of related items in a scrollable, horizontal list.\nThey allow users to quickly browse through items like images, cards, or products.\n\n**M3 spec reference:** `m3-docs/components/carousel/specs.md`\n\n**Layout variants:**\n- `multi-browse` (default) — Shows a mix of large, medium, and small (peeking)\n items. Best for exploring a large number of items.\n- `hero` — Focuses on one large primary item while showing a sliver of the\n next item. Best for featuring important content.\n- `uncontained` — Standard layout where all items have the same width and\n bleed off the edges of the container.\n\n**Animation & Gestures:**\nThis component uses GSAP for smooth drag, flick, and snap animations,\nmirroring the high-fidelity M3 Expressive motion specs. It handles touch\ngestures for mobile and mouse-drag for desktop.\n\n**Auto-sizing (`auto` mode):**\nWhen `auto=true` (default), the carousel measures its own width and\ndynamically calculates the optimal sizes for large, medium, and small items\nbased on the active `layout` to ensure they fit perfectly without awkward gaps\nor clipping at the edges."
1460
1454
  }
1461
1455
  ],
@@ -3054,6 +3048,12 @@
3054
3048
  }
3055
3049
  ]
3056
3050
  },
3051
+ {
3052
+ "kind": "javascript-module",
3053
+ "path": "src/actions/index.ts",
3054
+ "declarations": [],
3055
+ "exports": []
3056
+ },
3057
3057
  {
3058
3058
  "kind": "javascript-module",
3059
3059
  "path": "src/components/index.ts",
@@ -4858,6 +4858,30 @@
4858
4858
  },
4859
4859
  "default": "false"
4860
4860
  },
4861
+ {
4862
+ "kind": "field",
4863
+ "name": "infinite",
4864
+ "type": {
4865
+ "text": "boolean"
4866
+ },
4867
+ "default": "false"
4868
+ },
4869
+ {
4870
+ "kind": "field",
4871
+ "name": "autoplay",
4872
+ "type": {
4873
+ "text": "boolean"
4874
+ },
4875
+ "default": "false"
4876
+ },
4877
+ {
4878
+ "kind": "field",
4879
+ "name": "autoplayInterval",
4880
+ "type": {
4881
+ "text": "number"
4882
+ },
4883
+ "default": "3000"
4884
+ },
4861
4885
  {
4862
4886
  "kind": "field",
4863
4887
  "name": "_containerWidth",
@@ -5026,6 +5050,24 @@
5026
5050
  "privacy": "private",
5027
5051
  "default": "false"
5028
5052
  },
5053
+ {
5054
+ "kind": "field",
5055
+ "name": "_infiniteInitialized",
5056
+ "type": {
5057
+ "text": "boolean"
5058
+ },
5059
+ "privacy": "private",
5060
+ "default": "false"
5061
+ },
5062
+ {
5063
+ "kind": "field",
5064
+ "name": "_autoplayTimer",
5065
+ "type": {
5066
+ "text": "any"
5067
+ },
5068
+ "privacy": "private",
5069
+ "default": "null"
5070
+ },
5029
5071
  {
5030
5072
  "kind": "field",
5031
5073
  "name": "_cardSetX",
@@ -5144,6 +5186,49 @@
5144
5186
  }
5145
5187
  ]
5146
5188
  },
5189
+ {
5190
+ "kind": "field",
5191
+ "name": "_visualCardsMultiplier",
5192
+ "privacy": "private",
5193
+ "readonly": true
5194
+ },
5195
+ {
5196
+ "kind": "field",
5197
+ "name": "_snapSetsCount",
5198
+ "privacy": "private",
5199
+ "readonly": true
5200
+ },
5201
+ {
5202
+ "kind": "field",
5203
+ "name": "_snapItemsCount",
5204
+ "privacy": "private",
5205
+ "readonly": true
5206
+ },
5207
+ {
5208
+ "kind": "method",
5209
+ "name": "_checkInfiniteWrap",
5210
+ "privacy": "private"
5211
+ },
5212
+ {
5213
+ "kind": "method",
5214
+ "name": "_startAutoplay",
5215
+ "privacy": "private"
5216
+ },
5217
+ {
5218
+ "kind": "method",
5219
+ "name": "_stopAutoplay",
5220
+ "privacy": "private"
5221
+ },
5222
+ {
5223
+ "kind": "method",
5224
+ "name": "_handleMouseEnter",
5225
+ "privacy": "private"
5226
+ },
5227
+ {
5228
+ "kind": "method",
5229
+ "name": "_handleMouseLeave",
5230
+ "privacy": "private"
5231
+ },
5147
5232
  {
5148
5233
  "kind": "method",
5149
5234
  "name": "_handleMouseDown",
@@ -89,6 +89,9 @@ export declare class MoniCarousel extends MoniElement {
89
89
  showAllText: string;
90
90
  headerText: string;
91
91
  hideNav: boolean;
92
+ infinite: boolean;
93
+ autoplay: boolean;
94
+ autoplayInterval: number;
92
95
  private _containerWidth;
93
96
  private _slottedItems;
94
97
  private _hasSlottedShowAll;
@@ -108,6 +111,8 @@ export declare class MoniCarousel extends MoniElement {
108
111
  private _tickerTarget;
109
112
  private _tickerCurrent;
110
113
  private _isTicking;
114
+ private _infiniteInitialized;
115
+ private _autoplayTimer;
111
116
  private _cardSetX;
112
117
  private _cardSetWidth;
113
118
  private _imgSetX;
@@ -138,6 +143,14 @@ export declare class MoniCarousel extends MoniElement {
138
143
  private _scheduleLayout;
139
144
  updateLayout: (instant?: boolean) => void;
140
145
  private _applyLayout;
146
+ private get _visualCardsMultiplier();
147
+ private get _snapSetsCount();
148
+ private get _snapItemsCount();
149
+ private _checkInfiniteWrap;
150
+ private _startAutoplay;
151
+ private _stopAutoplay;
152
+ private _handleMouseEnter;
153
+ private _handleMouseLeave;
141
154
  private _handleMouseDown;
142
155
  private _handleMouseMove;
143
156
  private _handleMouseUp;
@@ -1 +1 @@
1
- {"version":3,"file":"moni-carousel.d.ts","sourceRoot":"","sources":["../../src/components/moni-carousel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAAE,WAAW,EAAgB,MAAM,kBAAkB,CAAC;AAG7D;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,qBACa,YAAa,SAAQ,WAAW;IACjB,KAAK,EAAE,YAAY,EAAE,CAAM;IACzB,MAAM,EAAE,cAAc,GAAG,MAAM,GAAG,aAAa,CAAkB;IAClD,IAAI,UAAQ;IACF,UAAU,SAAO;IAChB,WAAW,SAAM;IAClB,UAAU,SAAM;IAC1C,GAAG,SAAK;IACR,OAAO,SAAM;IACe,YAAY,SAAM;IACtB,OAAO,UAAS;IAC1B,WAAW,SAAc;IAC3B,UAAU,SAAM;IACJ,OAAO,UAAS;IAE3D,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,kBAAkB,CAAS;IAE5C,IAAI,cAAc,IAAI,YAAY,EAAE,CAEnC;IAE2B,OAAO,CAAC,gBAAgB,CAAkB;IACnD,OAAO,CAAC,MAAM,CAA8B;IAE/D,OAAO,CAAC,eAAe,CAA+B;IACtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAAS;IAG1B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,UAAU,CAAS;IAG3B,OAAO,CAAC,SAAS,CAAmC;IACpD,OAAO,CAAC,aAAa,CAAmC;IACxD,OAAO,CAAC,QAAQ,CAAmC;IACnD,OAAO,CAAC,gBAAgB,CAAmC;IAE3D,OAAgB,MAAM,4BAiOpB;IAEO,iBAAiB;IAwBjB,oBAAoB;IAoBpB,YAAY;IAaZ,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC;IAwBpD,IAAI,cAAc;;;;;MAwEjB;IAED,IAAI,QAAQ,WAEX;IAED,OAAO,CAAC,cAAc;IA+EtB;;;OAGG;IACH,OAAO,CAAC,kBAAkB;IAwB1B;;;OAGG;IACH,OAAO,CAAC,KAAK,CAaX;IAEF,OAAO,CAAC,eAAe,CAOrB;IAEF,YAAY,GAAI,iBAAe,UAmB7B;IAEF,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,cAAc;IAwCtB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,YAAY;IAoCpB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,mBAAmB;IAa3B,OAAO,CAAC,wBAAwB;IAYhC,OAAO,CAAC,iBAAiB;IA4ChB,MAAM;CAqIf;AAED,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,eAAe,EAAE,YAAY,CAAC;KAC9B;CACD"}
1
+ {"version":3,"file":"moni-carousel.d.ts","sourceRoot":"","sources":["../../src/components/moni-carousel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAAE,WAAW,EAAgB,MAAM,kBAAkB,CAAC;AAG7D;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,qBACa,YAAa,SAAQ,WAAW;IACjB,KAAK,EAAE,YAAY,EAAE,CAAM;IACzB,MAAM,EAAE,cAAc,GAAG,MAAM,GAAG,aAAa,CAAkB;IAClD,IAAI,UAAQ;IACF,UAAU,SAAO;IAChB,WAAW,SAAM;IAClB,UAAU,SAAM;IAC1C,GAAG,SAAK;IACR,OAAO,SAAM;IACe,YAAY,SAAM;IACtB,OAAO,UAAS;IAC1B,WAAW,SAAc;IAC3B,UAAU,SAAM;IACJ,OAAO,UAAS;IACxB,QAAQ,UAAS;IACjB,QAAQ,UAAS;IACD,gBAAgB,SAAQ;IAE3E,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,kBAAkB,CAAS;IAE5C,IAAI,cAAc,IAAI,YAAY,EAAE,CAEnC;IAE2B,OAAO,CAAC,gBAAgB,CAAkB;IACnD,OAAO,CAAC,MAAM,CAA8B;IAE/D,OAAO,CAAC,eAAe,CAA+B;IACtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAAS;IAG1B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,UAAU,CAAS;IAG3B,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,cAAc,CAAa;IAGnC,OAAO,CAAC,SAAS,CAAmC;IACpD,OAAO,CAAC,aAAa,CAAmC;IACxD,OAAO,CAAC,QAAQ,CAAmC;IACnD,OAAO,CAAC,gBAAgB,CAAmC;IAE3D,OAAgB,MAAM,4BAiOpB;IAEO,iBAAiB;IAwBjB,oBAAoB;IAoBpB,YAAY;IAiBZ,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC;IAwCpD,IAAI,cAAc;;;;;MAwEjB;IAED,IAAI,QAAQ,WAEX;IAED,OAAO,CAAC,cAAc;IA+EtB;;;OAGG;IACH,OAAO,CAAC,kBAAkB;IAwB1B;;;OAGG;IACH,OAAO,CAAC,KAAK,CAcX;IAEF,OAAO,CAAC,eAAe,CAOrB;IAEF,YAAY,GAAI,iBAAe,UA+C7B;IAEF,OAAO,CAAC,YAAY;IAmCpB,OAAO,KAAK,sBAAsB,GAKjC;IAED,OAAO,KAAK,cAAc,GASzB;IAED,OAAO,KAAK,eAAe,GAQ1B;IAED,OAAO,CAAC,kBAAkB;IAiD1B,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,iBAAiB;IAGzB,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,gBAAgB;IAexB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,cAAc;IAiDtB,OAAO,CAAC,iBAAiB;IAgBzB,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,YAAY;IAoCpB,OAAO,CAAC,gBAAgB;IAiBxB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,mBAAmB;IAa3B,OAAO,CAAC,wBAAwB;IAYhC,OAAO,CAAC,iBAAiB;IA4ChB,MAAM;CAkJf;AAED,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,qBAAqB;QAC9B,eAAe,EAAE,YAAY,CAAC;KAC9B;CACD"}
@@ -87,6 +87,9 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
87
87
  this.showAllText = 'Show all';
88
88
  this.headerText = '';
89
89
  this.hideNav = false;
90
+ this.infinite = false;
91
+ this.autoplay = false;
92
+ this.autoplayInterval = 3000;
90
93
  this._containerWidth = 0;
91
94
  this._slottedItems = [];
92
95
  this._hasSlottedShowAll = false;
@@ -104,6 +107,9 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
104
107
  this._tickerTarget = 0;
105
108
  this._tickerCurrent = 0;
106
109
  this._isTicking = false;
110
+ // Infinite & Autoplay state
111
+ this._infiniteInitialized = false;
112
+ this._autoplayTimer = null;
107
113
  // quickSetter caches — avoid gsap.set() overhead per frame
108
114
  this._cardSetX = [];
109
115
  this._cardSetWidth = [];
@@ -122,6 +128,7 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
122
128
  this._tickerCurrent = this._tickerTarget;
123
129
  gsap.ticker.remove(this._tick);
124
130
  this._isTicking = false;
131
+ this._checkInfiniteWrap();
125
132
  }
126
133
  this._applyLayout(this._tickerCurrent);
127
134
  };
@@ -141,6 +148,30 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
141
148
  if (this._cardSetX.length !== this._cards.length) {
142
149
  this._buildQuickSetters();
143
150
  }
151
+ if (this.infinite && !this._infiniteInitialized && this.effectiveItems.length > 0) {
152
+ const C = this.effectiveItems.length;
153
+ const V = C * this._visualCardsMultiplier;
154
+ const K = this._snapSetsCount;
155
+ const centerSetIndex = Math.floor(K / 2);
156
+ // Wait for the browser to render the new items so scrollWidth is updated
157
+ // A double-rAF or setTimeout ensures the layout engine has processed the new snap-items
158
+ requestAnimationFrame(() => {
159
+ setTimeout(() => {
160
+ if (this._scrollContainer) {
161
+ // Force layout calculation
162
+ this._scrollContainer.scrollWidth;
163
+ this._scrollContainer.scrollLeft = centerSetIndex * V * this.itemSize;
164
+ // If the browser still clamped it, it means it's not ready.
165
+ // But with setTimeout it should be fully ready.
166
+ // Force the visual ticker to match instantly to avoid an animation zoom from 0
167
+ this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
168
+ this._tickerCurrent = this._tickerTarget;
169
+ this._applyLayout(this._tickerCurrent);
170
+ }
171
+ }, 20);
172
+ });
173
+ this._infiniteInitialized = true;
174
+ }
144
175
  this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
145
176
  if (instant || (!this._isTicking && this._tickerCurrent === 0)) {
146
177
  this._tickerCurrent = this._tickerTarget;
@@ -434,6 +465,9 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
434
465
  }
435
466
  this._buildQuickSetters();
436
467
  this.updateLayout(true);
468
+ if (this.autoplay) {
469
+ this._startAutoplay();
470
+ }
437
471
  }
438
472
  updated(changedProperties) {
439
473
  super.updated(changedProperties);
@@ -445,15 +479,30 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
445
479
  changedProperties.has('padding') ||
446
480
  changedProperties.has('items') ||
447
481
  changedProperties.has('hideNav') ||
482
+ changedProperties.has('infinite') ||
483
+ changedProperties.has('autoplay') ||
484
+ changedProperties.has('autoplayInterval') ||
448
485
  changedProperties.has('_containerWidth') ||
449
486
  changedProperties.has('_slottedItems') ||
450
487
  changedProperties.has('_hasSlottedShowAll')) {
488
+ if (changedProperties.has('infinite') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
489
+ this._infiniteInitialized = false;
490
+ }
451
491
  if (changedProperties.has('layout') && this._scrollContainer) {
452
492
  this._scrollContainer.scrollLeft = 0;
493
+ this._infiniteInitialized = false;
453
494
  this._scrollContainer.style.scrollSnapType =
454
495
  this.layout === 'uncontained' ? 'none' : 'x mandatory';
455
496
  }
456
497
  this.updateLayout(true);
498
+ if (changedProperties.has('autoplay') || changedProperties.has('autoplayInterval') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
499
+ if (this.autoplay) {
500
+ this._startAutoplay();
501
+ }
502
+ else {
503
+ this._stopAutoplay();
504
+ }
505
+ }
457
506
  }
458
507
  }
459
508
  get computedLayout() {
@@ -639,7 +688,21 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
639
688
  const isMobile = this._isMobile;
640
689
  const cardCount = this._cards.length;
641
690
  for (let i = 0; i < cardCount; i++) {
642
- const p = i - t;
691
+ let p = i - t;
692
+ if (this.infinite) {
693
+ // Modulo wrap so the cards perfectly loop around the center
694
+ p = i - (t % cardCount);
695
+ if (p > cardCount / 2)
696
+ p -= cardCount;
697
+ if (p < -cardCount / 2)
698
+ p += cardCount;
699
+ }
700
+ // Optimization: if the card is far off-screen, hide it to save rendering work
701
+ if (p < -15 || p > 15) {
702
+ this._cardSetX[i](-9999);
703
+ this._titleSetOpacity[i](0);
704
+ continue;
705
+ }
643
706
  const layout = this._getCardLayout(p);
644
707
  // Use quickSetters — ~3x faster than gsap.set() per call
645
708
  this._cardSetX[i](layout.x);
@@ -652,8 +715,97 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
652
715
  this._titleSetOpacity[i](layout.opacity);
653
716
  }
654
717
  }
718
+ get _visualCardsMultiplier() {
719
+ if (!this.infinite || this.effectiveItems.length === 0)
720
+ return 1;
721
+ const C = this.effectiveItems.length;
722
+ // We need at least ~15 cards to fill the screen seamlessly for small datasets
723
+ return Math.max(1, Math.ceil(15 / C));
724
+ }
725
+ get _snapSetsCount() {
726
+ if (!this.infinite || this.effectiveItems.length === 0)
727
+ return 1;
728
+ const C = this.effectiveItems.length;
729
+ const V = C * this._visualCardsMultiplier;
730
+ // We want enough snap items so the user NEVER reaches the end in a single fast swipe.
731
+ // A total of ~1000 items is extremely cheap as empty divs, and provides a massive buffer.
732
+ let K = Math.ceil(1000 / V);
733
+ if (K % 2 === 0)
734
+ K += 1; // Keep it odd for a perfect center
735
+ return Math.max(3, K);
736
+ }
737
+ get _snapItemsCount() {
738
+ if (this.infinite) {
739
+ const C = this.effectiveItems.length;
740
+ const V_mult = this._visualCardsMultiplier;
741
+ const K = this._snapSetsCount;
742
+ return K * (C * V_mult);
743
+ }
744
+ return this.effectiveItems.length;
745
+ }
746
+ _checkInfiniteWrap() {
747
+ if (!this.infinite || this.effectiveItems.length === 0 || !this._scrollContainer)
748
+ return;
749
+ const C = this.effectiveItems.length;
750
+ const V = C * this._visualCardsMultiplier;
751
+ const K = this._snapSetsCount;
752
+ const centerSetIndex = Math.floor(K / 2);
753
+ // The scroll track is divided into K sets, each containing V items.
754
+ const setWidth = V * this.itemSize;
755
+ const currentScroll = this._scrollContainer.scrollLeft;
756
+ const currentSet = Math.floor(currentScroll / setWidth);
757
+ if (currentSet !== centerSetIndex) {
758
+ const delta = (centerSetIndex - currentSet) * setWidth;
759
+ const oldSnap = this._scrollContainer.style.scrollSnapType;
760
+ const oldBehavior = this._scrollContainer.style.scrollBehavior;
761
+ // Temporarily disable snap and smooth behavior so the wrap is instantaneous
762
+ this._scrollContainer.style.scrollSnapType = 'none';
763
+ this._scrollContainer.style.scrollBehavior = 'auto';
764
+ this._scrollContainer.scrollLeft += delta;
765
+ // INSTANTLY update the GSAP visual ticker so it doesn't animate the jump
766
+ this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
767
+ this._tickerCurrent = this._tickerTarget;
768
+ this._applyLayout(this._tickerCurrent);
769
+ // Restore original scroll behavior
770
+ this._scrollContainer.style.scrollBehavior = oldBehavior;
771
+ if (this._isDown) {
772
+ this._scrollLeftStart += delta;
773
+ }
774
+ if (oldSnap && oldSnap !== 'none') {
775
+ // Re-enable snap on the next frame to avoid jumping
776
+ requestAnimationFrame(() => {
777
+ if (this._scrollContainer && !this._isDown) {
778
+ this._scrollContainer.style.scrollSnapType = oldSnap;
779
+ }
780
+ });
781
+ }
782
+ }
783
+ }
784
+ _startAutoplay() {
785
+ this._stopAutoplay();
786
+ if (this.autoplay && this.effectiveItems.length > 0) {
787
+ this._autoplayTimer = setInterval(() => {
788
+ // We removed _isInteracting to make autoplay more robust. It only pauses on active drag.
789
+ if (!this._isDown) {
790
+ this._scrollNext();
791
+ }
792
+ }, this.autoplayInterval);
793
+ }
794
+ }
795
+ _stopAutoplay() {
796
+ if (this._autoplayTimer) {
797
+ clearInterval(this._autoplayTimer);
798
+ this._autoplayTimer = null;
799
+ }
800
+ }
801
+ _handleMouseEnter() {
802
+ }
803
+ _handleMouseLeave() {
804
+ this._handleMouseUp();
805
+ }
655
806
  _handleMouseDown(e) {
656
807
  gsap.killTweensOf(this._scrollContainer);
808
+ this._checkInfiniteWrap();
657
809
  this._isDown = true;
658
810
  this._draggedDistance = 0;
659
811
  this._scrollContainer.style.scrollBehavior = 'auto';
@@ -690,11 +842,20 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
690
842
  }
691
843
  // Dynamic momentum: stronger swipes have a much higher multiplier
692
844
  const momentumMultiplier = 350 + Math.abs(this._velocity) * 300;
693
- const projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier;
845
+ let projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier;
846
+ // Cap momentum throw to prevent hitting infinite wrap edges on fast swipes
847
+ const maxThrowDist = 15 * this.itemSize;
848
+ if (projectedScrollLeft < this._scrollContainer.scrollLeft - maxThrowDist) {
849
+ projectedScrollLeft = this._scrollContainer.scrollLeft - maxThrowDist;
850
+ }
851
+ else if (projectedScrollLeft > this._scrollContainer.scrollLeft + maxThrowDist) {
852
+ projectedScrollLeft = this._scrollContainer.scrollLeft + maxThrowDist;
853
+ }
694
854
  if (this.layout !== 'uncontained') {
695
855
  this._scrollContainer.style.scrollSnapType = 'none';
696
856
  let targetIndex = Math.round(projectedScrollLeft / this.itemSize);
697
- targetIndex = Math.max(0, Math.min(this.effectiveItems.length - 1, targetIndex));
857
+ const maxIndex = this.effectiveItems.length > 0 ? this._snapItemsCount - 1 : 0;
858
+ targetIndex = Math.max(0, Math.min(maxIndex, targetIndex));
698
859
  const snapPoint = targetIndex * this.itemSize;
699
860
  // Dynamic duration: longer distance = more time, max 0.85s for faster feeling
700
861
  const currentIndex = Math.round(this._scrollContainer.scrollLeft / this.itemSize);
@@ -715,6 +876,7 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
715
876
  }
716
877
  _handleTouchStart(e) {
717
878
  gsap.killTweensOf(this._scrollContainer);
879
+ this._checkInfiniteWrap();
718
880
  this._isDown = true;
719
881
  this._draggedDistance = 0;
720
882
  this._scrollContainer.style.scrollBehavior = 'auto';
@@ -770,7 +932,7 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
770
932
  if (this._wheelTimeout)
771
933
  clearTimeout(this._wheelTimeout);
772
934
  this._wheelTimeout = setTimeout(() => {
773
- if (this.layout !== 'uncontained') {
935
+ if (this.layout !== 'uncontained' && !this._isDown) {
774
936
  this._scrollContainer.style.scrollSnapType = 'x mandatory';
775
937
  }
776
938
  }, 150);
@@ -782,20 +944,20 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
782
944
  e.stopPropagation();
783
945
  return;
784
946
  }
947
+ const originalIndex = index % this.effectiveItems.length;
785
948
  this.dispatchEvent(new CustomEvent('item-click', {
786
- detail: { item, index },
949
+ detail: { item, index: originalIndex },
787
950
  bubbles: true,
788
951
  composed: true
789
952
  }));
790
953
  }
791
954
  _scrollPrevious() {
792
- const target = Math.max(0, this._scrollContainer.scrollLeft - this.itemSize);
955
+ const target = this._scrollContainer.scrollLeft - this.itemSize;
793
956
  this._scrollContainer.style.scrollBehavior = 'smooth';
794
957
  this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
795
958
  }
796
959
  _scrollNext() {
797
- const maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth;
798
- const target = Math.min(maxScroll, this._scrollContainer.scrollLeft + this.itemSize);
960
+ const target = this._scrollContainer.scrollLeft + this.itemSize;
799
961
  this._scrollContainer.style.scrollBehavior = 'smooth';
800
962
  this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
801
963
  }
@@ -883,14 +1045,23 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
883
1045
  if (rightPadding < minRightPaddingNeeded) {
884
1046
  rightPadding = minRightPaddingNeeded;
885
1047
  }
1048
+ let snapItemsCount = hasItems ? this._snapItemsCount : 0;
1049
+ let visualItems = this.effectiveItems;
1050
+ if (this.infinite && hasItems) {
1051
+ const V_mult = this._visualCardsMultiplier;
1052
+ visualItems = [];
1053
+ for (let i = 0; i < V_mult; i++) {
1054
+ visualItems = visualItems.concat(this.effectiveItems);
1055
+ }
1056
+ }
886
1057
  const trackWidth = hasItems
887
- ? this.effectiveItems.length * L + (this.effectiveItems.length - 1) * this.gap
1058
+ ? snapItemsCount * L + (snapItemsCount - 1) * this.gap
888
1059
  : 0;
889
1060
  const isScrollable = hasItems && this._scrollContainer &&
890
1061
  (this._scrollContainer.scrollWidth > this._scrollContainer.clientWidth);
891
- const showPrevArrow = isScrollable && this._scrollContainer.scrollLeft > 5;
892
- const showNextArrow = isScrollable &&
893
- (this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5);
1062
+ const showPrevArrow = this.infinite || (isScrollable && this._scrollContainer.scrollLeft > 5);
1063
+ const showNextArrow = this.infinite || (isScrollable &&
1064
+ (this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5));
894
1065
  return html `
895
1066
  <div class="carousel-container" style="
896
1067
  --carousel-snap-width: ${L}px;
@@ -937,34 +1108,35 @@ let MoniCarousel = class MoniCarousel extends MoniElement {
937
1108
  @mousedown=${this._handleMouseDown}
938
1109
  @mousemove=${this._handleMouseMove}
939
1110
  @mouseup=${this._handleMouseUp}
940
- @mouseleave=${this._handleMouseUp}
1111
+ @mouseenter=${this._handleMouseEnter}
1112
+ @mouseleave=${this._handleMouseLeave}
941
1113
  @touchstart=${this._handleTouchStart}
942
1114
  @touchmove=${this._handleTouchMove}
943
1115
  @touchend=${this._handleTouchEnd}
944
1116
  @wheel=${this._handleWheel}
945
1117
  >
946
1118
  <div class="snap-track">
947
- ${hasItems ? this.effectiveItems.map(() => html `<div class="snap-item"></div>`) : ''}
1119
+ ${hasItems ? Array.from({ length: snapItemsCount }).map(() => html `<div class="snap-item"></div>`) : ''}
948
1120
  </div>
949
1121
  <div class="visual-track">
950
1122
  ${hasItems
951
- ? this.effectiveItems.map((item, idx) => html `
1123
+ ? visualItems.map((item, idx) => html `
952
1124
  <div
953
1125
  class="card"
954
1126
  @click=${(e) => this._handleCardClick(e, item, idx)}
955
1127
  >
956
1128
  ${item.href
957
1129
  ? html `
958
- <a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;">
1130
+ <a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;" draggable="false">
959
1131
  <div class="img-parallax-container">
960
- <img src="${item.img}" alt="${item.title}" draggable="false" />
1132
+ <img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
961
1133
  </div>
962
1134
  <h2 class="card-title">${item.title}</h2>
963
1135
  </a>
964
1136
  `
965
1137
  : html `
966
1138
  <div class="img-parallax-container">
967
- <img src="${item.img}" alt="${item.title}" draggable="false" />
1139
+ <img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
968
1140
  </div>
969
1141
  <h2 class="card-title">${item.title}</h2>
970
1142
  `}
@@ -1029,6 +1201,15 @@ __decorate([
1029
1201
  __decorate([
1030
1202
  property({ type: Boolean, attribute: 'hide-nav' })
1031
1203
  ], MoniCarousel.prototype, "hideNav", void 0);
1204
+ __decorate([
1205
+ property({ type: Boolean, reflect: true })
1206
+ ], MoniCarousel.prototype, "infinite", void 0);
1207
+ __decorate([
1208
+ property({ type: Boolean, reflect: true })
1209
+ ], MoniCarousel.prototype, "autoplay", void 0);
1210
+ __decorate([
1211
+ property({ type: Number, attribute: 'autoplay-interval' })
1212
+ ], MoniCarousel.prototype, "autoplayInterval", void 0);
1032
1213
  __decorate([
1033
1214
  state()
1034
1215
  ], MoniCarousel.prototype, "_containerWidth", void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moni-labs/moni-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system unificado para Moni — Material Design 3 Expressive, mobile-first.",
@@ -96,6 +96,9 @@ export class MoniCarousel extends MoniElement {
96
96
  @property({ attribute: 'show-all-text' }) showAllText = 'Show all';
97
97
  @property({ attribute: 'header-text' }) headerText = '';
98
98
  @property({ type: Boolean, attribute: 'hide-nav' }) hideNav = false;
99
+ @property({ type: Boolean, reflect: true }) infinite = false;
100
+ @property({ type: Boolean, reflect: true }) autoplay = false;
101
+ @property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
99
102
 
100
103
  @state() private _containerWidth = 0;
101
104
  @state() private _slottedItems: CarouselItem[] = [];
@@ -124,6 +127,10 @@ export class MoniCarousel extends MoniElement {
124
127
  private _tickerCurrent = 0;
125
128
  private _isTicking = false;
126
129
 
130
+ // Infinite & Autoplay state
131
+ private _infiniteInitialized = false;
132
+ private _autoplayTimer: any = null;
133
+
127
134
  // quickSetter caches — avoid gsap.set() overhead per frame
128
135
  private _cardSetX: ((value: number) => void)[] = [];
129
136
  private _cardSetWidth: ((value: number) => void)[] = [];
@@ -412,6 +419,10 @@ export class MoniCarousel extends MoniElement {
412
419
  }
413
420
  this._buildQuickSetters();
414
421
  this.updateLayout(true);
422
+
423
+ if (this.autoplay) {
424
+ this._startAutoplay();
425
+ }
415
426
  }
416
427
 
417
428
  override updated(changedProperties: Map<string, any>) {
@@ -425,16 +436,32 @@ export class MoniCarousel extends MoniElement {
425
436
  changedProperties.has('padding') ||
426
437
  changedProperties.has('items') ||
427
438
  changedProperties.has('hideNav') ||
439
+ changedProperties.has('infinite') ||
440
+ changedProperties.has('autoplay') ||
441
+ changedProperties.has('autoplayInterval') ||
428
442
  changedProperties.has('_containerWidth') ||
429
443
  changedProperties.has('_slottedItems') ||
430
444
  changedProperties.has('_hasSlottedShowAll')
431
445
  ) {
446
+ if (changedProperties.has('infinite') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
447
+ this._infiniteInitialized = false;
448
+ }
449
+
432
450
  if (changedProperties.has('layout') && this._scrollContainer) {
433
451
  this._scrollContainer.scrollLeft = 0;
452
+ this._infiniteInitialized = false;
434
453
  this._scrollContainer.style.scrollSnapType =
435
454
  this.layout === 'uncontained' ? 'none' : 'x mandatory';
436
455
  }
437
456
  this.updateLayout(true);
457
+
458
+ if (changedProperties.has('autoplay') || changedProperties.has('autoplayInterval') || changedProperties.has('items') || changedProperties.has('_slottedItems')) {
459
+ if (this.autoplay) {
460
+ this._startAutoplay();
461
+ } else {
462
+ this._stopAutoplay();
463
+ }
464
+ }
438
465
  }
439
466
  }
440
467
 
@@ -637,6 +664,7 @@ export class MoniCarousel extends MoniElement {
637
664
  this._tickerCurrent = this._tickerTarget;
638
665
  gsap.ticker.remove(this._tick);
639
666
  this._isTicking = false;
667
+ this._checkInfiniteWrap();
640
668
  }
641
669
 
642
670
  this._applyLayout(this._tickerCurrent);
@@ -659,6 +687,34 @@ export class MoniCarousel extends MoniElement {
659
687
  this._buildQuickSetters();
660
688
  }
661
689
 
690
+ if (this.infinite && !this._infiniteInitialized && this.effectiveItems.length > 0) {
691
+ const C = this.effectiveItems.length;
692
+ const V = C * this._visualCardsMultiplier;
693
+ const K = this._snapSetsCount;
694
+ const centerSetIndex = Math.floor(K / 2);
695
+
696
+ // Wait for the browser to render the new items so scrollWidth is updated
697
+ // A double-rAF or setTimeout ensures the layout engine has processed the new snap-items
698
+ requestAnimationFrame(() => {
699
+ setTimeout(() => {
700
+ if (this._scrollContainer) {
701
+ // Force layout calculation
702
+ this._scrollContainer.scrollWidth;
703
+ this._scrollContainer.scrollLeft = centerSetIndex * V * this.itemSize;
704
+
705
+ // If the browser still clamped it, it means it's not ready.
706
+ // But with setTimeout it should be fully ready.
707
+
708
+ // Force the visual ticker to match instantly to avoid an animation zoom from 0
709
+ this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
710
+ this._tickerCurrent = this._tickerTarget;
711
+ this._applyLayout(this._tickerCurrent);
712
+ }
713
+ }, 20);
714
+ });
715
+ this._infiniteInitialized = true;
716
+ }
717
+
662
718
  this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
663
719
 
664
720
  if (instant || (!this._isTicking && this._tickerCurrent === 0)) {
@@ -676,7 +732,21 @@ export class MoniCarousel extends MoniElement {
676
732
  const isMobile = this._isMobile;
677
733
  const cardCount = this._cards.length;
678
734
  for (let i = 0; i < cardCount; i++) {
679
- const p = i - t;
735
+ let p = i - t;
736
+ if (this.infinite) {
737
+ // Modulo wrap so the cards perfectly loop around the center
738
+ p = i - (t % cardCount);
739
+ if (p > cardCount / 2) p -= cardCount;
740
+ if (p < -cardCount / 2) p += cardCount;
741
+ }
742
+
743
+ // Optimization: if the card is far off-screen, hide it to save rendering work
744
+ if (p < -15 || p > 15) {
745
+ this._cardSetX[i](-9999);
746
+ this._titleSetOpacity[i](0);
747
+ continue;
748
+ }
749
+
680
750
  const layout = this._getCardLayout(p);
681
751
 
682
752
  // Use quickSetters — ~3x faster than gsap.set() per call
@@ -693,8 +763,112 @@ export class MoniCarousel extends MoniElement {
693
763
  }
694
764
  }
695
765
 
766
+ private get _visualCardsMultiplier() {
767
+ if (!this.infinite || this.effectiveItems.length === 0) return 1;
768
+ const C = this.effectiveItems.length;
769
+ // We need at least ~15 cards to fill the screen seamlessly for small datasets
770
+ return Math.max(1, Math.ceil(15 / C));
771
+ }
772
+
773
+ private get _snapSetsCount() {
774
+ if (!this.infinite || this.effectiveItems.length === 0) return 1;
775
+ const C = this.effectiveItems.length;
776
+ const V = C * this._visualCardsMultiplier;
777
+ // We want enough snap items so the user NEVER reaches the end in a single fast swipe.
778
+ // A total of ~1000 items is extremely cheap as empty divs, and provides a massive buffer.
779
+ let K = Math.ceil(1000 / V);
780
+ if (K % 2 === 0) K += 1; // Keep it odd for a perfect center
781
+ return Math.max(3, K);
782
+ }
783
+
784
+ private get _snapItemsCount() {
785
+ if (this.infinite) {
786
+ const C = this.effectiveItems.length;
787
+ const V_mult = this._visualCardsMultiplier;
788
+ const K = this._snapSetsCount;
789
+ return K * (C * V_mult);
790
+ }
791
+ return this.effectiveItems.length;
792
+ }
793
+
794
+ private _checkInfiniteWrap() {
795
+ if (!this.infinite || this.effectiveItems.length === 0 || !this._scrollContainer) return;
796
+
797
+ const C = this.effectiveItems.length;
798
+ const V = C * this._visualCardsMultiplier;
799
+ const K = this._snapSetsCount;
800
+ const centerSetIndex = Math.floor(K / 2);
801
+
802
+ // The scroll track is divided into K sets, each containing V items.
803
+ const setWidth = V * this.itemSize;
804
+
805
+ const currentScroll = this._scrollContainer.scrollLeft;
806
+ const currentSet = Math.floor(currentScroll / setWidth);
807
+
808
+ if (currentSet !== centerSetIndex) {
809
+ const delta = (centerSetIndex - currentSet) * setWidth;
810
+
811
+ const oldSnap = this._scrollContainer.style.scrollSnapType;
812
+ const oldBehavior = this._scrollContainer.style.scrollBehavior;
813
+
814
+ // Temporarily disable snap and smooth behavior so the wrap is instantaneous
815
+ this._scrollContainer.style.scrollSnapType = 'none';
816
+ this._scrollContainer.style.scrollBehavior = 'auto';
817
+
818
+ this._scrollContainer.scrollLeft += delta;
819
+
820
+ // INSTANTLY update the GSAP visual ticker so it doesn't animate the jump
821
+ this._tickerTarget = this._scrollContainer.scrollLeft / this.itemSize;
822
+ this._tickerCurrent = this._tickerTarget;
823
+ this._applyLayout(this._tickerCurrent);
824
+
825
+ // Restore original scroll behavior
826
+ this._scrollContainer.style.scrollBehavior = oldBehavior;
827
+
828
+ if (this._isDown) {
829
+ this._scrollLeftStart += delta;
830
+ }
831
+
832
+ if (oldSnap && oldSnap !== 'none') {
833
+ // Re-enable snap on the next frame to avoid jumping
834
+ requestAnimationFrame(() => {
835
+ if (this._scrollContainer && !this._isDown) {
836
+ this._scrollContainer.style.scrollSnapType = oldSnap;
837
+ }
838
+ });
839
+ }
840
+ }
841
+ }
842
+
843
+ private _startAutoplay() {
844
+ this._stopAutoplay();
845
+ if (this.autoplay && this.effectiveItems.length > 0) {
846
+ this._autoplayTimer = setInterval(() => {
847
+ // We removed _isInteracting to make autoplay more robust. It only pauses on active drag.
848
+ if (!this._isDown) {
849
+ this._scrollNext();
850
+ }
851
+ }, this.autoplayInterval) as unknown as number;
852
+ }
853
+ }
854
+
855
+ private _stopAutoplay() {
856
+ if (this._autoplayTimer) {
857
+ clearInterval(this._autoplayTimer);
858
+ this._autoplayTimer = null;
859
+ }
860
+ }
861
+
862
+ private _handleMouseEnter() {
863
+ }
864
+
865
+ private _handleMouseLeave() {
866
+ this._handleMouseUp();
867
+ }
868
+
696
869
  private _handleMouseDown(e: MouseEvent) {
697
870
  gsap.killTweensOf(this._scrollContainer);
871
+ this._checkInfiniteWrap();
698
872
  this._isDown = true;
699
873
  this._draggedDistance = 0;
700
874
  this._scrollContainer.style.scrollBehavior = 'auto';
@@ -736,13 +910,22 @@ export class MoniCarousel extends MoniElement {
736
910
 
737
911
  // Dynamic momentum: stronger swipes have a much higher multiplier
738
912
  const momentumMultiplier = 350 + Math.abs(this._velocity) * 300;
739
- const projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier;
913
+ let projectedScrollLeft = this._scrollContainer.scrollLeft - this._velocity * momentumMultiplier;
914
+
915
+ // Cap momentum throw to prevent hitting infinite wrap edges on fast swipes
916
+ const maxThrowDist = 15 * this.itemSize;
917
+ if (projectedScrollLeft < this._scrollContainer.scrollLeft - maxThrowDist) {
918
+ projectedScrollLeft = this._scrollContainer.scrollLeft - maxThrowDist;
919
+ } else if (projectedScrollLeft > this._scrollContainer.scrollLeft + maxThrowDist) {
920
+ projectedScrollLeft = this._scrollContainer.scrollLeft + maxThrowDist;
921
+ }
740
922
 
741
923
  if (this.layout !== 'uncontained') {
742
924
  this._scrollContainer.style.scrollSnapType = 'none';
743
925
 
744
926
  let targetIndex = Math.round(projectedScrollLeft / this.itemSize);
745
- targetIndex = Math.max(0, Math.min(this.effectiveItems.length - 1, targetIndex));
927
+ const maxIndex = this.effectiveItems.length > 0 ? this._snapItemsCount - 1 : 0;
928
+ targetIndex = Math.max(0, Math.min(maxIndex, targetIndex));
746
929
  const snapPoint = targetIndex * this.itemSize;
747
930
 
748
931
  // Dynamic duration: longer distance = more time, max 0.85s for faster feeling
@@ -767,6 +950,7 @@ export class MoniCarousel extends MoniElement {
767
950
 
768
951
  private _handleTouchStart(e: TouchEvent) {
769
952
  gsap.killTweensOf(this._scrollContainer);
953
+ this._checkInfiniteWrap();
770
954
  this._isDown = true;
771
955
  this._draggedDistance = 0;
772
956
  this._scrollContainer.style.scrollBehavior = 'auto';
@@ -831,7 +1015,7 @@ export class MoniCarousel extends MoniElement {
831
1015
  // Restablecer el snap después de un pequeño retraso
832
1016
  if ((this as any)._wheelTimeout) clearTimeout((this as any)._wheelTimeout);
833
1017
  (this as any)._wheelTimeout = setTimeout(() => {
834
- if (this.layout !== 'uncontained') {
1018
+ if (this.layout !== 'uncontained' && !this._isDown) {
835
1019
  this._scrollContainer.style.scrollSnapType = 'x mandatory';
836
1020
  }
837
1021
  }, 150);
@@ -845,9 +1029,10 @@ export class MoniCarousel extends MoniElement {
845
1029
  return;
846
1030
  }
847
1031
 
1032
+ const originalIndex = index % this.effectiveItems.length;
848
1033
  this.dispatchEvent(
849
1034
  new CustomEvent('item-click', {
850
- detail: { item, index },
1035
+ detail: { item, index: originalIndex },
851
1036
  bubbles: true,
852
1037
  composed: true
853
1038
  })
@@ -855,14 +1040,13 @@ export class MoniCarousel extends MoniElement {
855
1040
  }
856
1041
 
857
1042
  private _scrollPrevious() {
858
- const target = Math.max(0, this._scrollContainer.scrollLeft - this.itemSize);
1043
+ const target = this._scrollContainer.scrollLeft - this.itemSize;
859
1044
  this._scrollContainer.style.scrollBehavior = 'smooth';
860
1045
  this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
861
1046
  }
862
1047
 
863
1048
  private _scrollNext() {
864
- const maxScroll = this._scrollContainer.scrollWidth - this._scrollContainer.clientWidth;
865
- const target = Math.min(maxScroll, this._scrollContainer.scrollLeft + this.itemSize);
1049
+ const target = this._scrollContainer.scrollLeft + this.itemSize;
866
1050
  this._scrollContainer.style.scrollBehavior = 'smooth';
867
1051
  this._scrollContainer.scrollTo({ left: target, behavior: 'smooth' });
868
1052
  }
@@ -959,15 +1143,27 @@ export class MoniCarousel extends MoniElement {
959
1143
  rightPadding = minRightPaddingNeeded;
960
1144
  }
961
1145
 
1146
+ let snapItemsCount = hasItems ? this._snapItemsCount : 0;
1147
+ let visualItems = this.effectiveItems;
1148
+
1149
+ if (this.infinite && hasItems) {
1150
+ const V_mult = this._visualCardsMultiplier;
1151
+
1152
+ visualItems = [];
1153
+ for (let i = 0; i < V_mult; i++) {
1154
+ visualItems = visualItems.concat(this.effectiveItems);
1155
+ }
1156
+ }
1157
+
962
1158
  const trackWidth = hasItems
963
- ? this.effectiveItems.length * L + (this.effectiveItems.length - 1) * this.gap
1159
+ ? snapItemsCount * L + (snapItemsCount - 1) * this.gap
964
1160
  : 0;
965
1161
 
966
1162
  const isScrollable = hasItems && this._scrollContainer &&
967
1163
  (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);
1164
+ const showPrevArrow = this.infinite || (isScrollable && this._scrollContainer.scrollLeft > 5);
1165
+ const showNextArrow = this.infinite || (isScrollable &&
1166
+ (this._scrollContainer.scrollWidth - this._scrollContainer.scrollLeft - this._scrollContainer.clientWidth > 5));
971
1167
 
972
1168
  return html`
973
1169
  <div class="carousel-container" style="
@@ -1015,18 +1211,19 @@ export class MoniCarousel extends MoniElement {
1015
1211
  @mousedown=${this._handleMouseDown}
1016
1212
  @mousemove=${this._handleMouseMove}
1017
1213
  @mouseup=${this._handleMouseUp}
1018
- @mouseleave=${this._handleMouseUp}
1214
+ @mouseenter=${this._handleMouseEnter}
1215
+ @mouseleave=${this._handleMouseLeave}
1019
1216
  @touchstart=${this._handleTouchStart}
1020
1217
  @touchmove=${this._handleTouchMove}
1021
1218
  @touchend=${this._handleTouchEnd}
1022
1219
  @wheel=${this._handleWheel}
1023
1220
  >
1024
1221
  <div class="snap-track">
1025
- ${hasItems ? this.effectiveItems.map(() => html`<div class="snap-item"></div>`) : ''}
1222
+ ${hasItems ? Array.from({ length: snapItemsCount }).map(() => html`<div class="snap-item"></div>`) : ''}
1026
1223
  </div>
1027
1224
  <div class="visual-track">
1028
1225
  ${hasItems
1029
- ? this.effectiveItems.map(
1226
+ ? visualItems.map(
1030
1227
  (item, idx) => html`
1031
1228
  <div
1032
1229
  class="card"
@@ -1034,16 +1231,16 @@ export class MoniCarousel extends MoniElement {
1034
1231
  >
1035
1232
  ${item.href
1036
1233
  ? html`
1037
- <a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;">
1234
+ <a href="${item.href}" target="${item.target || '_self'}" style="text-decoration:none; color:inherit;" draggable="false">
1038
1235
  <div class="img-parallax-container">
1039
- <img src="${item.img}" alt="${item.title}" draggable="false" />
1236
+ <img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
1040
1237
  </div>
1041
1238
  <h2 class="card-title">${item.title}</h2>
1042
1239
  </a>
1043
1240
  `
1044
1241
  : html`
1045
1242
  <div class="img-parallax-container">
1046
- <img src="${item.img}" alt="${item.title}" draggable="false" />
1243
+ <img src="${item.img}" alt="${item.title}" draggable="false" loading="lazy" />
1047
1244
  </div>
1048
1245
  <h2 class="card-title">${item.title}</h2>
1049
1246
  `}