/** * Copyright Aquera Inc 2025 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import '../nile-icon'; import '../nile-button'; import './nile-carousel-item/nile-carousel-item'; import { html, CSSResultArray, TemplateResult } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query, state } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import { range } from 'lit/directives/range.js'; import { prefersReducedMotion } from '../internal/animate'; import { watch } from '../internal/watch'; import { styles } from './nile-carousel.css'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValueMap } from 'lit'; import type NileCarouselItem from './nile-carousel-item/nile-carousel-item'; import { isCarouselItem, findMostVisibleSlide, getPageCount, getCurrentPage, canScrollNext, canScrollPrev, shouldSnapToSlide, scrollToSlide as scrollToSlideHelper, goToSlide as goToSlideHelper } from './carousel-helpers'; @customElement('nile-carousel') export class NileCarousel extends NileElement { static styles: CSSResultArray = [styles]; @property({ type: Boolean, reflect: true }) navigation = false; @property({ type: Boolean, reflect: true }) pagination = false; @property({ type: Boolean, reflect: true }) loop = false; @property({ type: String, attribute: 'navigation-position' }) navigationPosition: 'sides' | 'top-right' = 'sides'; @property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1; @property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1; @query('.carousel__slides') scrollContainer: HTMLElement; @query('.carousel__pagination') paginationContainer: HTMLElement; @state() activeSlide = 0; @state() scrolling = false; private mutationObserver: MutationObserver; private pendingSlideChange = false; connectedCallback(): void { super.connectedCallback(); this.setAttribute('role', 'region'); this.setAttribute('aria-label', 'carousel'); } disconnectedCallback(): void { super.disconnectedCallback(); this.mutationObserver?.disconnect(); } protected firstUpdated(): void { this.initializeSlides(); this.mutationObserver = new MutationObserver(this.handleSlotChange); this.mutationObserver.observe(this, { childList: true, subtree: true }); } protected willUpdate(changedProperties: PropertyValueMap | Map): void { if (changedProperties.has('slidesPerMove') || changedProperties.has('slidesPerPage')) { this.slidesPerMove = Math.min(this.slidesPerMove, this.slidesPerPage); } } private getPageCount() { const slidesCount = this.getSlides().length; return getPageCount(slidesCount, this.slidesPerPage, this.slidesPerMove); } private getCurrentPage() { return getCurrentPage(this.activeSlide, this.slidesPerMove); } private canScrollNext(): boolean { return canScrollNext(this.getCurrentPage(), this.getPageCount(), this.loop); } private canScrollPrev(): boolean { return canScrollPrev(this.getCurrentPage(), this.loop); } private getSlides() { return [...this.children].filter( (el: HTMLElement) => isCarouselItem(el) ) as NileCarouselItem[]; } private handleKeyDown(event: KeyboardEvent) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { const target = event.target as HTMLElement; const isFocusInPagination = target.closest('.carousel__pagination-item') !== null; const isNext = event.key === 'ArrowDown' || event.key === 'ArrowRight'; const isPrevious = event.key === 'ArrowUp' || event.key === 'ArrowLeft'; event.preventDefault(); if (isPrevious) { this.previous(); } if (isNext) { this.next(); } if (event.key === 'Home') { this.goToSlide(0); } if (event.key === 'End') { this.goToSlide(this.getSlides().length - 1); } if (isFocusInPagination) { this.updateComplete.then(() => { const activePaginationItem = this.shadowRoot?.querySelector( '.carousel__pagination-item--active' ); if (activePaginationItem) { activePaginationItem.focus(); } }); } } } private handleScroll = () => { if (!this.scrollContainer) { return; } this.scrolling = true; if (this.pendingSlideChange) { return; } const slides = this.getSlides(); const mostVisibleSlide = findMostVisibleSlide(slides, this.scrollContainer); if (mostVisibleSlide) { const newActiveIndex = slides.indexOf(mostVisibleSlide); if (newActiveIndex !== -1 && newActiveIndex !== this.activeSlide) { this.activeSlide = newActiveIndex; } } }; private handleScrollEnd = () => { this.scrolling = false; this.pendingSlideChange = false; if (this.scrollContainer) { const slides = this.getSlides(); const mostVisibleSlide = findMostVisibleSlide(slides, this.scrollContainer); if (mostVisibleSlide) { const newActiveIndex = slides.indexOf(mostVisibleSlide); if (newActiveIndex !== -1 && newActiveIndex !== this.activeSlide) { this.activeSlide = newActiveIndex; } } } }; private handleSlotChange = () => { this.initializeSlides(); }; private initializeSlides() { const slides = this.getSlides(); if (!slides.length) { return; } slides.forEach((slide, i) => { slide.classList.toggle('--is-active', i === this.activeSlide); }); this.updateSlidesSnap(); } @watch('activeSlide') handleSlideChange() { const slides = this.getSlides(); slides.forEach((slide, i) => { slide.classList.toggle('--is-active', i === this.activeSlide); }); if (this.hasUpdated) { this.emit('nile-slide-change', { detail: { index: this.activeSlide, slide: slides[this.activeSlide] } }); } } @watch('slidesPerMove') updateSlidesSnap() { const slides = this.getSlides(); const slidesPerMove = this.slidesPerMove; slides.forEach((slide, i) => { if (shouldSnapToSlide(i, slidesPerMove)) { slide.style.removeProperty('scroll-snap-align'); } else { slide.style.setProperty('scroll-snap-align', 'none'); } }); } previous(behavior: ScrollBehavior = 'smooth') { const slides = this.getSlides(); if (this.loop && this.activeSlide === 0) { this.goToSlide(slides.length - this.slidesPerMove, behavior); } else { this.goToSlide(this.activeSlide - this.slidesPerMove, behavior); } } next(behavior: ScrollBehavior = 'smooth') { const slides = this.getSlides(); const maxIndex = slides.length - this.slidesPerPage; if (this.loop && this.activeSlide >= maxIndex) { this.goToSlide(0, behavior); } else { this.goToSlide(this.activeSlide + this.slidesPerMove, behavior); } } goToSlide(index: number, behavior: ScrollBehavior = 'smooth') { const { slidesPerPage } = this; const slides = this.getSlides(); const result = goToSlideHelper(index, slides, slidesPerPage, behavior, this.loop); if (!result || !this.scrollContainer) { return; } this.activeSlide = result.newActiveSlide; const scrollBehavior = prefersReducedMotion() ? 'auto' : behavior; scrollToSlideHelper( result.slideToScroll, this.scrollContainer, scrollBehavior, (value: boolean) => { this.pendingSlideChange = value; } ); } private renderNavigationButton( direction: 'previous' | 'next', enabled: boolean, onClick: () => void ): TemplateResult { const isPrevious = direction === 'previous'; const iconName = isPrevious ? 'var(--nile-icon-arrow-left, var(--ng-icon-arrow-narrow-left))' : 'var(--nile-icon-arrow-right, var(--ng-icon-arrow-narrow-right))'; const slotName = isPrevious ? 'previous-icon' : 'next-icon'; const partName = isPrevious ? 'navigation-button--previous' : 'navigation-button--next'; const ariaLabel = isPrevious ? 'Previous slide' : 'Next slide'; const isTopRight = this.navigationPosition === 'top-right'; return html` `; } private renderScrollContainer(): TemplateResult { return html` `; } private renderPaginationItem(index: number, pagesCount: number, currentPage: number, slidesPerMove: number): TemplateResult { const isActive = index === currentPage; return html` `; } private renderPagination(): TemplateResult { if (!this.pagination) { return html``; } const pagesCount = this.getPageCount(); const currentPage = this.getCurrentPage(); const slidesPerMove = this.slidesPerMove; return html` `; } render() { const prevEnabled = this.canScrollPrev(); const nextEnabled = this.canScrollNext(); const isTopRight = this.navigationPosition === 'top-right'; return html` `; } } export default NileCarousel; declare global { interface HTMLElementTagNameMap { 'nile-carousel': NileCarousel; } }