UNPKG

@ussebastian/kitdigital

Version:

Kit Digital de la Universidad San Sebastián

620 lines (534 loc) 20 kB
import EmblaCarousel from 'embla-carousel'; import Autoplay from 'embla-carousel-autoplay'; import ClassNames from 'embla-carousel-class-names'; export class CarouselV2 { constructor(element, key) { this.key = key; this.isCarouselActive = false; this.element = element; this.baseClass = 'uss-carousel-v2'; this.emblaApi = null; this.autoplayOnInit = true; this.autoplayDelay = 20000; this.jumpInsteadOfScroll = false; this.carouselLoop = false; this.progress = 0; this.progressIndicatorType = 'stripe'; // stripe or circle this.wasPausedByVisibilityChange = false; this.rootNode = this.element; this.controlsContainer = this.findElement('controls'); this.viewportNode = this.findElement('viewport'); this.prevBtn = this.findElement('prev'); this.nextBtn = this.findElement('next'); this.playBtn = this.findElement('play'); this.shortcutNode = this.findElement('shortcuts'); this.skipNavButton = this.findElement('skip-navigation'); this.shortcutButtons = []; this.autoplayInstance = null; // Autoplay plugin instance this.slideNodes = []; this.slideContainer = null; this.whiteContrast = true; this.whenProgressIsOver100 = []; this.everyProgressUpdate = []; this.focusableSelectors = [ 'a[href]', 'button', "[tabindex]:not([tabindex='-1'])", 'input', 'select', 'textarea', ]; this.carouselType = null; this.mouseEnterTimeout = null; this.slidesToScroll = 1; this.carouselOffset = 0; this.handleSetupInput(); } getSlideToScroll() { // calculate teh slides to scroll based on the size of the slide and the viewport const viewportWidth = this.viewportNode.offsetWidth; const slideWidth = this.slideNodes[0].offsetWidth; const slidesToScroll = Math.floor((viewportWidth - this.carouselOffset) / slideWidth); return slidesToScroll; } getCarouselOffset() { // Assuming this.controlsContainer is already a reference to the container element if (!this.controlsContainer) { throw new Error('controlsContainer is not defined.'); } // Get the bounding rectangle of the controlsContainer element const rect = this.controlsContainer.getBoundingClientRect(); let offset = rect.left; // Distance from the element to the viewport's left // Traverse up the DOM tree to find the closest parent with a class of "container" let parentWithClass = this.controlsContainer.parentElement; while (parentWithClass && !parentWithClass.classList.contains('container')) { if (parentWithClass.nodeName === 'HTML') { // Reached the top of the document parentWithClass = null; break; } parentWithClass = parentWithClass.parentElement; } // If a parent with class "container" is found, calculate the offset from the element to this parent if (parentWithClass) { const parentRect = parentWithClass.getBoundingClientRect(); offset -= parentRect.left; // Adjust offset based on the parent's left position } this.carouselOffset = offset; // console.log({ offset: this.carouselOffset }); } handleSetupInput() { this.carouselType = this.element.dataset.ucCarouselType || 'hero'; } canAutoplay(value) { const cannotAutoplay = ['cards']; if (cannotAutoplay.includes(this.carouselType)) return false; return value; } setupEmbla() { this.emblaApi = EmblaCarousel( this.viewportNode, { loop: this.carouselLoop, slidesToScroll: this.slidesToScroll }, [ Autoplay({ playOnInit: this.canAutoplay(this.autoplayOnInit), delay: 1000 * 60 * 60 * 24 * 365 * 100, jump: true, }), ClassNames({ snapped: `${this.baseClass}__slide--snapped`, inView: '', draggable: '', dragging: '', }), ], ); this.autoplayInstance = this.emblaApi.plugins().autoplay; this.slideNodes = this.emblaApi.slideNodes(); this.slideContainer = this.emblaApi.containerNode(); this.whenProgressIsOver100.push(() => { if (this.emblaApi.canScrollNext()) this.emblaApi.scrollNext(this.jumpInsteadOfScroll); else this.emblaApi.scrollTo(0, this.jumpInsteadOfScroll); }); } // Life cycle methods mount() { this.setupEmbla(); this.setupEventListeners(); this.getCarouselOffset(); } addElements() { if (['cards'].includes(this.carouselType)) this.addOffset(); this.addShortcutButtons(); this.addSkipNavButton(); this.addProgressIndicator(); this.addIcons(); this.announceSlide(); } postInitialization() { this.addElements(); // setup initial State this.setupProgressHandler(); this.onChangeSlide(); // accesibility this.ARIA_rootNode(); this.ARIA_slides(); this.ARIA_navigationControls(); this.ARIA_shortcuts(); this.reduceMotionGuard(); } onReinit() { this.getCarouselOffset(); if (['cards'].includes(this.carouselType)) this.addOffset(); } onChangeSlide() { this.announceSlide(); this.updateSlidesTabIndexes(); this.toggleNavigationButtonsState(); this.toggleActiveShortcutButton(); this.toggleSkipNavButtonVisibility(); this.progress = 0; if (this.progressIndicator) this.progressIndicator.style.setProperty('--progress', `${this.progress}%`); this.toggleActiveShortcutButton(); } setupProgressHandler() { this.interval = setInterval(() => { // generte random 3 digit number if (this.autoplayInstance.isPlaying() === false) { clearInterval(this.interval); return; } this.progress += 0.1; this.everyProgressUpdate.map((fn) => fn()); // console.table({ // key: this.key, // progress: this.progress, // rnd, // }); if (this.progress >= 100) this.whenProgressIsOver100.map((fn) => fn()); }, this.autoplayDelay / 1000); } announceSlide() { const selectedSlideIndex = this.emblaApi.selectedScrollSnap(); let currentSlideLabel = this.rootNode.querySelector(`.${this.baseClass}__slide-announcer`); if (!currentSlideLabel) { currentSlideLabel = document.createElement('label'); currentSlideLabel.classList.add(`${this.baseClass}__slide-announcer`); currentSlideLabel.classList.add('uss-sr-only'); currentSlideLabel.setAttribute('aria-live', this.autoplayOnInit ? 'off' : 'polite'); this.rootNode.appendChild(currentSlideLabel); } currentSlideLabel.innerHTML = `Diapositiva ${selectedSlideIndex + 1} de ${ this.slideNodes.length }:`; } updateSlidesTabIndexes() { const selectedIndex = this.emblaApi.selectedScrollSnap(); this.emblaApi.slideNodes().forEach((slideElement, index) => { const isSelected = index === selectedIndex; const focusableElements = slideElement.querySelectorAll(this.focusableSelectors.join(',')); focusableElements.forEach((element) => { element.setAttribute('tabindex', isSelected ? '0' : '-1'); }); }); } // --- Toggle states --- toggleAutoplay() { if (this.autoplayInstance.isPlaying()) this.autoplayInstance.stop(); else this.autoplayInstance.play(); } toggleNavigationButtonsState() { this.prevBtn.disabled = !this.emblaApi.canScrollPrev(); this.nextBtn.disabled = !this.emblaApi.canScrollNext(); } toggleActiveShortcutButton() { const selectedIndex = this.emblaApi.selectedScrollSnap(); this.shortcutButtons.forEach((button, index) => { if (index === selectedIndex) { button.classList.add(`${this.baseClass}__shortcut--active`); button.setAttribute('aria-disabled', 'true'); button.removeAttribute('tabindex'); } else { button.classList.remove(`${this.baseClass}__shortcut--active`); button.removeAttribute('aria-disabled'); button.setAttribute('tabindex', '-1'); } }); } toggleAutoplayOnVisibilityChange() { this.wasPausedByVisibilityChange = false; document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { if (this.autoplayInstance.isPlaying()) { this.autoplayInstance.stop(); this.wasPausedByVisibilityChange = true; } } else if (document.visibilityState === 'visible') { if (!this.autoplayInstance.isPlaying() && this.wasPausedByVisibilityChange) { this.autoplayInstance.play(); this.wasPausedByVisibilityChange = false; } } }); } toggleSkipNavButtonVisibility() { const selectedSlide = this.emblaApi.selectedScrollSnap(); const slideElement = this.emblaApi.slideNodes()[selectedSlide]; const focusableElement = slideElement.querySelector(this.focusableSelectors.join(',')); if (focusableElement) { this.skipNavButton.setAttribute('tabindex', '0'); this.skipNavButton.setAttribute('aria-hidden', 'false'); } else { this.skipNavButton.setAttribute('tabindex', '-1'); this.skipNavButton.setAttribute('aria-hidden', 'true'); } } // --- Add elements --- addIcons() { const colorWhite = this.whiteContrast ? 'uss-icon--color-white icon-background--white' : ''; if (this.prevBtn) // <i class="uss-icon ri-information-line"></i> this.prevBtn.innerHTML = `<i class='uss-icon ri-arrow-left-s-line ${colorWhite} '></i>`; if (this.nextBtn) this.nextBtn.innerHTML = `<i class='uss-icon ri-arrow-right-s-line ${colorWhite} '></i>`; if (this.playBtn) { this.playBtn.innerHTML = this.autoplayInstance.isPlaying() ? `<i class='uss-icon icon-color--white'>pause</i>` : `<i class='uss-icon icon-color--white'>play_arrow</i>`; this.playBtn.setAttribute( 'aria-label', this.autoplayInstance.isPlaying() ? 'Pausar reproducción automática' : 'Reproducción automática', ); } } addOffset() { if (!this.carouselOffset || !this.viewportNode) return; this.viewportNode.style.setProperty('--carousel-offset', `${this.carouselOffset}px`); } addShortcutButtons() { if (!this.shortcutNode) return; this.shortcutNode.innerHTML = ''; this.emblaApi.scrollSnapList().forEach((_, index) => { const button = this.createShortcutButton(index + 1); this.shortcutButtons[index] = button; }); this.shortcutButtons.forEach((button, index) => { this.attachShortcutButtonEvents(button, index); this.shortcutNode.appendChild(button); }); this.toggleActiveShortcutButton(); } createShortcutButton(index) { let slideIndex; if (index < 10) slideIndex = `0${index}`; const button = this.createElement('shortcut', 'button'); button.setAttribute('type', 'button'); button.style.setProperty('--slide-index', `"${slideIndex}"`); return button; } canHaveProgressIndicator() { const cannotHaveProgressIndicator = ['cards']; if (cannotHaveProgressIndicator.includes(this.carouselType)) return false; return true; } addProgressIndicator() { if (!this.canHaveProgressIndicator()) return; this.progressIndicator = this.createElement( `progress-indicator--${this.progressIndicatorType}`, ); this.everyProgressUpdate.push(() => { const isCircle = this.progressIndicatorType === 'circle'; const toFixedNumber = isCircle ? 0 : 2; this.progressIndicator.style.setProperty( '--progress', `${Number(this.progress).toFixed(toFixedNumber)}%`, ); }); this.rootNode.appendChild(this.progressIndicator); } addSkipNavButton() { this.skipNavButton.setAttribute('tabindex', '-1'); this.skipNavButton.setAttribute('aria-hidden', 'true'); this.skipNavButton.innerHTML = 'Saltar al contenido de la diapositiva'; this.skipNavButton.addEventListener('keydown', (e) => { this.handleSkipNavButtonKeyDown(e); }); } // --- event listeners --- setupEventListeners() { this.emblaEventListeners(); this.clickEventListeners(); this.hoverEventListeners(); } emblaEventListeners() { this.emblaApi.on('init', this.postInitialization.bind(this)); this.emblaApi.on('reInit', this.onReinit.bind(this)); this.emblaApi.on('select', this.onChangeSlide.bind(this)); this.emblaApi.on('destroy', this.cleanup.bind(this)); this.emblaApi.on('autoplay:play', () => { this.setupProgressHandler(); if (!this.playBtn) return; this.playBtn.innerHTML = "<i class='uss-icon icon-color--white'>pause</i>"; this.playBtn.setAttribute('aria-label', 'Pausar reproducción automática'); }); this.emblaApi.on('autoplay:stop', () => { if (!this.playBtn) return; this.playBtn.innerHTML = "<i class='uss-icon icon-color--white'>play_arrow</i>"; this.playBtn.setAttribute('aria-label', 'Reproducción automática'); }); this.emblaApi.on('resize', () => { // stop autoplay when resizing setTimeout(() => { // console.log('reinit setupProgressHandler'); this.setupProgressHandler(); }, 1000); if (['cards'].includes(this.carouselType)) { this.slidesToScroll = this.getSlideToScroll(); this.emblaApi.reInit(); } }); } clickEventListeners() { if (this.prevBtn) { this.prevBtn.addEventListener('click', () => { this.emblaApi.scrollPrev(this.jumpInsteadOfScroll); this.autoplayInstance.stop(); }); } if (this.nextBtn) { this.nextBtn.addEventListener('click', () => { this.emblaApi.scrollNext(this.jumpInsteadOfScroll); this.autoplayInstance.stop(); }); } if (this.playBtn) { this.playBtn.addEventListener('click', () => { this.toggleAutoplay(); }); } } hoverEventListeners() { const addMouseEnter = (element) => { element.addEventListener('mouseenter', () => { const delay = 1000; const isPlaying = this.autoplayInstance.isPlaying(); this.mouseEnterTimeout = setTimeout(() => { if (!isPlaying) return; this.viewportNode.setAttribute('data-uss-stopped-by-hover', 'true'); if (!this.autoplayInstance) return; this.autoplayInstance.stop(); }, delay); }); }; const addMouseLeave = (element) => { element.addEventListener('mouseleave', () => { clearTimeout(this.mouseEnterTimeout); const attr = this.viewportNode.getAttribute('data-uss-stopped-by-hover'); if (attr) { if (!this.autoplayInstance) return; this.autoplayInstance.play(); this.viewportNode.removeAttribute('data-uss-stopped-by-hover'); } }); }; [this.viewportNode].forEach((element) => { addMouseEnter(element); addMouseLeave(element); }); } // --- support event handler functions --- handleSkipNavButtonKeyDown(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const selectedSlide = this.emblaApi.selectedScrollSnap(); const slideElement = this.emblaApi.slideNodes()[selectedSlide]; const focusableElement = slideElement.querySelector(this.focusableSelectors.join(',')); if (focusableElement) focusableElement.focus(); else slideElement.focus(); } } // set focus to shortcut button by index setShortcutFocus(index) { const shortcutButton = this.shortcutButtons[index]; if (shortcutButton) shortcutButton.focus(); } attachShortcutButtonEvents(button, index) { button.addEventListener('click', () => { this.emblaApi.scrollTo(index, this.jumpInsteadOfScroll); this.autoplayInstance.stop(); }); // arrow keydown button.addEventListener('keydown', (e) => { // left arrow or up arrow if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); const prevIndex = index === 0 ? this.shortcutButtons.length - 1 : index - 1; this.setShortcutFocus(prevIndex); } if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); const nextIndex = index === this.shortcutButtons.length - 1 ? 0 : index + 1; this.setShortcutFocus(nextIndex); } }); } // ---- Accessibility functions ---- ARIA_rootNode() { this.rootNode.setAttribute('role', 'region'); this.rootNode.setAttribute('aria-roledescription', 'carousel'); } ARIA_navigationControls() { this.prevBtn.setAttribute('aria-label', 'diapositiva anterior'); this.nextBtn.setAttribute('aria-label', 'diapositiva siguiente'); } ARIA_slides() { // container this.slideContainer.setAttribute('aria-atomic', 'false'); // Slides this.slideNodes.forEach((slideNode) => { slideNode.setAttribute('role', 'group'); slideNode.setAttribute('aria-roledescription', 'slide'); }); this.slideNodes.forEach((slideNode, index) => { const labelledby = this.getSlideId(slideNode, index); if (labelledby) { slideNode.setAttribute('aria-labelledby', labelledby); } else { slideNode.setAttribute( 'aria-label', `Diapositiva ${index + 1} de ${this.slideNodes.length}`, ); } }); } ARIA_shortcuts() { if (!this.shortcutNode) return; // container this.shortcutNode.setAttribute('role', 'group'); this.shortcutNode.setAttribute('aria-label', 'Elige una diapositiva para mostrar'); // Buttons const shortcutButtons = this.shortcutNode.querySelectorAll('button'); shortcutButtons.forEach((button, index) => { const slideNode = this.slideNodes[index]; const labelledby = this.getSlideId(slideNode, index); if (labelledby) { button.setAttribute('aria-labelledby', labelledby); } else { button.setAttribute('aria-label', `Diapositiva ${index + 1} de ${this.slideNodes.length}`); } }); } reduceMotionGuard() { const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) setTimeout(() => { const isPlaying = this.emblaApi?.plugins()?.autoplay?.isPlaying(); if (!isPlaying) return; this.jumpInsteadOfScroll = true; this.autoplayInstance.stop(); this.progress = 0; this.progressIndicator.style.setProperty('--progress', `${this.progress}%`); }, 10); } // --- Utility functions --- findElement(element) { return this.rootNode.querySelector(`.${this.baseClass}__${element}`); } createElement(classNameElement, tag = 'div') { const element = document.createElement(tag); element.classList.add(`${this.baseClass}__${classNameElement}`); return element; } cleanup() { // Detach event listeners added to the carousel elements if (this.prevBtn) this.prevBtn.removeEventListener('click', this.handlePrevButtonClick); if (this.nextBtn) this.nextBtn.removeEventListener('click', this.handleNextButtonClick); if (this.playBtn) this.playBtn.removeEventListener('click', this.onPlayBtnClick); if (this.skipNavButton) { this.skipNavButton.removeEventListener('keydown', this.handleSkipNavButtonKeyDown); } this.rootNode = null; this.viewportNode = null; this.prevBtn = null; this.nextBtn = null; this.playBtn = null; this.shortcutNode = null; this.skipNavButton = null; this.shortcutButtons = []; this.autoplayInstance = null; this.slideNodes = []; this.slideContainer = null; this.whenProgressIsOver100 = []; this.everyProgressUpdate = []; this.progressIndicator = null; } // eslint-disable-next-line class-methods-use-this getSlideId(slide) { const labelledby = slide.getAttribute('aria-labelledby'); if (labelledby) return labelledby; return null; } }