@ussebastian/kitdigital
Version:
Kit Digital de la Universidad San Sebastián
620 lines (534 loc) • 20 kB
JavaScript
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;
}
}