// Interactive, hydration-safe content carousel. component Carousel { outputs { initialize(payload: { index: number; count: number }) change(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object; reason: string | boolean }) previous(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object }) next(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object }) play(payload: { index: number; interval: number }) pause(payload: { index: number }) reachStart(payload: { index: number }) reachEnd(payload: { index: number }) dragStart(payload: { index: number; x: null }) dragEnd(payload: { index: number; distance: number }) } props { size: string = "default" color: string = "primary" title: string = "" description: string = "" items: unknown[] = [] activeIndex: number = 0 slidesPerView: number = 1 gap: string = "0.75rem" showPagination: boolean = false isAutoPlay: boolean = false autoplayInterval: number = 4000 isInfiniteLoop: boolean = false isRTL: boolean = false isCentered: boolean = false isDraggable: boolean = false isAutoHeight: boolean = false isSnap: boolean = false showCounter: boolean = false thumbnails: string = "none" ariaLabel: string = "Content carousel" variant: string = "default" class: string = "" } state currentIndex = activeIndex state playing: boolean = false state dragOrigin = null state dragOffset: number = 0 state autoplayTimer = null state carouselRoot = null functions { shared function slideCount() { return items.length } shared function maximumIndex(count, visible) { count = slideCount() visible = Math.max(1, Number(slidesPerView) || 1) if (isCentered || isSnap) { return Math.max(0, count - 1) } return Math.max(0, count - visible) } shared function normalizedIndex(index, maximum) { maximum = maximumIndex() if (slideCount() === 0) { return 0 } if (isInfiniteLoop) { if (index < 0) { return maximum } if (index > maximum) { return 0 } } return Math.max(0, Math.min(index, maximum)) } client function rememberRoot(sourceEvent, root) { if (!sourceEvent || !sourceEvent.currentTarget) { return } root = sourceEvent.currentTarget.closest(".wrn-next--carousel") if (root) { carouselRoot = root } } client function syncNavigation(sourceEvent, index, root, viewport, slides, slide, thumbnails, thumbnail, rail) { rememberRoot(sourceEvent) root = carouselRoot if (!root) { return } if (isSnap) { viewport = root.querySelector(".wrn-next__carousel-viewport") slides = root.querySelectorAll(".wrn-next__carousel-slide") slide = slides[index] if (viewport && slide) { viewport.scrollTo({ left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2, behavior: "smooth" }) } } thumbnails = root.querySelectorAll(".wrn-next__carousel-thumbnails button") if (thumbnails[index]) { thumbnail = thumbnails[index] rail = thumbnail.parentElement rail.scrollTo({ left: thumbnail.offsetLeft - (rail.clientWidth - thumbnail.clientWidth) / 2, top: thumbnail.offsetTop - (rail.clientHeight - thumbnail.clientHeight) / 2, behavior: "smooth" }) } } client function handleSnapScroll(sourceEvent, slides, viewport, viewportCenter, closestIndex, closestDistance, slide, slideCenter, distance, previousIndex) { if (!isSnap) { return } rememberRoot(sourceEvent) viewport = sourceEvent.currentTarget slides = viewport.querySelectorAll(".wrn-next__carousel-slide") viewportCenter = viewport.scrollLeft + viewport.clientWidth / 2 closestIndex = currentIndex closestDistance = 1000000000 slides.forEach(function (candidate, index) { slideCenter = candidate.offsetLeft + candidate.clientWidth / 2 distance = Math.abs(slideCenter - viewportCenter) if (distance < closestDistance) { closestDistance = distance closestIndex = index } }) closestIndex = normalizedIndex(closestIndex) if (closestIndex !== currentIndex) { previousIndex = currentIndex currentIndex = closestIndex output.change({ index: currentIndex, previousIndex: previousIndex, item: items[currentIndex], reason: "snap" }) slides = carouselRoot.querySelectorAll(".wrn-next__carousel-thumbnails button") slide = slides[currentIndex] if (slide) { viewport = slide.parentElement viewport.scrollTo({ left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2, top: slide.offsetTop - (viewport.clientHeight - slide.clientHeight) / 2, behavior: "smooth" }) } } } client function selectSlide(index, reason, sourceEvent, previousIndex) { if (slideCount() === 0) { return } previousIndex = currentIndex currentIndex = normalizedIndex(Number(index)) syncNavigation(sourceEvent, currentIndex) if (previousIndex === currentIndex && reason !== "initialize") { return } output.change({ index: currentIndex, previousIndex: previousIndex, item: items[currentIndex], reason: reason || "select" }) if (currentIndex === 0) { output.reachStart({ index: currentIndex }) } if (currentIndex === maximumIndex()) { output.reachEnd({ index: currentIndex }) } } client function previousSlide(sourceEvent, previousIndex) { previousIndex = currentIndex selectSlide(currentIndex - 1, "previous", sourceEvent) output.previous({ index: currentIndex, previousIndex: previousIndex, item: items[currentIndex] }) } client function nextSlide(sourceEvent, previousIndex) { previousIndex = currentIndex selectSlide(currentIndex + 1, "next", sourceEvent) output.next({ index: currentIndex, previousIndex: previousIndex, item: items[currentIndex] }) } client function handleKeydown(sourceEvent) { if (sourceEvent.key === "ArrowLeft") { sourceEvent.preventDefault() if (isRTL) { nextSlide(sourceEvent) } else { previousSlide(sourceEvent) } } if (sourceEvent.key === "ArrowRight") { sourceEvent.preventDefault() if (isRTL) { previousSlide(sourceEvent) } else { nextSlide(sourceEvent) } } if (sourceEvent.key === "Home") { sourceEvent.preventDefault() selectSlide(0, "keyboard", sourceEvent) } if (sourceEvent.key === "End") { sourceEvent.preventDefault() selectSlide(maximumIndex(), "keyboard", sourceEvent) } } client function beginDrag(sourceEvent) { if (!isDraggable || isSnap) { return } sourceEvent.preventDefault() dragOrigin = sourceEvent.clientX dragOffset = 0 sourceEvent.currentTarget.setPointerCapture(sourceEvent.pointerId) output.dragStart({ index: currentIndex, x: dragOrigin }) } client function moveDrag(sourceEvent) { if (!isDraggable || isSnap || dragOrigin === null) { return } sourceEvent.preventDefault() dragOffset = sourceEvent.clientX - dragOrigin } shared function cancelDrag() { dragOrigin = null dragOffset = 0 } client function endDrag(sourceEvent, distance) { if (!isDraggable || isSnap || dragOrigin === null) { return } sourceEvent.preventDefault() distance = dragOffset || sourceEvent.clientX - dragOrigin dragOrigin = null dragOffset = 0 if (Math.abs(distance) > 20) { if ((distance < 0 && !isRTL) || (distance > 0 && isRTL)) { nextSlide(sourceEvent) } else { previousSlide(sourceEvent) } } output.dragEnd({ index: currentIndex, distance: distance }) } shared function advanceAutoplay() { if (currentIndex >= maximumIndex()) { selectSlide(0, "autoplay") } else { selectSlide(currentIndex + 1, "autoplay") } } client function startAutoplay() { if (!isAutoPlay || playing || slideCount() < 2) { return } playing = true autoplayTimer = setInterval(advanceAutoplay, Math.max(1000, Number(autoplayInterval))) output.play({ index: currentIndex, interval: autoplayInterval }) } client function pauseAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer) } autoplayTimer = null if (playing) { output.pause({ index: currentIndex }) } playing = false } } lifecycle { mount { output.initialize({ index: currentIndex, count: slideCount() }) startAutoplay() } unmount { if (autoplayTimer) { clearInterval(autoplayTimer) } } } view { } style { .wrn-next--carousel { --wrn-carousel-color: var(--wrn-component-color, var(--wrn-color-primary)); display: grid; width: 100%; min-width: 0; gap: 1rem; padding: 1rem; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-md); color: var(--wrn-color-text); background: var(--wrn-color-surface); box-shadow: var(--wrn-shadow-1); } .wrn-next__carousel-header h3, .wrn-next__carousel-header p { margin: 0; } .wrn-next__carousel-header p { margin-top: 0.35rem; color: var(--wrn-color-text-muted); } .wrn-next__carousel-layout { display: flex; min-width: 0; gap: 0.75rem; } .wrn-next__carousel-main { display: grid; flex: 1; min-width: 0; gap: 0.75rem; } .wrn-next__carousel-stage { position: relative; min-width: 0; } .wrn-next__carousel-viewport { position: relative; overflow: hidden; min-width: 0; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-md); background: var(--wrn-color-surface-subtle, var(--wrn-color-surface)); outline: none; } .wrn-next__carousel-viewport:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-carousel-color) 30%, transparent); } .wrn-next__carousel-track { display: flex; align-items: stretch; gap: var(--wrn-carousel-gap); transform: translateX( calc( var(--wrn-carousel-index) * -1 * ( (100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) / var(--wrn-carousel-per-view) + var(--wrn-carousel-gap) ) ) ); transition: transform var(--wrn-motion-base) var(--wrn-ease-emphasized); translate: var(--wrn-carousel-drag-offset, 0) 0; will-change: transform; } .wrn-next--carousel[data-dragging="true"] .wrn-next__carousel-track { transition: none; } .wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-track { transform: translateX( calc( var(--wrn-carousel-index) * ( (100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) / var(--wrn-carousel-per-view) + var(--wrn-carousel-gap) ) ) ); } .wrn-next__carousel-slide { position: relative; display: grid; flex: 0 0 calc( (100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) / var(--wrn-carousel-per-view) ); overflow: hidden; min-width: 0; min-height: 20rem; place-items: center; background: var(--wrn-color-surface-raised, var(--wrn-color-surface)); } .wrn-next--carousel[data-auto-height="true"] .wrn-next__carousel-slide { min-height: 0; } .wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide { flex-basis: min(78%, 32rem); } .wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track { box-sizing: border-box; transform: translateX( calc(var(--wrn-carousel-index) * -1 * (min(78%, 32rem) + var(--wrn-carousel-gap))) ); } .wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide:first-child { margin-inline-start: calc((100% - min(78%, 32rem)) / 2); } .wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide:last-child { margin-inline-end: calc((100% - min(78%, 32rem)) / 2); } .wrn-next--carousel[data-centered="true"][data-rtl="true"] .wrn-next__carousel-track { transform: translateX( calc(var(--wrn-carousel-index) * (min(78%, 32rem) + var(--wrn-carousel-gap))) ); } .wrn-next__carousel-slide > img { position: absolute; width: 100%; height: 100%; object-fit: cover; inset: 0; } .wrn-next__carousel-slide-content { position: relative; z-index: 1; width: min(100%, 38rem); padding: clamp(2rem, 8vw, 5rem); text-align: center; } .wrn-next__carousel-slide-content h4, .wrn-next__carousel-slide-content p { margin: 0; } .wrn-next__carousel-slide-content h4 { font-size: clamp(1.5rem, 4vw, 2.4rem); } .wrn-next__carousel-slide-content p { margin-top: 0.65rem; color: var(--wrn-color-text-muted); } .wrn-next__carousel-control { position: absolute; z-index: 3; top: 50%; display: grid; width: 2.75rem; min-height: 2.75rem; padding: 0; border: 1px solid color-mix(in srgb, var(--wrn-color-border) 70%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--wrn-color-text) 76%, transparent); color: var(--wrn-color-surface); cursor: pointer; place-items: center; transform: translateY(-50%); } .wrn-next__carousel-control:focus-visible, .wrn-next__carousel-pagination button:focus-visible, .wrn-next__carousel-thumbnails button:focus-visible { outline: 2px solid var(--wrn-carousel-color); outline-offset: 2px; } .wrn-next__carousel-control:disabled { cursor: not-allowed; opacity: 0.35; } .wrn-next__carousel-control--previous { left: 1rem; } .wrn-next__carousel-control--next { right: 1rem; } .wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-control--previous { right: 1rem; left: auto; } .wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-control--next { right: auto; left: 1rem; } .wrn-next__carousel-pagination { display: flex; min-height: 1.5rem; align-items: center; justify-content: center; gap: 0.5rem; } .wrn-next__carousel-pagination button { width: 0.65rem; min-height: 0.65rem; padding: 0; border: 1px solid var(--wrn-color-text-muted); border-radius: 999px; background: transparent; cursor: pointer; } .wrn-next__carousel-pagination button[data-active="true"] { border-color: var(--wrn-carousel-color); background: var(--wrn-carousel-color); } .wrn-next__carousel-counter { position: absolute; z-index: 3; bottom: 0.8rem; left: 50%; padding: 0.25rem 0.65rem; border-radius: 999px; background: color-mix(in srgb, var(--wrn-color-text) 82%, transparent); color: var(--wrn-color-surface); font-weight: 700; transform: translateX(-50%); } .wrn-next__carousel-thumbnails { display: flex; overflow: auto; gap: 0.55rem; scrollbar-width: thin; } .wrn-next__carousel-thumbnails button { display: grid; flex: 0 0 8.5rem; min-height: 3.75rem; overflow: hidden; padding: 0; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface); color: var(--wrn-color-text); cursor: pointer; place-items: center; } .wrn-next__carousel-thumbnails button[data-active="true"] { border-color: var(--wrn-carousel-color); box-shadow: inset 0 0 0 1px var(--wrn-carousel-color); } .wrn-next__carousel-thumbnails img { width: 100%; height: 4rem; object-fit: cover; } .wrn-next__carousel-thumbnails span { padding: 0.5rem; } .wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails { flex: 0 0 9rem; flex-direction: column; max-height: 28rem; } .wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails button { flex-basis: 7.5rem; width: 100%; } .wrn-next--carousel[data-draggable="true"] .wrn-next__carousel-viewport { cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; } .wrn-next--carousel[data-draggable="true"] .wrn-next__carousel-viewport:active { cursor: grabbing; } .wrn-next--carousel[data-draggable="true"] img, .wrn-next--carousel[data-draggable="true"] a { -webkit-user-drag: none; } .wrn-next--carousel[data-snap="true"] .wrn-next__carousel-viewport { overflow-x: auto; scroll-behavior: smooth; scroll-snap-type: x mandatory; scrollbar-width: thin; } .wrn-next--carousel[data-snap="true"] .wrn-next__carousel-track { --wrn-carousel-snap-slide: calc( (100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) / var(--wrn-carousel-per-view) ); display: grid; width: 100%; min-width: 100%; box-sizing: content-box; grid-auto-columns: var(--wrn-carousel-snap-slide); grid-auto-flow: column; padding-inline: calc((100% - var(--wrn-carousel-snap-slide)) / 2); transform: none; } .wrn-next--carousel[data-snap="true"] .wrn-next__carousel-slide { width: auto; min-width: 0; scroll-snap-align: center; } @media (max-width: 640px) { .wrn-next__carousel-slide { flex-basis: 100%; min-height: 16rem; } .wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-layout { flex-direction: column-reverse; } .wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails { flex: auto; flex-direction: row; max-height: none; } .wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails button { flex-basis: 8.5rem; width: auto; } } @media (prefers-reduced-motion: reduce) { .wrn-next__carousel-track { transition-duration: 0.01ms; } } /* Shared component foundation. */ .wrn-component { --wrn-component-color: var(--wrn-color-primary); --wrn-component-scale: 1; margin: 0; color: var(--wrn-color-text); font-family: var(--wrn-font-sans, inherit); } .wrn-component--color-primary { --wrn-component-color: var(--wrn-color-primary); } .wrn-component--color-secondary { --wrn-component-color: var(--wrn-color-secondary); } .wrn-component--color-success { --wrn-component-color: var(--wrn-color-success); } .wrn-component--color-warning { --wrn-component-color: var(--wrn-color-warning); } .wrn-component--color-danger { --wrn-component-color: var(--wrn-color-danger); } .wrn-component--color-info { --wrn-component-color: var(--wrn-color-info); } .wrn-component--size-xs { --wrn-component-scale: 0.78; } .wrn-component--size-sm { --wrn-component-scale: 0.88; } .wrn-component--size-default, .wrn-component--size-md { --wrn-component-scale: 1; } .wrn-component--size-lg { --wrn-component-scale: 1.14; } .wrn-component--size-xl { --wrn-component-scale: 1.28; } .wrn-component:not(.wrn-btn) { font-size: calc(1em * var(--wrn-component-scale)); } .wrn-component :is(a, button, input, select, textarea):focus-visible { outline-color: var(--wrn-component-color); } .wrn-component p { margin: 0; color: var(--wrn-color-muted); line-height: 1.6; } } }