UNPKG

@tofandel/vue3-carousel

Version:

A simple carousel component for Vue 3

1,335 lines (1,312 loc) 57.8 kB
/** * Vue 3 Carousel 0.14.4 * (c) 2025 * @license MIT */ 'use strict'; var vue = require('vue'); const BREAKPOINT_MODE_OPTIONS = ['viewport', 'carousel']; const DIR_MAP = { 'bottom-to-top': 'btt', 'left-to-right': 'ltr', 'right-to-left': 'rtl', 'top-to-bottom': 'ttb', }; const DIR_OPTIONS = [ 'ltr', 'left-to-right', 'rtl', 'right-to-left', 'ttb', 'top-to-bottom', 'btt', 'bottom-to-top', ]; const I18N_DEFAULT_CONFIG = { ariaGallery: 'Gallery', ariaNavigateToPage: 'Navigate to page {slideNumber}', ariaNavigateToSlide: 'Navigate to slide {slideNumber}', ariaNextSlide: 'Navigate to next slide', ariaPreviousSlide: 'Navigate to previous slide', iconArrowDown: 'Arrow pointing downwards', iconArrowLeft: 'Arrow pointing to the left', iconArrowRight: 'Arrow pointing to the right', iconArrowUp: 'Arrow pointing upwards', itemXofY: 'Item {currentSlide} of {slidesCount}', }; const NORMALIZED_DIR_OPTIONS = Object.values(DIR_MAP); const SLIDE_EFFECTS = ['slide', 'fade']; const SNAP_ALIGN_OPTIONS = [ 'center', 'start', 'end', 'center-even', 'center-odd', ]; const DEFAULT_CONFIG = { autoplay: 0, breakpointMode: BREAKPOINT_MODE_OPTIONS[0], breakpoints: undefined, dir: DIR_OPTIONS[0], enabled: true, gap: 0, height: 'auto', i18n: I18N_DEFAULT_CONFIG, ignoreAnimations: false, itemsToScroll: 1, itemsToShow: 1, modelValue: 0, mouseDrag: true, pauseAutoplayOnHover: false, preventExcessiveDragging: false, slideEffect: SLIDE_EFFECTS[0], snapAlign: SNAP_ALIGN_OPTIONS[0], touchDrag: true, transition: 300, wrapAround: false, }; // Use a symbol for inject provide to avoid any kind of collision with another lib // https://vuejs.org/guide/components/provide-inject#working-with-symbol-keys const injectCarousel = Symbol('carousel'); const createSlideRegistry = (emit) => { const slides = vue.shallowReactive([]); const updateSlideIndexes = (startIndex) => { if (startIndex !== undefined) { slides.slice(startIndex).forEach((slide, offset) => { var _a; (_a = slide.exposed) === null || _a === void 0 ? void 0 : _a.setIndex(startIndex + offset); }); } else { slides.forEach((slide, index) => { var _a; (_a = slide.exposed) === null || _a === void 0 ? void 0 : _a.setIndex(index); }); } }; return { cleanup: () => { slides.splice(0, slides.length); }, getSlides: () => slides, registerSlide: (slide, index) => { if (!slide) return; if (slide.props.isClone) { return; } const slideIndex = index !== null && index !== void 0 ? index : slides.length; slides.splice(slideIndex, 0, slide); updateSlideIndexes(slideIndex); emit('slide-registered', { slide, index: slideIndex }); }, unregisterSlide: (slide) => { const slideIndex = slides.indexOf(slide); if (slideIndex === -1) return; emit('slide-unregistered', { slide, index: slideIndex }); slides.splice(slideIndex, 1); updateSlideIndexes(slideIndex); }, }; }; function calculateAverage(numbers) { if (numbers.length === 0) return 0; const sum = numbers.reduce((acc, num) => acc + num, 0); return sum / numbers.length; } function createCloneSlides({ slides, position, toShow }) { const clones = []; const isBefore = position === 'before'; const start = isBefore ? -toShow : 0; const end = isBefore ? 0 : toShow; if (slides.length <= 0) { return clones; } for (let i = start; i < end; i++) { const index = isBefore ? i : i + slides.length; const props = { index, isClone: true, id: undefined, // Make sure we don't duplicate the id which would be invalid html key: `clone-${position}-${i}`, }; const vnode = slides[((i % slides.length) + slides.length) % slides.length].vnode; const clone = vue.cloneVNode(vnode, props); clone.el = null; clones.push(clone); } return clones; } const FOCUSABLE_ELEMENTS_SELECTOR = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'; /** * Disables keyboard tab navigation for all focusable child elements * @param node Vue virtual node containing the elements to disable */ function disableChildrenTabbing(node) { if (!node.el || !(node.el instanceof Element)) { return; } const elements = node.el.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR); for (const el of elements) { if (el instanceof HTMLElement && !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true') { el.setAttribute('tabindex', '-1'); } } } /** Useful function to destructure props without triggering reactivity for certain keys */ function except(obj, keys) { return Object.keys(obj).filter((k) => !keys.includes(k)) .reduce((acc, key) => (acc[key] = obj[key], acc), {}); } /** * Calculates the number of slides to move based on drag movement * @param params Configuration parameters for drag calculation * @returns Number of slides to move (positive or negative) */ function getDraggedSlidesCount(params) { const { isVertical, isReversed, dragged, effectiveSlideSize } = params; // Get drag value based on direction const dragValue = isVertical ? dragged.y : dragged.x; // If no drag, return +0 explicitly if (dragValue === 0) return 0; const slidesDragged = Math.round(dragValue / effectiveSlideSize); return isReversed ? slidesDragged : -slidesDragged; } function getNumberInRange({ val, max, min }) { if (max < min) { return val; } return Math.min(Math.max(val, isNaN(min) ? val : min), isNaN(max) ? val : max); } function getTransformValues(el) { const { transform } = window.getComputedStyle(el); //add sanity check return transform .split(/[(,)]/) .slice(1, -1) .map((v) => parseFloat(v)); } function getScaleMultipliers(transformElements) { let widthMultiplier = 1; let heightMultiplier = 1; transformElements.forEach((el) => { const transformArr = getTransformValues(el); if (transformArr.length === 6) { widthMultiplier /= transformArr[0]; heightMultiplier /= transformArr[3]; } }); return { widthMultiplier, heightMultiplier }; } /** * Calculates the snap align offset for a carousel item based on items to show. * Returns the number of slides to offset. * * @param align - The alignment type. * @param itemsToShow - The number of items to show. * @returns The calculated offset. */ function getSnapAlignOffsetByItemsToShow(align, itemsToShow) { switch (align) { case 'start': return 0; case 'center': case 'center-odd': return (itemsToShow - 1) / 2; case 'center-even': return (itemsToShow - 2) / 2; case 'end': return itemsToShow - 1; default: return 0; } } /** * Calculates the snap align offset for a carousel item based on slide and viewport size. * Returns the real width to offset. * * @param align - The alignment type. * @param slideSize - The size of the slide. * @param viewportSize - The size of the viewport. * @returns The calculated offset. */ function getSnapAlignOffsetBySlideAndViewport(align, slideSize, viewportSize) { switch (align) { case 'start': return 0; case 'center': case 'center-odd': return (viewportSize - slideSize) / 2; case 'center-even': return viewportSize / 2 - slideSize; case 'end': return viewportSize - slideSize; default: return 0; } } /** * Calculates the snap align offset for a carousel item. * * @param params - The parameters for calculating the offset. * @returns The calculated offset. */ function getSnapAlignOffset({ slideSize, viewportSize, align, itemsToShow, }) { if (itemsToShow !== undefined) { return getSnapAlignOffsetByItemsToShow(align, itemsToShow); } if (slideSize !== undefined && viewportSize !== undefined) { return getSnapAlignOffsetBySlideAndViewport(align, slideSize, viewportSize); } return 0; } function i18nFormatter(string = '', values = {}) { return Object.entries(values).reduce((acc, [key, value]) => acc.replace(`{${key}}`, String(value)), string); } function mapNumberToRange({ val, max, min = 0 }) { const mod = max - min + 1; return ((((val - min) % mod) + mod) % mod) + min; } /** * Returns a throttled version of the function using requestAnimationFrame. * * @param fn - The function to throttle. * @param ms - The number of milliseconds to wait for the throttled function to be called again */ function throttle(fn, ms = 0) { let isThrottled = false; let start = 0; let frameId = null; function throttled(...args) { if (isThrottled) return; isThrottled = true; const step = () => { frameId = requestAnimationFrame((time) => { const elapsed = time - start; if (elapsed > ms) { start = time; fn(...args); isThrottled = false; } else { step(); } }); }; step(); } throttled.cancel = () => { if (frameId) { cancelAnimationFrame(frameId); frameId = null; isThrottled = false; } }; return throttled; } /** * Converts a value to a CSS-compatible string. * @param value - The value to convert. * @returns The CSS-compatible string. **/ function toCssValue(value, unit = 'px') { if (value === null || value === undefined || value === '') { return undefined; } if (typeof value === 'number' || parseFloat(value).toString() === value) { return `${value}${unit}`; } return value; } const ARIA = vue.defineComponent({ name: 'CarouselAria', setup() { const carousel = vue.inject(injectCarousel); if (!carousel) { return () => ''; } return () => vue.h('div', { class: ['carousel__liveregion', 'carousel__sr-only'], 'aria-live': 'polite', 'aria-atomic': 'true', }, i18nFormatter(carousel.config.i18n['itemXofY'], { currentSlide: carousel.currentSlide + 1, slidesCount: carousel.slidesCount, })); }, }); const carouselProps = { // time to auto advance slides in ms autoplay: { default: DEFAULT_CONFIG.autoplay, type: Number, }, // an object to store breakpoints breakpoints: { default: DEFAULT_CONFIG.breakpoints, type: Object, }, // controls the breakpoint mode relative to the carousel container or the viewport breakpointMode: { default: DEFAULT_CONFIG.breakpointMode, validator(value) { return BREAKPOINT_MODE_OPTIONS.includes(value); }, }, // enable/disable the carousel component enabled: { default: DEFAULT_CONFIG.enabled, type: Boolean, }, // control the gap between slides gap: { default: DEFAULT_CONFIG.gap, type: Number, }, // control the gap between slides height: { default: DEFAULT_CONFIG.height, type: [Number, String], }, ignoreAnimations: { default: false, type: [Array, Boolean, String], }, // count of items to be scrolled itemsToScroll: { default: DEFAULT_CONFIG.itemsToScroll, type: Number, }, // count of items to showed per view itemsToShow: { default: DEFAULT_CONFIG.itemsToShow, type: [Number, String], }, // aria-labels and additional text labels i18n: { default: DEFAULT_CONFIG.i18n, type: Object, }, // slide number number of initial slide modelValue: { default: undefined, type: Number, }, // toggle mouse dragging. mouseDrag: { default: DEFAULT_CONFIG.mouseDrag, type: Boolean, }, // toggle mouse dragging. touchDrag: { default: DEFAULT_CONFIG.touchDrag, type: Boolean, }, pauseAutoplayOnHover: { default: DEFAULT_CONFIG.pauseAutoplayOnHover, type: Boolean, }, preventExcessiveDragging: { default: false, type: Boolean, validator(value, props) { if (value && props.wrapAround) { console.warn(`[vue3-carousel]: "preventExcessiveDragging" cannot be used with wrapAround. The setting will be ignored.`); } return true; }, }, // control snap position alignment snapAlign: { default: DEFAULT_CONFIG.snapAlign, validator(value) { return SNAP_ALIGN_OPTIONS.includes(value); }, }, slideEffect: { type: String, default: DEFAULT_CONFIG.slideEffect, validator(value) { return SLIDE_EFFECTS.includes(value); }, }, // sliding transition time in ms transition: { default: DEFAULT_CONFIG.transition, type: Number, }, // control the gap between slides dir: { type: String, default: DEFAULT_CONFIG.dir, validator(value, props) { // The value must match one of these strings if (!DIR_OPTIONS.includes(value)) { return false; } const normalizedDir = value in DIR_MAP ? DIR_MAP[value] : value; if (['ttb', 'btt'].includes(normalizedDir) && (!props.height || props.height === 'auto')) { console.warn(`[vue3-carousel]: The dir "${value}" is not supported with height "auto".`); } return true; }, }, // control infinite scrolling mode wrapAround: { default: DEFAULT_CONFIG.wrapAround, type: Boolean, }, clamp: { type: Boolean, } }; const Carousel = vue.defineComponent({ name: 'VueCarousel', props: carouselProps, emits: [ 'before-init', 'drag', 'init', 'loop', 'slide-end', 'slide-registered', 'slide-start', 'slide-unregistered', 'update:modelValue', ], setup(props, { slots, emit, expose }) { var _a; const slideRegistry = createSlideRegistry(emit); const slides = slideRegistry.getSlides(); const slidesCount = vue.computed(() => slides.length); const root = vue.ref(null); const viewport = vue.ref(null); const slideSize = vue.ref(0); const fallbackConfig = vue.computed(() => (Object.assign(Object.assign(Object.assign({}, DEFAULT_CONFIG), except(props, ['breakpoints', 'modelValue'])), { i18n: Object.assign(Object.assign({}, DEFAULT_CONFIG.i18n), props.i18n) }))); // current active config const config = vue.shallowReactive(Object.assign({}, fallbackConfig.value)); // slides const currentSlideIndex = vue.ref((_a = props.modelValue) !== null && _a !== void 0 ? _a : 0); const activeSlideIndex = vue.ref(currentSlideIndex.value); vue.watch(currentSlideIndex, (val) => (activeSlideIndex.value = val)); const prevSlideIndex = vue.ref(0); const middleSlideIndex = vue.computed(() => Math.ceil((slidesCount.value - 1) / 2)); const maxSlideIndex = vue.computed(() => slidesCount.value - 1); const minSlideIndex = vue.computed(() => 0); let autoplayTimer = null; let transitionTimer = null; let resizeObserver = null; const effectiveSlideSize = vue.computed(() => slideSize.value + config.gap); const normalizedDir = vue.computed(() => { const dir = config.dir || 'ltr'; return dir in DIR_MAP ? DIR_MAP[dir] : dir; }); const isReversed = vue.computed(() => ['rtl', 'btt'].includes(normalizedDir.value)); const isVertical = vue.computed(() => ['ttb', 'btt'].includes(normalizedDir.value)); const isAuto = vue.computed(() => config.itemsToShow === 'auto'); const dimension = vue.computed(() => (isVertical.value ? 'height' : 'width')); function updateBreakpointsConfig() { var _a; if (!mounted.value) { return; } // Determine the width source based on the 'breakpointMode' config const widthSource = (fallbackConfig.value.breakpointMode === 'carousel' ? (_a = root.value) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect().width : typeof window !== 'undefined' ? window.innerWidth : 0) || 0; const breakpointsArray = Object.keys(props.breakpoints || {}) .map((key) => Number(key)) .sort((a, b) => +b - +a); const newConfig = {}; breakpointsArray.some((breakpoint) => { if (widthSource >= breakpoint) { Object.assign(newConfig, props.breakpoints[breakpoint]); if (newConfig.i18n) { Object.assign(newConfig.i18n, fallbackConfig.value.i18n, props.breakpoints[breakpoint].i18n); } return true; } return false; }); Object.assign(config, fallbackConfig.value, newConfig); // Validate itemsToShow if (!isAuto.value) { config.itemsToShow = getNumberInRange({ val: Number(config.itemsToShow), max: props.clamp ? slidesCount.value : Infinity, min: 1, }); } } const handleResize = throttle(() => { updateBreakpointsConfig(); updateSlidesData(); updateSlideSize(); }); const transformElements = vue.shallowReactive(new Set()); /** * Setup functions */ const slidesRect = vue.ref([]); function updateSlidesRectSize({ widthMultiplier, heightMultiplier, }) { slidesRect.value = slides.map((slide) => { var _a; const rect = (_a = slide.exposed) === null || _a === void 0 ? void 0 : _a.getBoundingRect(); return { width: rect.width * widthMultiplier, height: rect.height * heightMultiplier, }; }); } const viewportRect = vue.ref({ width: 0, height: 0, }); function updateViewportRectSize({ widthMultiplier, heightMultiplier, }) { var _a; const rect = ((_a = viewport.value) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect()) || { width: 0, height: 0 }; viewportRect.value = { width: rect.width * widthMultiplier, height: rect.height * heightMultiplier, }; } function updateSlideSize() { if (!viewport.value) return; const scaleMultipliers = getScaleMultipliers(transformElements); updateViewportRectSize(scaleMultipliers); updateSlidesRectSize(scaleMultipliers); if (isAuto.value) { slideSize.value = calculateAverage(slidesRect.value.map((slide) => slide[dimension.value])); } else { const itemsToShow = Number(config.itemsToShow); const totalGap = (itemsToShow - 1) * config.gap; slideSize.value = (viewportRect.value[dimension.value] - totalGap) / itemsToShow; } } function updateSlidesData() { if (!config.wrapAround && slidesCount.value > 0) { currentSlideIndex.value = getNumberInRange({ val: currentSlideIndex.value, max: maxSlideIndex.value, min: minSlideIndex.value, }); } } const ignoreAnimations = vue.computed(() => { if (typeof props.ignoreAnimations === 'string') { return props.ignoreAnimations.split(','); } else if (Array.isArray(props.ignoreAnimations)) { return props.ignoreAnimations; } else if (!props.ignoreAnimations) { return []; } return false; }); vue.watchEffect(() => updateSlidesData()); vue.watchEffect(() => { // Call updateSlideSize when viewport is ready and track deps updateSlideSize(); }); let animationInterval; const setAnimationInterval = (event) => { const target = event.target; if (!(target === null || target === void 0 ? void 0 : target.contains(root.value)) || (Array.isArray(ignoreAnimations.value) && ignoreAnimations.value.includes(event.animationName))) { return; } transformElements.add(target); if (!animationInterval) { const stepAnimation = () => { animationInterval = requestAnimationFrame(() => { updateSlideSize(); stepAnimation(); }); }; stepAnimation(); } }; const finishAnimation = (event) => { const target = event.target; if (target) { transformElements.delete(target); } if (animationInterval && transformElements.size === 0) { cancelAnimationFrame(animationInterval); updateSlideSize(); } }; const mounted = vue.ref(false); if (typeof document !== 'undefined') { vue.watchEffect(() => { if (mounted.value && ignoreAnimations.value !== false) { document.addEventListener('animationstart', setAnimationInterval); document.addEventListener('animationend', finishAnimation); } else { document.removeEventListener('animationstart', setAnimationInterval); document.removeEventListener('animationend', finishAnimation); } }); } vue.onMounted(() => { mounted.value = true; updateBreakpointsConfig(); initAutoplay(); if (root.value) { resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(root.value); } emit('init'); }); vue.onBeforeUnmount(() => { mounted.value = false; slideRegistry.cleanup(); if (transitionTimer) { clearTimeout(transitionTimer); } if (animationInterval) { cancelAnimationFrame(animationInterval); } if (autoplayTimer) { clearInterval(autoplayTimer); } if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } if (typeof document !== 'undefined') { handleBlur(); } if (root.value) { root.value.removeEventListener('transitionend', updateSlideSize); root.value.removeEventListener('animationiteration', updateSlideSize); } }); /** * Carousel Event listeners */ let isTouch = false; const startPosition = { x: 0, y: 0 }; const dragged = vue.reactive({ x: 0, y: 0 }); const isHover = vue.ref(false); const isDragging = vue.ref(false); const handleMouseEnter = () => { isHover.value = true; }; const handleMouseLeave = () => { isHover.value = false; }; const handleArrowKeys = throttle((event) => { if (event.ctrlKey) return; switch (event.key) { case 'ArrowLeft': case 'ArrowUp': if (isVertical.value === event.key.endsWith('Up')) { if (isReversed.value) { next(true); } else { prev(true); } } break; case 'ArrowRight': case 'ArrowDown': if (isVertical.value === event.key.endsWith('Down')) { if (isReversed.value) { prev(true); } else { next(true); } } break; } }, 200); const handleFocus = () => { document.addEventListener('keydown', handleArrowKeys); }; const handleBlur = () => { document.removeEventListener('keydown', handleArrowKeys); }; function handleDragStart(event) { // Prevent drag initiation on input elements or if already sliding const targetTagName = event.target.tagName; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTagName) || isSliding.value) { return; } // Detect if the event is a touchstart or mousedown event isTouch = event.type === 'touchstart'; // For mouse events, prevent default to avoid text selection if (!isTouch) { event.preventDefault(); if (event.button !== 0) { // Ignore non-left-click mouse events return; } } // Initialize start positions for the drag startPosition.x = 'touches' in event ? event.touches[0].clientX : event.clientX; startPosition.y = 'touches' in event ? event.touches[0].clientY : event.clientY; // Attach event listeners for dragging and drag end const moveEvent = isTouch ? 'touchmove' : 'mousemove'; const endEvent = isTouch ? 'touchend' : 'mouseup'; document.addEventListener(moveEvent, handleDragging, { passive: false }); document.addEventListener(endEvent, handleDragEnd, { passive: true }); } const handleDragging = throttle((event) => { isDragging.value = true; // Get the current position based on the interaction type (touch or mouse) const currentX = 'touches' in event ? event.touches[0].clientX : event.clientX; const currentY = 'touches' in event ? event.touches[0].clientY : event.clientY; // Calculate deltas for X and Y axes dragged.x = currentX - startPosition.x; dragged.y = currentY - startPosition.y; const draggedSlides = getDraggedSlidesCount({ isVertical: isVertical.value, isReversed: isReversed.value, dragged, effectiveSlideSize: effectiveSlideSize.value, }); activeSlideIndex.value = config.wrapAround ? currentSlideIndex.value + draggedSlides : getNumberInRange({ val: currentSlideIndex.value + draggedSlides, max: maxSlideIndex.value, min: minSlideIndex.value, }); // Emit a drag event for further customization if needed emit('drag', { deltaX: dragged.x, deltaY: dragged.y }); }); function handleDragEnd() { handleDragging.cancel(); // Prevent accidental clicks when there is a slide drag if (activeSlideIndex.value !== currentSlideIndex.value && !isTouch) { const preventClick = (e) => { e.preventDefault(); window.removeEventListener('click', preventClick); }; window.addEventListener('click', preventClick); } slideTo(activeSlideIndex.value); // Reset drag state dragged.x = 0; dragged.y = 0; isDragging.value = false; const moveEvent = isTouch ? 'touchmove' : 'mousemove'; const endEvent = isTouch ? 'touchend' : 'mouseup'; document.removeEventListener(moveEvent, handleDragging); document.removeEventListener(endEvent, handleDragEnd); } /** * Autoplay */ function initAutoplay() { if (!config.autoplay || config.autoplay <= 0) { return; } autoplayTimer = setInterval(() => { if (config.pauseAutoplayOnHover && isHover.value) { return; } next(); }, config.autoplay); } function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } } function resetAutoplay() { stopAutoplay(); initAutoplay(); } /** * Navigation function */ const isSliding = vue.ref(false); function slideTo(slideIndex, skipTransition = false) { if (!skipTransition && isSliding.value) { return; } let targetIndex = slideIndex; let mappedIndex = slideIndex; prevSlideIndex.value = currentSlideIndex.value; if (!config.wrapAround) { targetIndex = getNumberInRange({ val: targetIndex, max: maxSlideIndex.value, min: minSlideIndex.value, }); } else { mappedIndex = mapNumberToRange({ val: targetIndex, max: maxSlideIndex.value, min: minSlideIndex.value, }); } emit('slide-start', { slidingToIndex: slideIndex, currentSlideIndex: currentSlideIndex.value, prevSlideIndex: prevSlideIndex.value, slidesCount: slidesCount.value, }); stopAutoplay(); isSliding.value = true; currentSlideIndex.value = targetIndex; if (mappedIndex !== targetIndex) { modelWatcher.pause(); } emit('update:modelValue', mappedIndex); const transitionCallback = () => { if (config.wrapAround && mappedIndex !== targetIndex) { modelWatcher.resume(); currentSlideIndex.value = mappedIndex; emit('loop', { currentSlideIndex: currentSlideIndex.value, slidingToIndex: slideIndex, }); } emit('slide-end', { currentSlideIndex: currentSlideIndex.value, prevSlideIndex: prevSlideIndex.value, slidesCount: slidesCount.value, }); isSliding.value = false; resetAutoplay(); }; transitionTimer = setTimeout(transitionCallback, config.transition); } function next(skipTransition = false) { slideTo(currentSlideIndex.value + config.itemsToScroll, skipTransition); } function prev(skipTransition = false) { slideTo(currentSlideIndex.value - config.itemsToScroll, skipTransition); } function restartCarousel() { updateBreakpointsConfig(); updateSlidesData(); updateSlideSize(); resetAutoplay(); } // Update the carousel on props change vue.watch(() => [fallbackConfig.value, props.breakpoints], () => updateBreakpointsConfig(), { deep: true }); vue.watch(() => props.autoplay, () => resetAutoplay()); // Handle changing v-model value const modelWatcher = vue.watch(() => props.modelValue, (val) => { if (val === currentSlideIndex.value) { return; } slideTo(Number(val), true); }); // Init carousel emit('before-init'); const clonedSlidesCount = vue.computed(() => { if (!config.wrapAround) { return { before: 0, after: 0 }; } if (isAuto.value) { return { before: slides.length, after: slides.length }; } const itemsToShow = Number(config.itemsToShow); const slidesToClone = Math.ceil(itemsToShow + (config.itemsToScroll - 1)); const before = slidesToClone - activeSlideIndex.value; const after = slidesToClone - (slidesCount.value - (activeSlideIndex.value + 1)); return { before: Math.max(0, before), after: Math.max(0, after), }; }); const clonedSlidesOffset = vue.computed(() => { if (!clonedSlidesCount.value.before) { return 0; } if (isAuto.value) { return (slidesRect.value .slice(-1 * clonedSlidesCount.value.before) .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) * -1); } return clonedSlidesCount.value.before * effectiveSlideSize.value * -1; }); const snapAlignOffset = vue.computed(() => { var _a; if (isAuto.value) { const slideIndex = ((currentSlideIndex.value % slides.length) + slides.length) % slides.length; return getSnapAlignOffset({ slideSize: (_a = slidesRect.value[slideIndex]) === null || _a === void 0 ? void 0 : _a[dimension.value], viewportSize: viewportRect.value[dimension.value], align: config.snapAlign, }); } return getSnapAlignOffset({ align: config.snapAlign, itemsToShow: +config.itemsToShow, }); }); const scrolledOffset = vue.computed(() => { let output = 0; if (isAuto.value) { if (currentSlideIndex.value < 0) { output = slidesRect.value .slice(currentSlideIndex.value) .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) * -1; } else { output = slidesRect.value .slice(0, currentSlideIndex.value) .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0); } output -= snapAlignOffset.value; // remove whitespace if (!config.wrapAround) { const maxSlidingValue = slidesRect.value.reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) - viewportRect.value[dimension.value] - config.gap; output = getNumberInRange({ val: output, max: maxSlidingValue, min: 0, }); } } else { let scrolledSlides = currentSlideIndex.value - snapAlignOffset.value; // remove whitespace if (!config.wrapAround) { scrolledSlides = getNumberInRange({ val: scrolledSlides, max: slidesCount.value - +config.itemsToShow, min: 0, }); } output = scrolledSlides * effectiveSlideSize.value; } return output * (isReversed.value ? 1 : -1); }); const visibleRange = vue.computed(() => { var _a, _b; if (!isAuto.value) { const base = currentSlideIndex.value - snapAlignOffset.value; if (config.wrapAround) { return { min: Math.floor(base), max: Math.ceil(base + Number(config.itemsToShow) - 1), }; } return { min: Math.floor(getNumberInRange({ val: base, max: slidesCount.value - Number(config.itemsToShow), min: 0, })), max: Math.ceil(getNumberInRange({ val: base + Number(config.itemsToShow) - 1, max: slidesCount.value - 1, min: 0, })), }; } // Auto width mode let minIndex = 0; { let accumulatedSize = 0; let index = 0 - clonedSlidesCount.value.before; const offset = Math.abs(scrolledOffset.value + clonedSlidesOffset.value); while (accumulatedSize <= offset) { const normalizedIndex = ((index % slides.length) + slides.length) % slides.length; accumulatedSize += ((_a = slidesRect.value[normalizedIndex]) === null || _a === void 0 ? void 0 : _a[dimension.value]) + config.gap; index++; } minIndex = index - 1; } let maxIndex = 0; { let index = minIndex; let accumulatedSize = 0; if (index < 0) { accumulatedSize = slidesRect.value .slice(0, index) .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) - Math.abs(scrolledOffset.value + clonedSlidesOffset.value); } else { accumulatedSize = slidesRect.value .slice(0, index) .reduce((acc, slide) => acc + slide[dimension.value] + config.gap, 0) - Math.abs(scrolledOffset.value); } while (accumulatedSize < viewportRect.value[dimension.value]) { const normalizedIndex = ((index % slides.length) + slides.length) % slides.length; accumulatedSize += ((_b = slidesRect.value[normalizedIndex]) === null || _b === void 0 ? void 0 : _b[dimension.value]) + config.gap; index++; } maxIndex = index - 1; } return { min: Math.floor(minIndex), max: Math.ceil(maxIndex), }; }); const trackTransform = vue.computed(() => { if (config.slideEffect === 'fade') { return undefined; } const translateAxis = isVertical.value ? 'Y' : 'X'; // Include user drag interaction offset const dragOffset = isVertical.value ? dragged.y : dragged.x; let totalOffset = scrolledOffset.value + dragOffset; if (!config.wrapAround && config.preventExcessiveDragging) { let maxSlidingValue = 0; if (isAuto.value) { maxSlidingValue = slidesRect.value.reduce((acc, slide) => acc + slide[dimension.value], 0); } else { maxSlidingValue = (slidesCount.value - Number(config.itemsToShow)) * effectiveSlideSize.value; } const min = isReversed.value ? 0 : -1 * maxSlidingValue; const max = isReversed.value ? maxSlidingValue : 0; totalOffset = getNumberInRange({ val: totalOffset, min, max, }); } return `translate${translateAxis}(${totalOffset}px)`; }); const carouselStyle = vue.computed(() => ({ '--vc-transition-duration': isSliding.value ? toCssValue(config.transition, 'ms') : undefined, '--vc-slide-gap': toCssValue(config.gap), '--vc-carousel-height': toCssValue(config.height), '--vc-cloned-offset': toCssValue(clonedSlidesOffset.value), })); const nav = { slideTo, next, prev }; const provided = vue.reactive({ activeSlide: activeSlideIndex, config, currentSlide: currentSlideIndex, isSliding, isVertical, maxSlide: maxSlideIndex, minSlide: minSlideIndex, nav, normalizedDir, slideRegistry, slideSize, slides, slidesCount, viewport, visibleRange, }); vue.provide(injectCarousel, provided); const data = vue.reactive({ config, currentSlide: currentSlideIndex, maxSlide: maxSlideIndex, middleSlide: middleSlideIndex, minSlide: minSlideIndex, slideSize, slidesCount, }); expose(vue.reactive(Object.assign({ data, next, prev, restartCarousel, slideTo, updateBreakpointsConfig, updateSlideSize, updateSlidesData }, vue.toRefs(provided)))); return () => { var _a; const slotSlides = slots.default || slots.slides; const outputSlides = (slotSlides === null || slotSlides === void 0 ? void 0 : slotSlides(data)) || []; const { before, after } = clonedSlidesCount.value; const slidesBefore = createCloneSlides({ slides, position: 'before', toShow: before, }); const slidesAfter = createCloneSlides({ slides, position: 'after', toShow: after, }); const output = [...slidesBefore, ...outputSlides, ...slidesAfter]; if (!config.enabled || !output.length) { return vue.h('section', { ref: root, class: ['carousel', 'is-disabled'], }, output); } const addonsElements = ((_a = slots.addons) === null || _a === void 0 ? void 0 : _a.call(slots, data)) || []; const trackEl = vue.h('ol', { class: 'carousel__track', style: { transform: trackTransform.value }, onMousedownCapture: config.mouseDrag ? handleDragStart : null, onTouchstartPassiveCapture: config.touchDrag ? handleDragStart : null, }, output); const viewPortEl = vue.h('div', { class: 'carousel__viewport', ref: viewport }, trackEl); return vue.h('section', { ref: root, class: [ 'carousel', `is-${normalizedDir.value}`, `is-effect-${config.slideEffect}`, { 'is-vertical': isVertical.value, 'is-sliding': isSliding.value, 'is-dragging': isDragging.value, 'is-hover': isHover.value, }, ], dir: normalizedDir.value, style: carouselStyle.value, 'aria-label': config.i18n['ariaGallery'], tabindex: '0', onFocus: handleFocus, onBlur: handleBlur, onMouseenter: handleMouseEnter, onMouseleave: handleMouseLeave, }, [viewPortEl, addonsElements, vue.h(ARIA)]); }; }, }); var IconName; (function (IconName) { IconName["arrowDown"] = "arrowDown"; IconName["arrowLeft"] = "arrowLeft"; IconName["arrowRight"] = "arrowRight"; IconName["arrowUp"] = "arrowUp"; })(IconName || (IconName = {})); const iconI18n = (name) => `icon${name.charAt(0).toUpperCase() + name.slice(1)}`; const icons = { arrowDown: 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z', arrowLeft: 'M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z', arrowRight: 'M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z', arrowUp: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z', }; function isIconName(candidate) { return candidate in IconName; } const validateIconName = (value) => { return value && isIconName(value); }; const Icon = vue.defineComponent({ props: { name: { type: String, required: true, validator: validateIconName, }, title: { type: String, default: (props) => props.name ? DEFAULT_CONFIG.i18n[iconI18n(props.name)] : '', }, }, setup(props) { const carousel = vue.inject(injectCarousel, null); return () => { const iconName = props.name; if (!iconName || !validateIconName(iconName)) return; const path = icons[iconName]; const pathEl = vue.h('path', { d: path }); const iconTitle = (carousel === null || carousel === void 0 ? void 0 : carousel.config.i18n[iconI18n(iconName)]) || props.title; const titleEl = vue.h('title', iconTitle); return vue.h('svg', { class: 'carousel__icon', viewBox: '0 0 24 24', role: 'img', 'aria-label': iconTitle, }, [titleEl, pathEl]); }; }, }); const Navigation = vue.defineComponent({ name: 'CarouselNavigation', inheritAttrs: false, props: { carousel: { type: Object, }, }, setup(props, { slots, attrs }) { let carousel = vue.inject(injectCarousel, null); const { next: slotNext, prev: slotPrev } = slots; const getPrevIcon = () => { const directionIcons = { btt: 'arrowDown', ltr: 'arrowLeft', rtl: 'arrowRight', ttb: 'arrowUp', }; return directionIcons[carousel.normalizedDir]; }; const getNextIcon = () => { const directionIcons = { btt: 'arrowUp', ltr: 'arrowRight', rtl: 'arrowLeft', ttb: 'arrowDown', }; return directionIcons[carousel.normalizedDir]; }; const prevDisabled = vue.computed(() => !carousel.config.wrapAround && carousel.currentSlide <= carousel.minSlide); const nextDisabled = vue.computed(() => !carousel.config.wrapAround && carousel.currentSlide >= carousel.maxSlide); return () => { if (props.carousel) { carousel = props.carousel; } if (!carousel) { console.warn('[vue3-carousel]: A carousel component must be provided for the navigation component to display'); return ''; } const { i18n } = carousel.config; const prevButton = vue.h('button', Object.assign(Object.assign({ type: 'button', disabled: prevDisabled.value, 'aria-label': i18n['ariaPreviousSlide'], title: i18n['ariaPreviousSlide'], onClick: carousel.nav.prev }, attrs), { class: [ 'carousel__prev', { 'carousel__prev--disabled': prevDisabled.value }, attrs.class, ] }), (slotPrev === null || slotPrev === void 0 ? void 0 : slotPrev()) || vue.h(Icon, { name: getPrevIcon() })); const nextButton = vue.h('button', Object.assign(Object.assign({ type: 'button', disabled: nextDisabled.value, 'aria-label': i18n['ariaNextSlide'], title: i18n['ariaNextSlide'], onClick: carousel.nav.next }, attrs), { class: [ 'carousel__next', { 'carousel__next--disabled': nextDisabled.value }, attrs.class, ] }), (slotNext === null || slotNext === void 0 ? void 0 : slotNext()) || vue.h(Icon, { name: getNextIcon() })); return [prevButton, nextButton]; }; }, }); const Pagination = vue.defineComponent({ name: 'CarouselPagination', props: { disableOnClick: { type: Boolean, }, paginateByItemsToShow: { type: Boolean, }, carous