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