vue3-carousel
Version:
A simple carousel component for Vue 3
1,230 lines (1,209 loc) • 69 kB
JavaScript
/**
* Vue 3 Carousel 0.15.0
* (c) 2025
* @license MIT
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('vue')) :
typeof define === 'function' && define.amd ? define(['exports', 'vue'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.VueCarousel = {}, global.Vue));
})(this, (function (exports, vue) { 'use strict';
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_MOUSE_WHEEL_THRESHOLD = 10;
const DEFAULT_DRAG_THRESHOLD = 0.3;
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,
mouseWheel: false,
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, threshold } = 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 dragRatio = dragValue / effectiveSlideSize;
const absRatio = Math.abs(dragRatio);
// If below the threshold, consider it no movement
if (absRatio < threshold)
return 0;
// For drags less than a full slide, move one slide in the drag direction
// For drags of a full slide or more, move the corresponding number of slides
const slidesDragged = absRatio < 1 ? Math.sign(dragRatio) : Math.round(dragRatio);
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,
}));
},
});
function useDrag(options) {
let isTouch = false;
const startPosition = { x: 0, y: 0 };
const dragged = vue.reactive({ x: 0, y: 0 });
const isDragging = vue.ref(false);
const { isSliding } = options;
const sliding = vue.computed(() => {
return typeof isSliding === 'boolean' ? isSliding : isSliding.value;
});
const handleDragStart = (event) => {
var _a;
// Prevent drag initiation on input elements or if already sliding
const targetTagName = event.target.tagName;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTagName) || sliding.value) {
return;
}
isTouch = event.type === 'touchstart';
if (!isTouch) {
event.preventDefault();
if (event.button !== 0) {
return;
}
}
startPosition.x = isTouch
? event.touches[0].clientX
: event.clientX;
startPosition.y = isTouch
? event.touches[0].clientY
: event.clientY;
const moveEvent = isTouch ? 'touchmove' : 'mousemove';
const endEvent = isTouch ? 'touchend' : 'mouseup';
document.addEventListener(moveEvent, handleDrag, { passive: false });
document.addEventListener(endEvent, handleDragEnd, { passive: true });
(_a = options.onDragStart) === null || _a === void 0 ? void 0 : _a.call(options);
};
const handleDrag = throttle((event) => {
var _a;
isDragging.value = true;
const currentX = isTouch
? event.touches[0].clientX
: event.clientX;
const currentY = isTouch
? event.touches[0].clientY
: event.clientY;
dragged.x = currentX - startPosition.x;
dragged.y = currentY - startPosition.y;
(_a = options.onDrag) === null || _a === void 0 ? void 0 : _a.call(options, { deltaX: dragged.x, deltaY: dragged.y, isTouch });
});
const handleDragEnd = () => {
var _a;
handleDrag.cancel();
if (!isTouch) {
const preventClick = (e) => {
e.preventDefault();
window.removeEventListener('click', preventClick);
};
window.addEventListener('click', preventClick);
}
(_a = options.onDragEnd) === null || _a === void 0 ? void 0 : _a.call(options);
dragged.x = 0;
dragged.y = 0;
isDragging.value = false;
const moveEvent = isTouch ? 'touchmove' : 'mousemove';
const endEvent = isTouch ? 'touchend' : 'mouseup';
document.removeEventListener(moveEvent, handleDrag);
document.removeEventListener(endEvent, handleDragEnd);
};
return {
dragged,
isDragging,
handleDragStart,
};
}
function useHover() {
const isHover = vue.ref(false);
const handleMouseEnter = () => {
isHover.value = true;
};
const handleMouseLeave = () => {
isHover.value = false;
};
return {
isHover,
handleMouseEnter,
handleMouseLeave,
};
}
function useWheel(options) {
const { isVertical, isSliding, config } = options;
// Create computed values to handle both reactive and non-reactive inputs
const vertical = vue.computed(() => {
return typeof isVertical === 'boolean' ? isVertical : isVertical.value;
});
const sliding = vue.computed(() => {
return typeof isSliding === 'boolean' ? isSliding : isSliding.value;
});
const handleScroll = (event) => {
var _a, _b;
event.preventDefault();
if (!config.mouseWheel || sliding.value) {
return;
}
// Add sensitivity threshold to prevent small movements from triggering navigation
const threshold = typeof config.mouseWheel === 'object'
? ((_a = config.mouseWheel.threshold) !== null && _a !== void 0 ? _a : DEFAULT_MOUSE_WHEEL_THRESHOLD)
: DEFAULT_MOUSE_WHEEL_THRESHOLD;
// Determine scroll direction
const deltaY = Math.abs(event.deltaY) > threshold ? event.deltaY : 0;
const deltaX = Math.abs(event.deltaX) > threshold ? event.deltaX : 0;
// If neither delta exceeds the threshold, don't navigate
if (deltaY === 0 && deltaX === 0) {
return;
}
// Determine primary delta based on carousel orientation
const primaryDelta = vertical.value ? deltaY : deltaX;
// If primaryDelta is 0, use the other delta as fallback
const effectiveDelta = primaryDelta !== 0 ? primaryDelta : vertical.value ? deltaX : deltaY;
// Positive delta means scrolling down/right
const isScrollingForward = effectiveDelta > 0;
(_b = options.onWheel) === null || _b === void 0 ? void 0 : _b.call(options, { deltaX, deltaY, isScrollingForward });
};
return {
handleScroll,
};
}
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);
},
},
clamp: {
type: Boolean,
},
// control the direction of the carousel
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;
},
},
// enable/disable the carousel component
enabled: {
default: DEFAULT_CONFIG.enabled,
type: Boolean,
},
// control the gap between slides
gap: {
default: DEFAULT_CONFIG.gap,
type: Number,
},
// set carousel height
height: {
default: DEFAULT_CONFIG.height,
type: [Number, String],
},
// aria-labels and additional text labels
i18n: {
default: DEFAULT_CONFIG.i18n,
type: Object,
},
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],
},
// slide number number of initial slide
modelValue: {
default: undefined,
type: Number,
},
// toggle mouse dragging
mouseDrag: {
default: DEFAULT_CONFIG.mouseDrag,
type: [Boolean, Object],
},
// toggle mouse wheel scrolling
mouseWheel: {
default: DEFAULT_CONFIG.mouseWheel,
type: [Boolean, Object],
},
// control mouse scroll threshold
mouseScrollThreshold: {
default: DEFAULT_CONFIG.mouseScrollThreshold,
type: Number,
},
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;
},
},
slideEffect: {
type: String,
default: DEFAULT_CONFIG.slideEffect,
validator(value) {
return SLIDE_EFFECTS.includes(value);
},
},
// control snap position alignment
snapAlign: {
default: DEFAULT_CONFIG.snapAlign,
validator(value) {
return SNAP_ALIGN_OPTIONS.includes(value);
},
},
// toggle touch dragging
touchDrag: {
default: DEFAULT_CONFIG.touchDrag,
type: [Boolean, Object],
},
// sliding transition time in ms
transition: {
default: DEFAULT_CONFIG.transition,
type: Number,
},
// control infinite scrolling mode
wrapAround: {
default: DEFAULT_CONFIG.wrapAround,
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',
'wheel',
],
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
*/
const { isHover, handleMouseEnter, handleMouseLeave } = useHover();
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);
};
/**
* 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);
const onDrag = ({ deltaX, deltaY, isTouch }) => {
var _a, _b, _c, _d;
emit('drag', { deltaX, deltaY });
const threshold = isTouch
? typeof config.touchDrag === 'object'
? ((_b = (_a = config.touchDrag) === null || _a === void 0 ? void 0 : _a.threshold) !== null && _b !== void 0 ? _b : DEFAULT_DRAG_THRESHOLD)
: DEFAULT_DRAG_THRESHOLD
: typeof config.mouseDrag === 'object'
? ((_d = (_c = config.mouseDrag) === null || _c === void 0 ? void 0 : _c.threshold) !== null && _d !== void 0 ? _d : DEFAULT_DRAG_THRESHOLD)
: DEFAULT_DRAG_THRESHOLD;
const draggedSlides = getDraggedSlidesCount({
isVertical: isVertical.value,
isReversed: isReversed.value,
dragged: { x: deltaX, y: deltaY },
effectiveSlideSize: effectiveSlideSize.value,
threshold,
});
activeSlideIndex.value = config.wrapAround
? currentSlideIndex.value + draggedSlides
: getNumberInRange({
val: currentSlideIndex.value + draggedSlides,
max: maxSlideIndex.value,
min: minSlideIndex.value,
});
};
const onDragEnd = () => slideTo(activeSlideIndex.value);
const { dragged, isDragging, handleDragStart } = useDrag({
isSliding,
onDrag,
onDragEnd,
});
const onWheel = ({ deltaX, deltaY, isScrollingForward }) => {
emit('wheel', { deltaX, deltaY });
if (isScrollingForward) {
// Scrolling down/right
if (isReversed.value) {
prev();
}
else {
next();
}
}
else {
// Scrolling up/left
if (isReversed.value) {
next();
}
else {
prev();
}
}
};
const { handleScroll } = useWheel({
isVertical,
isSliding,
config,
onWheel,
});
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 maxSl