diff --git a/docs/api/data.md b/docs/api/data.md index 35b2f2a8..d4c46f7a 100644 --- a/docs/api/data.md +++ b/docs/api/data.md @@ -21,13 +21,12 @@ if (myCarousel.data.currentSlide === 10) { ## Available Data -| Data | Description | -| ---------------- | -------------------------------------------------------------------------- | -| `config` | the current carousel configuration | -| `slidesCount` | slides total count | -| ~~`slideWidth`~~ | ~~single slide width~~ | -| `slideSize` | single slide width or height | -| `currentSlide` | current slide index | -| `maxSlide` | maximum slide index | -| `minSlide` | minimum slide index | -| `middleSlide` | middle slide index | +| Data | Description | +| -------------- | ---------------------------------- | +| `config` | the current carousel configuration | +| `currentSlide` | current slide index | +| `maxSlide` | maximum slide index | +| `middleSlide` | middle slide index | +| `minSlide` | minimum slide index | +| `slideSize` | single slide width or height | +| `slidesCount` | slides total count | diff --git a/docs/api/events.md b/docs/api/events.md index bfe1a2ca..61ffc2bc 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -30,18 +30,23 @@ const handleSlideStart = (data) => { Triggered before the carousel is initialized. Use this to perform any setup tasks required before the carousel is ready. +### @drag + +Triggered while the carousel is being dragged, providing live positional data. Emits the following: + +- `x`: The horizontal drag position. +- `y`: The vertical drag position. + ### @init Triggered once the carousel is mounted and fully initialized. This is ideal for executing post-initialization logic. -### slide-start +### @loop -Triggered at the beginning of the sliding function. Emits the following data: +Triggered when the carousel loops over (wraps around), only in wrap-around mode. Emits the following data: -- `slidingToIndex`: The index of the slide the carousel is moving to. -- `currentSlideIndex`: The current slide index before the transition starts. -- `prevSlideIndex`: The index of the slide before the current one. -- `slidesCount`: The total number of slides in the carousel. +- `currentSlideIndex`: The current slide index before the loop occurs. +- `slidingToIndex`: The index of the slide the carousel loops to. ### @slide-end @@ -51,33 +56,28 @@ Triggered after the sliding animation completes and the current slide is updated - `prevSlideIndex`: The index of the slide before the transition. - `slidesCount`: The total number of slides in the carousel. -### @loop - -Triggered when the carousel loops over (wraps around), only in wrap-around mode. Emits the following data: - -- `slidingToIndex`: The index of the slide the carousel loops to. -- `currentSlideIndex`: The current slide index before the loop occurs. - -### @drag - -Triggered while the carousel is being dragged, providing live positional data. Emits the following: - -- `x`: The horizontal drag position. -- `y`: The vertical drag position. - ### @slide-registered Triggered when a new slide is registered with the carousel. Emits the following data: -- `slide`: The Vue component instance of the registered slide - `index`: The index position where the slide was registered +- `slide`: The Vue component instance of the registered slide + +### @slide-start + +Triggered at the beginning of the sliding function. Emits the following data: + +- `currentSlideIndex`: The current slide index before the transition starts. +- `prevSlideIndex`: The index of the slide before the current one. +- `slidingToIndex`: The index of the slide the carousel is moving to. +- `slidesCount`: The total number of slides in the carousel. ### @slide-unregistered Triggered when a slide is unregistered (removed) from the carousel. Emits the following data: -- `slide`: The Vue component instance of the unregistered slide - `index`: The index position from which the slide was removed +- `slide`: The Vue component instance of the unregistered slide ## Notes diff --git a/docs/api/methods.md b/docs/api/methods.md index 1d76967d..ee50306f 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -17,10 +17,6 @@ myCarousel.next() myCarousel.updateSlideSize() ``` -## slideTo(index: number) - -Slide to specific slide index - ## next() Slide to the next slide @@ -29,37 +25,32 @@ Slide to the next slide Slide to the previous slide -## ~~updateSlideWidth()~~ +## restartCarousel() -~~Update `slideWidth` value based on `itemsToShow` and the current carousel width~~ +Restart the carousel settings and data, internally it calls: -## updateSlideSize() +- `resetAutoplay` +- `updateBreakpointsConfig` +- `updateSlidesData` +- `updateSlideSize` -Update `slideSize` value based on `itemsToShow`, `dir` and the current carousel width/height +## slideTo(index: number, skipTransition = false) + +Slide to specific slide index ## updateBreakpointsConfig() Update the current carousel config based on `breakpoints` settings and screen width +## updateSlideSize() + +Update `slideSize` value based on `itemsToShow`, `dir` and the current carousel width/height + ## updateSlidesData() Update all the slide related date includes: - `currentSlideIndex` -- `middleSlide` - `maxSlide` +- `middleSlide` - `minSlide` - -## ~~initDefaultConfig()~~ - -~~Init carousel default configurations~~ - -## restartCarousel() - -Restart the carousel settings and data, internally it calls: - -- ~~`initDefaultConfig`~~ -- `resetAutoplay` -- `updateBreakpointsConfig` -- `updateSlidesData` -- `updateSlideSize` diff --git a/docs/components/navigation.md b/docs/components/navigation.md index 3db9a55a..3bfd0f3c 100644 --- a/docs/components/navigation.md +++ b/docs/components/navigation.md @@ -61,12 +61,12 @@ You can customize the navigation buttons using slots: | Variable | Default Value | Description | | ------------------------ | ------------------------- | ------------------------------- | -| `--vc-nav-width` | `30px` | Navigation button width | -| `--vc-nav-height` | `30px` | Navigation button height | +| `--vc-nav-background` | `transparent` | Navigation button background | | `--vc-nav-border-radius` | `0` | Navigation button border radius | | `--vc-nav-color` | `var(--vc-clr-primary)` | Navigation button color | | `--vc-nav-color-hover` | `var(--vc-clr-secondary)` | Navigation button hover color | -| `--vc-nav-background` | `transparent` | Navigation button background | +| `--vc-nav-height` | `30px` | Navigation button height | +| `--vc-nav-width` | `30px` | Navigation button width | ## Accessibility diff --git a/docs/components/pagination.md b/docs/components/pagination.md index 03262e47..d31e6053 100644 --- a/docs/components/pagination.md +++ b/docs/components/pagination.md @@ -35,13 +35,12 @@ import { Pagination as CarouselPagination } from 'vue3-carousel' | Variable | Default Value | Description | | --------------------------- | ------------------------- | ---------------------------------- | -| `--vc-pgn-width` | `16px` | Pagination button width | +| `--vc-pgn-active-color` | `var(--vc-clr-primary)` | Active pagination button color | +| `--vc-pgn-background-color` | `var(--vc-clr-secondary)` | Pagination button background color | +| `--vc-pgn-border-radius` | `0` | Pagination button border radius | | `--vc-pgn-height` | `4px` | Pagination button height | | `--vc-pgn-margin` | `6px 5px` | Pagination button margin | -| `--vc-pgn-border-radius` | `0` | Pagination button border radius | -| `--vc-pgn-background-color` | `var(--vc-clr-secondary)` | Pagination button background color | -| `--vc-pgn-active-color` | `var(--vc-clr-primary)` | Active pagination button color | - +| `--vc-pgn-width` | `16px` | Pagination button width | ## Accessibility diff --git a/docs/components/slide.md b/docs/components/slide.md index 3803a981..e4a5cec6 100644 --- a/docs/components/slide.md +++ b/docs/components/slide.md @@ -31,8 +31,8 @@ The default slot exposes these reactive properties for custom slide content: | currentIndex | Number | Current index position of the slide | | isActive | Boolean | True when this slide is the current active slide | | isClone | Boolean | True if this is a clone slide (used for infinite scroll) | -| isPrev | Boolean | True if this slide is immediately before the active slide | | isNext | Boolean | True if this slide is immediately after the active slide | +| isPrev | Boolean | True if this slide is immediately before the active slide | | isSliding | Boolean | True during slide transition animations | | isVisible | Boolean | True when the slide is within the visible viewport | @@ -80,12 +80,12 @@ The component provides these CSS classes for styling: | CSS Class | Description | | --------------------------- | ------------------------- | | `.carousel__slide` | Base slide styles | -| `.carousel__slide--clone` | Cloned slide styles | -| `.carousel__slide--visible` | Visible slide styles | | `.carousel__slide--active` | Active slide styles | -| `.carousel__slide--prev` | Previous slide styles | +| `.carousel__slide--clone` | Cloned slide styles | | `.carousel__slide--next` | Next slide styles | +| `.carousel__slide--prev` | Previous slide styles | | `.carousel__slide--sliding` | Styles during transitions | +| `.carousel__slide--visible` | Visible slide styles | ## Best Practices diff --git a/docs/config.md b/docs/config.md index 1a859ce9..a0b15987 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,29 +2,33 @@ ## Available Props -| Prop | Default | Description | -| -------------------------- | -------------------------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `enabled` | true | Controlled weather the carousel is enabled or disabled. | -| `itemsToShow` | 1 | Count of items to showed per view (can be a fraction). Must be between 1 and the total number of slides. If set to a value less than 1, it defaults to 1. If set to a value greater than the total number of slides, it defaults to the total number of slides. | -| `itemsToScroll` | 1 | Number of slides to be scrolled | -| `wrapAround` | false | Enable infinite scrolling mode. | -| `snapAlign` | 'center' | Controls the carousel position alignment, can be 'start', 'end', 'center-[odd\|even]' | -| `transition` | 300 | Sliding transition time in ms. | -| `autoplay` | 0 | Auto play time in ms. | -| `breakpointMode` | 'viewport' | Determines how the carousel breakpoints are calculated. acceptable values: 'viewport', 'carousel' | -| `breakpoints` | null | An object to pass all the breakpoints settings. | -| `modelValue` | 0 | Index number of the initial slide. | -| `mouseDrag` | true | Toggle mouse dragging | -| `touchDrag` | true | Toggle pointer touch dragging | -| `pauseAutoplayOnHover` | false | Toggle if auto play should pause on mouse hover | -| `dir` | 'ltr' | Controls the carousel direction. Available values: 'ltr', 'rtl', 'ttb', 'btt' or use verbose 'left-to-right', 'right-to-left', 'top-to-bottom', 'bottom-to-top' | -| `i18n` | [`{ ariaNextSlide: ...}`](#i18n) | Used to translate and/or change aria labels and additional texts used in the carousel. | -| `gap` | 0 | Used to add gap between the slides. | -| `height` | 'auto' | Carousel track height. | -| `ignoreAnimations` | false | List of animation names to ignore for size calculations. Can be a boolean, string, or array of strings. | -| `preventExcessiveDragging` | false | Prevents unwanted dragging behavior when the carousel reaches its first or last slide. | - - +| Prop | Type | Default | Description | +| -------------------------- | ------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `autoplay` | `number` | 0 | Time interval (in milliseconds) between auto-advancing slides. Set to 0 to disable autoplay. | +| `breakpointMode` | 'viewport', 'carousel' | 'viewport' | Defines whether breakpoints are calculated based on viewport width or carousel container width. | +| `breakpoints` | `object` | null | Responsive breakpoint configurations. Each breakpoint can override any carousel prop. | +| `dir` | 'ltr', 'rtl', 'ttb', 'btt' | 'ltr' | Carousel sliding direction. Supports horizontal (ltr/rtl) and vertical (ttb/btt) orientations. | +| `enabled` | `boolean` | true | Controls whether the carousel is interactive. When false, all interactions are disabled. | +| `gap` | `number` | 0 | Space (in pixels) between carousel slides. | +| `height` | `number` \| `string` | 'auto' | Sets the carousel track height. Required for vertical orientation. | +| `i18n` | `object` | [`{ ariaNextSlide: ...}`](#i18n) | Internationalization options for accessibility labels and text content. | +| `ignoreAnimations` | `boolean` \| `string` \| `array` | false | Specifies which CSS animations should be excluded from slide size calculations. | +| `itemsToScroll` | `number` | 1 | Number of slides to move when navigating. Useful for creating slide groups. | +| `itemsToShow` | `number` \| 'auto' | 1 | Number of slides visible simultaneously. Use 'auto' for variable width slides. | +| `modelValue` | `number` | 0 | Controls the active slide index. Can be used with v-model for two-way binding. | +| `mouseDrag` | `boolean` | true | Enables/disables mouse drag navigation. | +| `pauseAutoplayOnHover` | `boolean` | false | When true, autoplay pauses while the mouse cursor is over the carousel. | +| `preventExcessiveDragging` | `boolean` | false | Limits dragging behavior at carousel boundaries for better UX. | +| `snapAlign` | 'start', 'end', 'center-odd', 'center-even' | 'center' | Determines how slides are aligned within the viewport. | +| `touchDrag` | `boolean` | true | Enables/disables touch navigation on touch-enabled devices. | +| `transition` | `number` | 300 | Duration of the slide transition animation in milliseconds. | +| `wrapAround` | `boolean` | false | When true, creates an infinite loop effect by connecting the last slide to the first. | + +> **itemsToShow**: Controls the number of visible slides. Values between 1 and the total slide count are valid. Values outside this range are automatically clamped. Using 'auto' allows slides to determine their own width based on content. + +> **Direction Settings**: For vertical orientations ('ttb'/'top-to-bottom', 'btt'/'bottom-to-top'), the carousel requires a fixed height setting. Direction can be specified using either short ('ltr', 'rtl', 'ttb', 'btt') or verbose ('left-to-right', 'right-to-left', 'top-to-bottom', 'bottom-to-top') formats. + +> **Drag Prevention**: The `preventExcessiveDragging` option is automatically disabled when `wrapAround` is enabled, as boundary restrictions aren't needed in infinite loop mode. ## Slots @@ -62,12 +66,11 @@ Used to add display carousel addons components. ### Slots Attributes -| Prop | Description | -| ---------------- | ------------------------------------------------------------------------------------------- | -| ~~`slideWidth`~~ | ~~the width of a single slide element.~~ | -| `slideSize` | the width/height of a single slide element. | -| `currentSlide` | index number of the current slide. | -| `slidesCount` | the count of all slides | +| Prop | Description | +| -------------- | ------------------------------------------- | +| `currentSlide` | index number of the current slide. | +| `slideSize` | the width/height of a single slide element. | +| `slidesCount` | the count of all slides | #### Example @@ -91,12 +94,12 @@ Available keys: | Key | Defaults | Description | | --------------------- | -------------------------------------- | -------------------------------------------------------------------------- | +| `ariaGallery` | "Gallery" | Used as the aria-label for the main carousel element, indicating purpose. | +| `ariaNavigateToSlide` | "Navigate to slide {slideNumber}" | Sets title and aria-label for pagination buttons to select a slide. | | `ariaNextSlide` | "Navigate to next slide" | Sets title and aria-label for the “Next” navigation button. | | `ariaPreviousSlide` | "Navigate to previous slide" | Sets title and aria-label for the “Previous” navigation button. | -| `ariaNavigateToSlide` | "Navigate to slide {slideNumber}" | Sets title and aria-label for pagination buttons to select a slide. | -| `ariaGallery` | "Gallery" | Used as the aria-label for the main carousel element, indicating purpose. | -| `itemXofY` | "Item {currentSlide} of {slidesCount}" | Provides screen readers with the current slide’s position in the sequence. | -| `iconArrowUp` | "Arrow pointing upwards" | Sets title and aria-label for the upward-pointing arrow SVG icon. | | `iconArrowDown` | "Arrow pointing downwards" | Sets title and aria-label for the downward-pointing arrow SVG icon. | -| `iconArrowRight` | "Arrow pointing to the right" | Sets title and aria-label for the right-pointing arrow SVG icon. | | `iconArrowLeft` | "Arrow pointing to the left" | Sets title and aria-label for the left-pointing arrow SVG icon. | +| `iconArrowRight` | "Arrow pointing to the right" | Sets title and aria-label for the right-pointing arrow SVG icon. | +| `iconArrowUp` | "Arrow pointing upwards" | Sets title and aria-label for the upward-pointing arrow SVG icon. | +| `itemXofY` | "Item {currentSlide} of {slidesCount}" | Provides screen readers with the current slide’s position in the sequence. | diff --git a/src/components/ARIA/ARIA.ts b/src/components/ARIA/ARIA.ts index 0809bede..3164109b 100644 --- a/src/components/ARIA/ARIA.ts +++ b/src/components/ARIA/ARIA.ts @@ -1,4 +1,4 @@ -import { defineComponent, inject, h } from 'vue' +import { defineComponent, h, inject } from 'vue' import { injectCarousel } from '@/shared' import { i18nFormatter } from '@/utils' diff --git a/src/components/Carousel/Carousel.css b/src/components/Carousel/Carousel.css index a1100a09..099cd04e 100644 --- a/src/components/Carousel/Carousel.css +++ b/src/components/Carousel/Carousel.css @@ -4,11 +4,11 @@ } .carousel { + height: var(--vc-carousel-height); + overscroll-behavior: none; position: relative; touch-action: pan-y; - overscroll-behavior: none; z-index: 1; - height: var(--vc-carousel-height); } .carousel.is-dragging { @@ -17,78 +17,68 @@ .carousel__track { display: flex; + gap: var(--vc-slide-gap); + height: 100%; list-style: none; - padding: 0 !important; margin: 0 !important; - width: 100%; - height: 100%; + padding: 0 !important; position: relative; transition: transform ease-out; transition-duration: var(--vc-transition-duration); - gap: var(--vc-slide-gap); + width: 100%; } .carousel__viewport { + height: 100%; overflow: hidden; width: 100%; - height: 100%; } .carousel__sr-only { - position: absolute; - width: 1px; + border: 0; + clip: rect(0, 0, 0, 0); height: 1px; - padding: 0; margin: -1px; overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; + padding: 0; + position: absolute; + width: 1px; } -.carousel.is-ttb { - .carousel__track { - flex-direction: column; - } +.carousel.is-ttb .carousel__track { + flex-direction: column; } -.carousel.is-btt { - .carousel__track { - flex-direction: column-reverse; - } +.carousel.is-btt .carousel__track { + flex-direction: column-reverse; } -.carousel.is-vertical { - .carousel__slide--clone:first-child { - margin-block-start: var(--vc-cloned-offset); - } +.carousel.is-vertical .carousel__slide--clone:first-child { + margin-block-start: var(--vc-cloned-offset); } -.carousel:not(.is-vertical) { - .carousel__slide--clone:first-child { - margin-inline-start: var(--vc-cloned-offset); - } +.carousel:not(.is-vertical) .carousel__slide--clone:first-child { + margin-inline-start: var(--vc-cloned-offset); } -.carousel.is-effect-fade { - .carousel__track { - transition: none; - display: grid; - grid-template-columns: 100%; - grid-template-rows: 100%; - } +.carousel.is-effect-fade .carousel__track { + display: grid; + grid-template-columns: 100%; + grid-template-rows: 100%; + transition: none; +} - .carousel__slide { - opacity: 0; - width: 100% !important; - height: 100% !important; - transition: opacity ease-in-out; - transition-duration: var(--vc-transition-duration); - grid-area: 1 / 1; /* Make all slides occupy the same grid cell */ - pointer-events: none; /* Prevent inactive slides from being clickable */ - } +.carousel.is-effect-fade .carousel__slide { + grid-area: 1 / 1; + height: 100% !important; + opacity: 0; + pointer-events: none; + transition: opacity ease-in-out; + transition-duration: var(--vc-transition-duration); + width: 100% !important; +} - .carousel__slide--active { - opacity: 1; - pointer-events: auto; /* Re-enable pointer events for active slide */ - } +.carousel.is-effect-fade .carousel__slide--active { + opacity: 1; + pointer-events: auto; } diff --git a/src/components/Carousel/Carousel.spec.ts b/src/components/Carousel/Carousel.spec.ts index 9ac54ba8..9e0e8880 100644 --- a/src/components/Carousel/Carousel.spec.ts +++ b/src/components/Carousel/Carousel.spec.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { expect, it, describe, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { Carousel } from './Carousel' diff --git a/src/components/Carousel/Carousel.ts b/src/components/Carousel/Carousel.ts index 4a77e114..af91a7e9 100644 --- a/src/components/Carousel/Carousel.ts +++ b/src/components/Carousel/Carousel.ts @@ -1,42 +1,42 @@ import { + ComputedRef, + computed, defineComponent, - onMounted, + h, onBeforeUnmount, - ref, - reactive, + onMounted, provide, - computed, - h, - watch, - SetupContext, + reactive, Ref, - ComputedRef, - watchEffect, + ref, + SetupContext, shallowReactive, + watch, + watchEffect, } from 'vue' import { ARIA as ARIAComponent } from '@/components/ARIA' import { CarouselConfig, + createSlideRegistry, DEFAULT_CONFIG, DIR_MAP, + injectCarousel, NonNormalizedDir, NormalizedDir, - injectCarousel, - createSlideRegistry, } from '@/shared' import { - except, - throttle, - getNumberInRange, - mapNumberToRange, + calculateAverage, createCloneSlides, + except, getDraggedSlidesCount, - getSnapAlignOffset, + getNumberInRange, getScaleMultipliers, + getSnapAlignOffset, + mapNumberToRange, ScaleMultipliers, + throttle, toCssValue, - calculateAverage, } from '@/utils' import { @@ -52,15 +52,15 @@ export const Carousel = defineComponent({ name: 'VueCarousel', props: carouselProps, emits: [ - 'init', + 'before-init', 'drag', - 'slide-start', + 'init', 'loop', - 'update:modelValue', 'slide-end', - 'before-init', 'slide-registered', + 'slide-start', 'slide-unregistered', + 'update:modelValue', ], setup(props: CarouselConfig, { slots, emit, expose }: SetupContext) { const slideRegistry = createSlideRegistry(emit) @@ -823,45 +823,45 @@ export const Carousel = defineComponent({ const nav: CarouselNav = { slideTo, next, prev } const provided: InjectedCarousel = reactive({ + activeSlide: activeSlideIndex, config, - slidesCount, - viewport, - slides, currentSlide: currentSlideIndex, - activeSlide: activeSlideIndex, + isSliding, + isVertical, maxSlide: maxSlideIndex, minSlide: minSlideIndex, - visibleRange, - slideSize, - isVertical, - normalizedDir, nav, - isSliding, + normalizedDir, slideRegistry, + slideSize, + slides, + slidesCount, + viewport, + visibleRange, }) provide(injectCarousel, provided) const data = reactive({ config, - slidesCount, - slideSize, currentSlide: currentSlideIndex, maxSlide: maxSlideIndex, - minSlide: minSlideIndex, middleSlide: middleSlideIndex, + minSlide: minSlideIndex, + slideSize, + slidesCount, }) expose({ - updateBreakpointsConfig, - updateSlidesData, - updateSlideSize, - restartCarousel, - slideTo, + data, + nav, next, prev, - nav, - data, + restartCarousel, + slideTo, + updateBreakpointsConfig, + updateSlideSize, + updateSlidesData, }) return () => { diff --git a/src/components/Carousel/Carousel.types.ts b/src/components/Carousel/Carousel.types.ts index 4ce77cea..05a39099 100644 --- a/src/components/Carousel/Carousel.types.ts +++ b/src/components/Carousel/Carousel.types.ts @@ -6,50 +6,60 @@ import { ShallowReactive, } from 'vue' -import { SlideRegistry, CarouselConfig, NormalizedDir } from '@/shared' +import { CarouselConfig, NormalizedDir, SlideRegistry } from '@/shared' -export interface CarouselNav { - slideTo: (index: number) => void - next: (skipTransition?: boolean) => void - prev: (skipTransition?: boolean) => void +export type ElRect = { + height: number + width: number } -export type InjectedCarousel = Reactive<{ - config: CarouselConfig - viewport: Ref - slides: ShallowReactive> - slidesCount: ComputedRef - activeSlide: Ref - currentSlide: Ref - maxSlide: ComputedRef - minSlide: ComputedRef - visibleRange: ComputedRef<{ min: number; max: number }> - slideSize: Ref - isVertical: ComputedRef - normalizedDir: ComputedRef - nav: CarouselNav - isSliding: Ref - slideRegistry: SlideRegistry -}> +export type Range = { + min: number + max: number +} -export interface CarouselData { +export type CarouselData = { config: CarouselConfig - slidesCount: Ref - slideSize: Ref currentSlide: Ref maxSlide: Ref - minSlide: Ref middleSlide: Ref + minSlide: Ref + slideSize: Ref + slidesCount: Ref } -export interface CarouselMethods extends CarouselNav { +export type CarouselExposed = CarouselMethods & { + data: Reactive + nav: CarouselNav +} + +export type CarouselMethods = CarouselNav & { + restartCarousel: () => void updateBreakpointsConfig: () => void - updateSlidesData: () => void updateSlideSize: () => void - restartCarousel: () => void + updateSlidesData: () => void } -export interface CarouselExposed extends CarouselMethods { - nav: CarouselNav - data: Reactive + +export type CarouselNav = { + next: (skipTransition?: boolean) => void + prev: (skipTransition?: boolean) => void + slideTo: (index: number) => void } -export type ElRect = { width: number; height: number } + +export type InjectedCarousel = Reactive<{ + activeSlide: Ref + config: CarouselConfig + currentSlide: Ref + isSliding: Ref + isVertical: ComputedRef + maxSlide: ComputedRef + minSlide: ComputedRef + nav: CarouselNav + normalizedDir: ComputedRef + slideRegistry: SlideRegistry + slideSize: Ref + slides: ShallowReactive> + slidesCount: ComputedRef + viewport: Ref + visibleRange: ComputedRef +}> diff --git a/src/components/Carousel/carouselProps.ts b/src/components/Carousel/carouselProps.ts index f752bbdc..9456741f 100644 --- a/src/components/Carousel/carouselProps.ts +++ b/src/components/Carousel/carouselProps.ts @@ -11,33 +11,35 @@ import { import type { BreakpointMode, - Dir, - SnapAlign, CarouselConfig, - SlideEffect, + Dir, NonNormalizedDir, NormalizedDir, + SlideEffect, + SnapAlign, } from '@/shared' export const carouselProps = { - // enable/disable the carousel component - enabled: { - default: DEFAULT_CONFIG.enabled, - type: Boolean, + // time to auto advance slides in ms + autoplay: { + default: DEFAULT_CONFIG.autoplay, + type: Number, }, - // count of items to showed per view - itemsToShow: { - default: DEFAULT_CONFIG.itemsToShow, - type: [Number, String], + // an object to store breakpoints + breakpoints: { + default: DEFAULT_CONFIG.breakpoints, + type: Object as PropType, }, - // count of items to be scrolled - itemsToScroll: { - default: DEFAULT_CONFIG.itemsToScroll, - type: Number, + // controls the breakpoint mode relative to the carousel container or the viewport + breakpointMode: { + default: DEFAULT_CONFIG.breakpointMode, + validator(value: BreakpointMode) { + return BREAKPOINT_MODE_OPTIONS.includes(value) + }, }, - // control infinite scrolling mode - wrapAround: { - default: DEFAULT_CONFIG.wrapAround, + // enable/disable the carousel component + enabled: { + default: DEFAULT_CONFIG.enabled, type: Boolean, }, // control the gap between slides @@ -50,39 +52,24 @@ export const carouselProps = { default: DEFAULT_CONFIG.height, type: [Number, String], }, - // control snap position alignment - snapAlign: { - default: DEFAULT_CONFIG.snapAlign, - validator(value: SnapAlign) { - return SNAP_ALIGN_OPTIONS.includes(value) - }, + ignoreAnimations: { + default: false, + type: [Array, Boolean, String] as PropType, }, - // sliding transition time in ms - transition: { - default: DEFAULT_CONFIG.transition, + // count of items to be scrolled + itemsToScroll: { + default: DEFAULT_CONFIG.itemsToScroll, type: Number, }, - // controls the breakpoint mode relative to the carousel container or the viewport - breakpointMode: { - default: DEFAULT_CONFIG.breakpointMode, - validator(value: BreakpointMode) { - return BREAKPOINT_MODE_OPTIONS.includes(value) - }, - }, - // an object to store breakpoints - breakpoints: { - default: DEFAULT_CONFIG.breakpoints, - type: Object as PropType, - }, - // time to auto advance slides in ms - autoplay: { - default: DEFAULT_CONFIG.autoplay, - type: Number, + // count of items to showed per view + itemsToShow: { + default: DEFAULT_CONFIG.itemsToShow, + type: [Number, String], }, - // pause autoplay when mouse hover over the carousel - pauseAutoplayOnHover: { - default: DEFAULT_CONFIG.pauseAutoplayOnHover, - type: Boolean, + // aria-labels and additional text labels + i18n: { + default: DEFAULT_CONFIG.i18n, + type: Object as PropType, }, // slide number number of initial slide modelValue: { @@ -99,7 +86,43 @@ export const carouselProps = { default: DEFAULT_CONFIG.touchDrag, type: Boolean, }, + pauseAutoplayOnHover: { + default: DEFAULT_CONFIG.pauseAutoplayOnHover, + type: Boolean, + }, + preventExcessiveDragging: { + default: false, + type: Boolean, + validator(value: boolean, props: { wrapAround?: boolean }) { + if (value && props.wrapAround) { + console.warn( + `[vue3-carousel warn]: "preventExcessiveDragging" cannot be used with wrapAround. The setting will be ignored.` + ) + } + + return true + }, + }, // control snap position alignment + snapAlign: { + default: DEFAULT_CONFIG.snapAlign, + validator(value: SnapAlign) { + return SNAP_ALIGN_OPTIONS.includes(value) + }, + }, + slideEffect: { + type: String as PropType, + default: DEFAULT_CONFIG.slideEffect, + validator(value: SlideEffect) { + 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 as PropType, default: DEFAULT_CONFIG.dir, @@ -122,33 +145,9 @@ export const carouselProps = { return true }, }, - // aria-labels and additional text labels - i18n: { - default: DEFAULT_CONFIG.i18n, - type: Object as PropType, - }, - ignoreAnimations: { - default: false, - type: [Array, Boolean, String] as PropType, - }, - slideEffect: { - type: String as PropType, - default: DEFAULT_CONFIG.slideEffect, - validator(value: SlideEffect) { - return SLIDE_EFFECTS.includes(value) - }, - }, - preventExcessiveDragging: { - default: false, + // control infinite scrolling mode + wrapAround: { + default: DEFAULT_CONFIG.wrapAround, type: Boolean, - validator(value: boolean, props: { wrapAround?: boolean }) { - if (value && props.wrapAround) { - console.warn( - `[vue3-carousel warn]: "preventExcessiveDragging" cannot be used with wrapAround. The setting will be ignored.` - ) - } - - return true - }, }, } diff --git a/src/components/Icon/Icon.css b/src/components/Icon/Icon.css index 9f17dd99..5ff14f48 100644 --- a/src/components/Icon/Icon.css +++ b/src/components/Icon/Icon.css @@ -3,7 +3,7 @@ } .carousel__icon { - width: var(--vc-icn-width); - height: var(--vc-icn-width); fill: currentColor; + height: var(--vc-icn-width); + width: var(--vc-icn-width); } diff --git a/src/components/Icon/Icon.spec.ts b/src/components/Icon/Icon.spec.ts index 53a94b3a..f328925e 100644 --- a/src/components/Icon/Icon.spec.ts +++ b/src/components/Icon/Icon.spec.ts @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { expect, it, describe, afterEach, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Icon } from './Icon' import { IconName, IconProps } from './Icon.types' @@ -11,13 +11,6 @@ describe('Icon.ts', () => { consoleMock.mockReset() }) - it('It should error if no iconName', () => { - const wrapper = mount(Icon, { props: {} as IconProps }) - expect(wrapper.html()).toBe('') - expect(consoleMock).toHaveBeenCalledOnce() - expect(consoleMock.mock.calls[0][0]).toBe('[Vue warn]: Missing required prop: "name"') - }) - it('It should error if iconName is invalid', () => { const wrapper = mount(Icon, { props: { name: 'foo' as IconProps['name'] } }) expect(wrapper.html()).toBe('') @@ -27,14 +20,23 @@ describe('Icon.ts', () => { ) }) + it('It should error if no iconName', () => { + const wrapper = mount(Icon, { props: {} as IconProps }) + expect(wrapper.html()).toBe('') + expect(consoleMock).toHaveBeenCalledOnce() + expect(consoleMock.mock.calls[0][0]).toBe('[Vue warn]: Missing required prop: "name"') + }) + it('It should render standalone', async () => { - await Promise.all(Object.values(IconName).map(async (name) => { - const wrapper = mount(Icon, { props: { name: name } }) - expect(consoleMock).not.toHaveBeenCalled() - expect(wrapper.html()).toMatchSnapshot() + await Promise.all( + Object.values(IconName).map(async (name) => { + const wrapper = mount(Icon, { props: { name: name } }) + expect(consoleMock).not.toHaveBeenCalled() + expect(wrapper.html()).toMatchSnapshot() - await wrapper.setProps({ title: 'Test title' }) - expect(wrapper.find('svg title').text()).toBe('Test title') - })) + await wrapper.setProps({ title: 'Test title' }) + expect(wrapper.find('svg title').text()).toBe('Test title') + }) + ) }) }) diff --git a/src/components/Icon/Icon.ts b/src/components/Icon/Icon.ts index 6d800c5e..6ea50efa 100644 --- a/src/components/Icon/Icon.ts +++ b/src/components/Icon/Icon.ts @@ -4,22 +4,22 @@ import { DEFAULT_CONFIG, injectCarousel } from '@/shared' import { IconName, IconNameValue, IconProps } from './Icon.types' -function isIconName(candidate: string): candidate is IconName { - return candidate in IconName -} - const iconI18n = (name: Name) => `icon${name.charAt(0).toUpperCase() + name.slice(1)}` as `icon${Capitalize}` -const validateIconName = (value: IconNameValue) => { - return value && isIconName(value) -} - export const icons = { - arrowUp: 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z', arrowDown: 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-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', 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: string): candidate is IconName { + return candidate in IconName +} + +const validateIconName = (value: IconNameValue) => { + return value && isIconName(value) } export const Icon = defineComponent({ @@ -45,8 +45,7 @@ export const Icon = defineComponent({ const path = icons[iconName] const pathEl = h('path', { d: path }) - const iconTitle: string = - carousel?.config.i18n[iconI18n(iconName)] || props.title! + const iconTitle: string = carousel?.config.i18n[iconI18n(iconName)] || props.title! const titleEl = h('title', iconTitle) diff --git a/src/components/Icon/Icon.types.ts b/src/components/Icon/Icon.types.ts index 9245c13d..5cd98826 100644 --- a/src/components/Icon/Icon.types.ts +++ b/src/components/Icon/Icon.types.ts @@ -1,13 +1,13 @@ export enum IconName { - arrowUp = 'arrowUp', arrowDown = 'arrowDown', - arrowRight = 'arrowRight', arrowLeft = 'arrowLeft', + arrowRight = 'arrowRight', + arrowUp = 'arrowUp', } export type IconNameValue = `${IconName}` -export interface IconProps { - title?: string +export type IconProps = { name: IconNameValue + title?: string } diff --git a/src/components/Icon/__snapshots__/Icon.spec.ts.snap b/src/components/Icon/__snapshots__/Icon.spec.ts.snap index 0d259894..f39abf83 100644 --- a/src/components/Icon/__snapshots__/Icon.spec.ts.snap +++ b/src/components/Icon/__snapshots__/Icon.spec.ts.snap @@ -1,16 +1,16 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Icon.ts > It should render standalone 1`] = ` -" - Arrow pointing upwards - +" + Arrow pointing downwards + " `; exports[`Icon.ts > It should render standalone 2`] = ` -" - Arrow pointing downwards - +" + Arrow pointing to the left + " `; @@ -22,8 +22,8 @@ exports[`Icon.ts > It should render standalone 3`] = ` `; exports[`Icon.ts > It should render standalone 4`] = ` -" - Arrow pointing to the left - +" + Arrow pointing upwards + " `; diff --git a/src/components/Navigation/Navigation.css b/src/components/Navigation/Navigation.css index 7aef42b4..5c032a7a 100644 --- a/src/components/Navigation/Navigation.css +++ b/src/components/Navigation/Navigation.css @@ -1,32 +1,32 @@ :root { - --vc-nav-width: 30px; - --vc-nav-height: 30px; + --vc-nav-background: transparent; --vc-nav-border-radius: 0; --vc-nav-color: var(--vc-clr-primary); --vc-nav-color-hover: var(--vc-clr-secondary); - --vc-nav-background: transparent; + --vc-nav-height: 30px; + --vc-nav-width: 30px; } -.carousel__prev, -.carousel__next { - box-sizing: content-box; +.carousel__next, +.carousel__prev { + align-items: center; background: var(--vc-nav-background); + border: 0; border-radius: var(--vc-nav-border-radius); - width: var(--vc-nav-width); - height: var(--vc-nav-height); - text-align: center; - font-size: var(--vc-nav-height); - padding: 0; + box-sizing: content-box; color: var(--vc-nav-color); + cursor: pointer; display: flex; + font-size: var(--vc-nav-height); + height: var(--vc-nav-height); justify-content: center; - align-items: center; - position: absolute; - border: 0; - cursor: pointer; margin: 0 10px; + padding: 0; + position: absolute; + text-align: center; top: 50%; transform: translateY(-50%); + width: var(--vc-nav-width); } .carousel__next--disabled, @@ -35,57 +35,57 @@ opacity: 0.5; } +.carousel__next { + right: 0; +} + .carousel__prev { left: 0; } -.carousel__next { - right: 0; +.carousel.is-btt { + .carousel__next { + top: 0; + } + .carousel__prev { + bottom: 0; + } } .carousel.is-rtl { - .carousel__prev { - left: auto; - right: 0; - } .carousel__next { - right: auto; left: 0; + right: auto; } -} - -.carousel.is-vertical { - .carousel__prev, - .carousel__next { + .carousel__prev { left: auto; - top: auto; - right: 50%; - transform: translate(50%); - margin: 5px auto; + right: 0; } } -.carousel.is-btt { - .carousel__prev { +.carousel.is-ttb { + .carousel__next { bottom: 0; } - .carousel__next { + .carousel__prev { top: 0; } } -.carousel.is-ttb { +.carousel.is-vertical { + .carousel__next, .carousel__prev { - top: 0; - } - .carousel__next { - bottom: 0; + left: auto; + margin: 5px auto; + right: 50%; + top: auto; + transform: translate(50%); } } @media (hover: hover) { - .carousel__prev:hover, - .carousel__next:hover { + .carousel__next:hover, + .carousel__prev:hover { color: var(--vc-nav-color-hover); } } diff --git a/src/components/Navigation/Navigation.ts b/src/components/Navigation/Navigation.ts index 6ae51e9f..94e9986e 100644 --- a/src/components/Navigation/Navigation.ts +++ b/src/components/Navigation/Navigation.ts @@ -1,6 +1,6 @@ -import { inject, h, defineComponent, computed } from 'vue' +import { computed, defineComponent, h, inject } from 'vue' -import { NormalizedDir, injectCarousel } from '@/shared' +import { injectCarousel, NormalizedDir } from '@/shared' import { Icon, IconNameValue } from '../Icon' @@ -18,27 +18,31 @@ export const Navigation = defineComponent({ const getPrevIcon = () => { const directionIcons: Record = { + btt: 'arrowDown', ltr: 'arrowLeft', rtl: 'arrowRight', ttb: 'arrowUp', - btt: 'arrowDown', } return directionIcons[carousel.normalizedDir] } const getNextIcon = () => { const directionIcons: Record = { + btt: 'arrowUp', ltr: 'arrowRight', rtl: 'arrowLeft', ttb: 'arrowDown', - btt: 'arrowUp', } return directionIcons[carousel.normalizedDir] } - const prevDisabled = computed(() => !carousel.config.wrapAround && carousel.currentSlide <= carousel.minSlide) - const nextDisabled = computed(() => !carousel.config.wrapAround && carousel.currentSlide >= carousel.maxSlide) + const prevDisabled = computed( + () => !carousel.config.wrapAround && carousel.currentSlide <= carousel.minSlide + ) + const nextDisabled = computed( + () => !carousel.config.wrapAround && carousel.currentSlide >= carousel.maxSlide + ) return () => { const { i18n } = carousel.config @@ -53,7 +57,7 @@ export const Navigation = defineComponent({ ...attrs, class: [ 'carousel__prev', - {'carousel__prev--disabled': prevDisabled.value}, + { 'carousel__prev--disabled': prevDisabled.value }, attrs.class, ], }, @@ -70,7 +74,7 @@ export const Navigation = defineComponent({ ...attrs, class: [ 'carousel__next', - {'carousel__next--disabled': nextDisabled.value}, + { 'carousel__next--disabled': nextDisabled.value }, attrs.class, ], }, diff --git a/src/components/Pagination/Pagination.css b/src/components/Pagination/Pagination.css index 416aea11..a8b1b859 100644 --- a/src/components/Pagination/Pagination.css +++ b/src/components/Pagination/Pagination.css @@ -1,65 +1,62 @@ :root { - --vc-pgn-width: 16px; + --vc-pgn-active-color: var(--vc-clr-primary); + --vc-pgn-background-color: var(--vc-clr-secondary); + --vc-pgn-border-radius: 0; --vc-pgn-height: 4px; --vc-pgn-margin: 6px 5px; - --vc-pgn-border-radius: 0; - --vc-pgn-background-color: var(--vc-clr-secondary); - --vc-pgn-active-color: var(--vc-clr-primary); + --vc-pgn-width: 16px; } .carousel__pagination { + bottom: 5px; display: flex; justify-content: center; - list-style: none !important; + left: 50%; line-height: 0; - padding: 0 !important; + list-style: none !important; margin: 0 !important; + padding: 0 !important; position: absolute; - bottom: 5px; - left: 50%; transform: translateX(-50%); } .carousel__pagination-button { - display: block; border: 0; - margin: 0; cursor: pointer; - padding: var(--vc-pgn-margin); - background: transparent; -} - -.carousel__pagination-button::after { + margin: var(--vc-pgn-margin); + background-color: var(--vc-pgn-background-color); + border-radius: var(--vc-pgn-border-radius); display: block; - content: ''; - width: var(--vc-pgn-width); height: var(--vc-pgn-height); - border-radius: var(--vc-pgn-border-radius); - background-color: var(--vc-pgn-background-color); + width: var(--vc-pgn-width); + padding: 0; } -.carousel__pagination-button--active::after { +.carousel__pagination-button--active { background-color: var(--vc-pgn-active-color); } @media (hover: hover) { - .carousel__pagination-button:hover::after { + .carousel__pagination-button:hover { background-color: var(--vc-pgn-active-color); } } - .carousel.is-vertical { .carousel__pagination { + bottom: 50%; + flex-direction: column; left: auto; right: 5px; - bottom: 50%; transform: translateY(50%); - flex-direction: column; } - .carousel__pagination-button::after { + .carousel__pagination-button { height: var(--vc-pgn-width); width: var(--vc-pgn-height); } -} \ No newline at end of file +} + +.carousel.is-btt .carousel__pagination { + flex-direction: column-reverse; +} diff --git a/src/components/Pagination/Pagination.ts b/src/components/Pagination/Pagination.ts index 998705e3..b239f9cc 100644 --- a/src/components/Pagination/Pagination.ts +++ b/src/components/Pagination/Pagination.ts @@ -1,7 +1,7 @@ -import { inject, h, VNode, defineComponent, computed } from 'vue' +import { computed, defineComponent, h, inject, VNode } from 'vue' import { injectCarousel } from '@/shared' -import { mapNumberToRange, i18nFormatter, getSnapAlignOffset } from '@/utils' +import { getSnapAlignOffset, i18nFormatter, mapNumberToRange } from '@/utils' import { PaginationProps } from './Pagination.types' @@ -83,7 +83,7 @@ export const Pagination = defineComponent({ onClick: () => carousel.nav.slideTo( isPaginated.value - ? slide * +carousel.config.itemsToShow + offset.value + ? Math.floor(slide * +carousel.config.itemsToShow + offset.value) : slide ), }) diff --git a/src/components/Pagination/Pagination.types.ts b/src/components/Pagination/Pagination.types.ts index 8652ccd7..78c742c2 100644 --- a/src/components/Pagination/Pagination.types.ts +++ b/src/components/Pagination/Pagination.types.ts @@ -1,4 +1,4 @@ -export interface PaginationProps { +export type PaginationProps = { disableOnClick?: boolean paginateByItemsToShow?: boolean } diff --git a/src/components/Slide/Slide.css b/src/components/Slide/Slide.css index ea20011c..223fdfa0 100644 --- a/src/components/Slide/Slide.css +++ b/src/components/Slide/Slide.css @@ -1,11 +1,8 @@ .carousel__slide { - flex-shrink: 0; - margin: 0; - + align-items: center; display: flex; + flex-shrink: 0; justify-content: center; - align-items: center; - - /* Fix iOS scrolling #22 */ + margin: 0; transform: translateZ(0); } diff --git a/src/components/Slide/Slide.ts b/src/components/Slide/Slide.ts index 5b02bd63..7b43d4ed 100644 --- a/src/components/Slide/Slide.ts +++ b/src/components/Slide/Slide.ts @@ -1,18 +1,18 @@ import { - defineComponent, - inject, - h, - SetupContext, - computed, ComputedRef, + computed, + defineComponent, + DeepReadonly, getCurrentInstance, - onUnmounted, - provide, - useId, + h, + inject, onMounted, + onUnmounted, onUpdated, - DeepReadonly, + provide, ref, + SetupContext, + useId, } from 'vue' import { injectCarousel } from '@/shared' @@ -23,14 +23,6 @@ import { SlideProps } from './Slide.types' export const Slide = defineComponent({ name: 'CarouselSlide', props: { - isClone: { - type: Boolean, - default: false, - }, - position: { - type: String, - default: undefined, - }, id: { type: String, default: (props: { isClone?: boolean }) => (props.isClone ? undefined : useId()), @@ -39,6 +31,14 @@ export const Slide = defineComponent({ type: Number, default: undefined, }, + isClone: { + type: Boolean, + default: false, + }, + position: { + type: String, + default: undefined, + }, }, setup(props: DeepReadonly, { attrs, slots, expose }: SetupContext) { const carousel = inject(injectCarousel) diff --git a/src/components/Slide/Slide.types.ts b/src/components/Slide/Slide.types.ts index 70957b89..d6935a61 100644 --- a/src/components/Slide/Slide.types.ts +++ b/src/components/Slide/Slide.types.ts @@ -1,6 +1,6 @@ -export interface SlideProps { +export type SlideProps = { id?: string - position?: 'before' | 'after' index: number isClone?: boolean + position?: 'before' | 'after' } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4b2b49b7..d6011da2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,14 +1,14 @@ import { CarouselConfig } from './types' -export const SNAP_ALIGN_OPTIONS = [ - 'center', - 'start', - 'end', - 'center-even', - 'center-odd', -] as const -export const SLIDE_EFFECTS = ['slide', 'fade'] as const export const BREAKPOINT_MODE_OPTIONS = ['viewport', 'carousel'] as const + +export const DIR_MAP = { + 'bottom-to-top': 'btt', + 'left-to-right': 'ltr', + 'right-to-left': 'rtl', + 'top-to-bottom': 'ttb', +} as const + export const DIR_OPTIONS = [ 'ltr', 'left-to-right', @@ -19,47 +19,51 @@ export const DIR_OPTIONS = [ 'btt', 'bottom-to-top', ] as const + export 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', - ariaNavigateToSlide: 'Navigate to slide {slideNumber}', - ariaNavigateToPage: 'Navigate to page {slideNumber}', - ariaGallery: 'Gallery', - itemXofY: 'Item {currentSlide} of {slidesCount}', - iconArrowUp: 'Arrow pointing upwards', iconArrowDown: 'Arrow pointing downwards', - iconArrowRight: 'Arrow pointing to the right', iconArrowLeft: 'Arrow pointing to the left', -} as const - -export const DIR_MAP = { - 'left-to-right': 'ltr', - 'right-to-left': 'rtl', - 'top-to-bottom': 'ttb', - 'bottom-to-top': 'btt', + iconArrowRight: 'Arrow pointing to the right', + iconArrowUp: 'Arrow pointing upwards', + itemXofY: 'Item {currentSlide} of {slidesCount}', } as const export const NORMALIZED_DIR_OPTIONS = Object.values(DIR_MAP) +export const SLIDE_EFFECTS = ['slide', 'fade'] as const + +export const SNAP_ALIGN_OPTIONS = [ + 'center', + 'start', + 'end', + 'center-even', + 'center-odd', +] as const + export const DEFAULT_CONFIG: CarouselConfig = { - enabled: true, - itemsToShow: 1, - itemsToScroll: 1, - modelValue: 0, - transition: 300, autoplay: 0, - gap: 0, - height: 'auto', - wrapAround: false, - pauseAutoplayOnHover: false, - mouseDrag: true, - touchDrag: true, - snapAlign: SNAP_ALIGN_OPTIONS[0], - dir: DIR_OPTIONS[0], breakpointMode: BREAKPOINT_MODE_OPTIONS[0], breakpoints: undefined, + dir: DIR_OPTIONS[0], + enabled: true, + gap: 0, + height: 'auto', i18n: I18N_DEFAULT_CONFIG, ignoreAnimations: false, - slideEffect: SLIDE_EFFECTS[0], + 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, } diff --git a/src/shared/index.ts b/src/shared/index.ts index a688077e..4b15ea00 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,4 +1,4 @@ -export * from './injectSymbols' export * from './constants' -export * from './types' +export * from './injectSymbols' export * from './slideRegistry' +export * from './types' diff --git a/src/shared/slideRegistry.ts b/src/shared/slideRegistry.ts index 7f80652e..04afe3fc 100644 --- a/src/shared/slideRegistry.ts +++ b/src/shared/slideRegistry.ts @@ -16,6 +16,12 @@ const createSlideRegistry = (emit: EmitFn) => { } return { + cleanup: () => { + slides.splice(0, slides.length) + }, + + getSlides: () => slides, + registerSlide: (slide: ComponentInternalInstance, index?: number) => { if (!slide) return @@ -38,12 +44,6 @@ const createSlideRegistry = (emit: EmitFn) => { slides.splice(slideIndex, 1) updateSlideIndexes(slideIndex) }, - - cleanup: () => { - slides.splice(0, slides.length) - }, - - getSlides: () => slides, } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 09993137..a6feeb9c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,53 +1,54 @@ import { BREAKPOINT_MODE_OPTIONS, + DIR_MAP, DIR_OPTIONS, - SNAP_ALIGN_OPTIONS, I18N_DEFAULT_CONFIG, NORMALIZED_DIR_OPTIONS, - DIR_MAP, SLIDE_EFFECTS, + SNAP_ALIGN_OPTIONS, } from './constants' +export type BreakpointMode = (typeof BREAKPOINT_MODE_OPTIONS)[number] + export type Breakpoints = { [key: number]: Partial< Omit > } -export type SlideEffect = (typeof SLIDE_EFFECTS)[number] -export type SnapAlign = (typeof SNAP_ALIGN_OPTIONS)[number] - export type Dir = (typeof DIR_OPTIONS)[number] -export type BreakpointMode = (typeof BREAKPOINT_MODE_OPTIONS)[number] +export type I18nKeys = keyof typeof I18N_DEFAULT_CONFIG + +export type NonNormalizedDir = keyof typeof DIR_MAP export type NormalizedDir = (typeof NORMALIZED_DIR_OPTIONS)[number] -export type NonNormalizedDir = keyof typeof DIR_MAP +export type SlideEffect = (typeof SLIDE_EFFECTS)[number] -export type I18nKeys = keyof typeof I18N_DEFAULT_CONFIG +export type SnapAlign = (typeof SNAP_ALIGN_OPTIONS)[number] -export interface CarouselConfig { - enabled: boolean - itemsToShow: number | 'auto' - itemsToScroll: number - modelValue?: number - transition?: number - gap: number +export type CarouselConfig = { autoplay?: number - snapAlign: SnapAlign - wrapAround?: boolean - pauseAutoplayOnHover?: boolean - mouseDrag?: boolean - touchDrag?: boolean - dir?: Dir breakpointMode?: BreakpointMode breakpoints?: Breakpoints + dir?: Dir + enabled: boolean + gap: number height: string | number i18n: { [key in I18nKeys]?: string } ignoreAnimations: boolean | string[] | string - slideEffect: SlideEffect + itemsToScroll: number + itemsToShow: number | 'auto' + modelValue?: number + mouseDrag?: boolean + pauseAutoplayOnHover?: boolean preventExcessiveDragging: boolean + slideEffect: SlideEffect + snapAlign: SnapAlign + touchDrag?: boolean + transition?: number + wrapAround?: boolean } export type VueClass = string | Record | VueClass[] diff --git a/src/utils/calculateAverage.spec.ts b/src/utils/calculateAverage.spec.ts index 63a724d7..d63e2c85 100644 --- a/src/utils/calculateAverage.spec.ts +++ b/src/utils/calculateAverage.spec.ts @@ -3,18 +3,14 @@ import { describe, expect, test } from 'vitest' import { calculateAverage } from './calculateAverage' describe('calculateAverage', () => { - test('returns 0 for empty array', () => { - expect(calculateAverage([])).toBe(0) + test('calculates average for multiple values', () => { + expect(calculateAverage([1, 2, 3, 4, 5])).toBe(3) }) test('calculates average for single value', () => { expect(calculateAverage([5])).toBe(5) }) - test('calculates average for multiple values', () => { - expect(calculateAverage([1, 2, 3, 4, 5])).toBe(3) - }) - test('handles decimal numbers', () => { expect(calculateAverage([1.5, 2.5, 3.5])).toBe(2.5) }) @@ -22,4 +18,8 @@ describe('calculateAverage', () => { test('handles negative numbers', () => { expect(calculateAverage([-1, 0, 1])).toBe(0) }) + + test('returns 0 for empty array', () => { + expect(calculateAverage([])).toBe(0) + }) }) diff --git a/src/utils/camelCaseToKebabCase.spec.ts b/src/utils/camelCaseToKebabCase.spec.ts index c23b80fb..13771eb3 100644 --- a/src/utils/camelCaseToKebabCase.spec.ts +++ b/src/utils/camelCaseToKebabCase.spec.ts @@ -3,22 +3,22 @@ import { describe, it, expect } from 'vitest' import { camelCaseToKebabCase } from './camelCaseToKebabCase' describe('camelCaseToKebabCase', () => { - it('converts single camelCase word to kebab-case', () => { - expect(camelCaseToKebabCase('camelCase')).toBe('camel-case') - }) - it('converts multiple camelCase words to kebab-case', () => { expect(camelCaseToKebabCase('myVariableName')).toBe('my-variable-name') }) - it('handles strings with no uppercase letters', () => { - expect(camelCaseToKebabCase('lowercase')).toBe('lowercase') + it('converts single camelCase word to kebab-case', () => { + expect(camelCaseToKebabCase('camelCase')).toBe('camel-case') }) it('handles empty strings', () => { expect(camelCaseToKebabCase('')).toBe('') }) + it('handles strings with no uppercase letters', () => { + expect(camelCaseToKebabCase('lowercase')).toBe('lowercase') + }) + it('handles strings with numbers', () => { expect(camelCaseToKebabCase('camelCase123')).toBe('camel-case123') }) diff --git a/src/utils/createCloneSlides.spec.ts b/src/utils/createCloneSlides.spec.ts new file mode 100644 index 00000000..cb00a7bc --- /dev/null +++ b/src/utils/createCloneSlides.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { h, ComponentInternalInstance } from 'vue' + +import { createCloneSlides } from './createCloneSlides' + +function createMockSlide(index: number): ComponentInternalInstance { + return { + vnode: h('div', { key: index }, `Slide ${index}`), + // ...other properties... + } as ComponentInternalInstance +} + +describe('createCloneSlides', () => { + it('should clone slides correctly after', () => { + const slides = [createMockSlide(1), createMockSlide(2), createMockSlide(3)] + const clones = createCloneSlides({ slides, position: 'after', toShow: 1 }) + expect(clones[0].key).toBe('clone-after-0') + }) + + it('should clone slides correctly before', () => { + const slides = [createMockSlide(1), createMockSlide(2), createMockSlide(3)] + const clones = createCloneSlides({ slides, position: 'before', toShow: 1 }) + expect(clones[0].key).toBe('clone-before--1') + }) + + it('should create the correct number of clones after', () => { + const slides = [createMockSlide(1), createMockSlide(2), createMockSlide(3)] + const clones = createCloneSlides({ slides, position: 'after', toShow: 2 }) + expect(clones.length).toBe(2) + }) + + it('should create the correct number of clones before', () => { + const slides = [createMockSlide(1), createMockSlide(2), createMockSlide(3)] + const clones = createCloneSlides({ slides, position: 'before', toShow: 2 }) + expect(clones.length).toBe(2) + }) + + it('should handle empty slides array', () => { + const slides: ComponentInternalInstance[] = [] + const clones = createCloneSlides({ slides, position: 'before', toShow: 2 }) + expect(clones.length).toBe(0) + }) + + it('should handle zero clones', () => { + const slides = [createMockSlide(1), createMockSlide(2), createMockSlide(3)] + const clones = createCloneSlides({ slides, position: 'before', toShow: 0 }) + expect(clones.length).toBe(0) + }) +}) diff --git a/src/utils/disableChildrenTabbing.spec.ts b/src/utils/disableChildrenTabbing.spec.ts new file mode 100644 index 00000000..39f271c6 --- /dev/null +++ b/src/utils/disableChildrenTabbing.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { VNode } from 'vue' + +import { disableChildrenTabbing } from './disableChildrenTabbing' + +describe('disableChildrenTabbing', () => { + let container: HTMLElement + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + document.body.removeChild(container) + }) + + it('should disable tabbing for all child elements', () => { + const child1 = document.createElement('button') + const child2 = document.createElement('input') + container.appendChild(child1) + container.appendChild(child2) + + disableChildrenTabbing({ el: container } as unknown as VNode) + + expect(child1.tabIndex).toBe(-1) + expect(child2.tabIndex).toBe(-1) + }) + + it('should not affect elements outside the container', () => { + const outsideChild = document.createElement('button') + document.body.appendChild(outsideChild) + + disableChildrenTabbing({ el: container } as unknown as VNode) + + expect(outsideChild.tabIndex).not.toBe(-1) + + document.body.removeChild(outsideChild) + }) + + it('should not change tabIndex for elements that already have tabIndex -1', () => { + const child = document.createElement('button') + child.tabIndex = -1 + container.appendChild(child) + + disableChildrenTabbing({ el: container } as unknown as VNode) + + expect(child.tabIndex).toBe(-1) + }) +}) diff --git a/src/utils/getDraggedSlidesCount.spec.ts b/src/utils/getDraggedSlidesCount.spec.ts index 50065d7b..d3e4966b 100644 --- a/src/utils/getDraggedSlidesCount.spec.ts +++ b/src/utils/getDraggedSlidesCount.spec.ts @@ -13,16 +13,6 @@ describe('getDraggedSlidesCount', () => { expect(getDraggedSlidesCount(params)).toBe(-2) }) - it('should calculate the correct number of slides for vertical drag', () => { - const params = { - isVertical: true, - isReversed: false, - dragged: { x: 0, y: 150 }, - effectiveSlideSize: 100, - } - expect(getDraggedSlidesCount(params)).toBe(-2) - }) - it('should calculate the correct number of slides for reversed horizontal drag', () => { const params = { isVertical: false, @@ -43,6 +33,16 @@ describe('getDraggedSlidesCount', () => { expect(getDraggedSlidesCount(params)).toBe(2) }) + it('should calculate the correct number of slides for vertical drag', () => { + const params = { + isVertical: true, + isReversed: false, + dragged: { x: 0, y: 150 }, + effectiveSlideSize: 100, + } + expect(getDraggedSlidesCount(params)).toBe(-2) + }) + it('should handle zero drag', () => { const params = { isVertical: false, diff --git a/src/utils/getDraggedSlidesCount.ts b/src/utils/getDraggedSlidesCount.ts index 869d26ad..90e41769 100644 --- a/src/utils/getDraggedSlidesCount.ts +++ b/src/utils/getDraggedSlidesCount.ts @@ -1,4 +1,4 @@ -interface DragParams { +type DragParams = { isVertical: boolean isReversed: boolean dragged: { x: number; y: number } diff --git a/src/utils/getNumberInRange.spec.ts b/src/utils/getNumberInRange.spec.ts index c92d2938..96aa9696 100644 --- a/src/utils/getNumberInRange.spec.ts +++ b/src/utils/getNumberInRange.spec.ts @@ -3,6 +3,15 @@ import { expect, it, describe } from 'vitest' import { getNumberInRange } from '@/utils' describe('getCurrentSlideIndex', () => { + it('When min is larger than max should return val', () => { + const val = 2 + const min = 10 + const max = 5 + const results = getNumberInRange({ val, min, max }) + + expect(results).toBe(val) + }) + it('When the number inside the range should return the same value', () => { const val = 5 const min = 0 @@ -30,16 +39,6 @@ describe('getCurrentSlideIndex', () => { expect(results).toBe(min) }) - it('When the min is larger than max should return val', () => { - const val = 2 - const min = 10 - const max = 5 - const results = getNumberInRange({ val, min, max }) - - expect(results).toBe(val) - }) - - it('doesn`t bound a NaN min or max', () => { const val = 20 expect(getNumberInRange({ val, min: 0, max: NaN })).toBe(val) diff --git a/src/utils/getSnapAlignOffset.spec.ts b/src/utils/getSnapAlignOffset.spec.ts index c9e7e4f5..36a15e61 100644 --- a/src/utils/getSnapAlignOffset.spec.ts +++ b/src/utils/getSnapAlignOffset.spec.ts @@ -3,11 +3,33 @@ import { describe, expect, test } from 'vitest' import { getSnapAlignOffset } from './getSnapAlignOffset' describe('getSnapAlignOffset', () => { - describe('with itemsToShow parameter', () => { - test('should return correct offset for start alignment', () => { - expect(getSnapAlignOffset({ align: 'start', itemsToShow: 3 })).toBe(0) + describe('edge cases', () => { + test('should handle equal viewport and slide sizes', () => { + expect( + getSnapAlignOffset({ + align: 'center', + slideSize: 800, + viewportSize: 800, + }) + ).toBe(0) + }) + + test('should return 0 for invalid alignment', () => { + expect( + getSnapAlignOffset({ + align: 'invalid' as any, + slideSize: 200, + viewportSize: 800, + }) + ).toBe(0) }) + test('should return 0 when no parameters provided', () => { + expect(getSnapAlignOffset({ align: 'start' })).toBe(0) + }) + }) + + describe('with itemsToShow parameter', () => { test('should return correct offset for center/center-odd alignment', () => { expect(getSnapAlignOffset({ align: 'center', itemsToShow: 3 })).toBe(1) expect(getSnapAlignOffset({ align: 'center-odd', itemsToShow: 5 })).toBe(2) @@ -20,19 +42,13 @@ describe('getSnapAlignOffset', () => { test('should return correct offset for end alignment', () => { expect(getSnapAlignOffset({ align: 'center-even', itemsToShow: 4 })).toBe(1) }) - }) - describe('with slideSize and viewportSize parameters', () => { test('should return correct offset for start alignment', () => { - expect( - getSnapAlignOffset({ - align: 'start', - slideSize: 200, - viewportSize: 800, - }) - ).toBe(0) + expect(getSnapAlignOffset({ align: 'start', itemsToShow: 3 })).toBe(0) }) + }) + describe('with slideSize and viewportSize parameters', () => { test('should return correct offset for center/center-odd alignment', () => { expect( getSnapAlignOffset({ @@ -70,31 +86,15 @@ describe('getSnapAlignOffset', () => { }) ).toBe(600) }) - }) - - describe('edge cases', () => { - test('should return 0 when no parameters provided', () => { - expect(getSnapAlignOffset({ align: 'start' })).toBe(0) - }) - test('should return 0 for invalid alignment', () => { + test('should return correct offset for start alignment', () => { expect( getSnapAlignOffset({ - align: 'invalid' as any, + align: 'start', slideSize: 200, viewportSize: 800, }) ).toBe(0) }) - - test('should handle equal viewport and slide sizes', () => { - expect( - getSnapAlignOffset({ - align: 'center', - slideSize: 800, - viewportSize: 800, - }) - ).toBe(0) - }) }) }) diff --git a/src/utils/getSnapAlignOffset.ts b/src/utils/getSnapAlignOffset.ts index 14c41181..5d5a481b 100644 --- a/src/utils/getSnapAlignOffset.ts +++ b/src/utils/getSnapAlignOffset.ts @@ -1,6 +1,6 @@ import { SnapAlign } from '@/shared' -interface SnapAlignOffsetParams { +type SnapAlignOffsetParams = { align: SnapAlign slideSize?: number viewportSize?: number diff --git a/src/utils/i18nFormatter.spec.ts b/src/utils/i18nFormatter.spec.ts new file mode 100644 index 00000000..4df9bb20 --- /dev/null +++ b/src/utils/i18nFormatter.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { i18nFormatter } from './i18nFormatter' + +describe('i18nFormatter', () => { + it('handles empty string input', () => { + const result = i18nFormatter('', { name: 'World' }) + expect(result).toBe('') + }) + + it('handles no values input', () => { + const result = i18nFormatter('Hello, World!') + expect(result).toBe('Hello, World!') + }) + + it('leaves placeholders without corresponding values unchanged', () => { + const result = i18nFormatter('Hello, {name}!', {}) + expect(result).toBe('Hello, {name}!') + }) + + it('replaces multiple placeholders', () => { + const result = i18nFormatter('Hello, {name}! You have {count} messages.', { + name: 'Alice', + count: 5, + }) + expect(result).toBe('Hello, Alice! You have 5 messages.') + }) + + it('replaces placeholders with corresponding values', () => { + const result = i18nFormatter('Hello, {name}!', { name: 'World' }) + expect(result).toBe('Hello, World!') + }) +}) diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c551dfb..1479a437 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,12 +1,12 @@ -export * from './getNumberInRange' -export * from './mapNumberToRange' -export * from './i18nFormatter' -export * from './throttle' -export * from './except' -export * from './getScaleMultipliers' +export * from './calculateAverage' export * from './createCloneSlides' export * from './disableChildrenTabbing' +export * from './except' export * from './getDraggedSlidesCount' +export * from './getNumberInRange' +export * from './getScaleMultipliers' export * from './getSnapAlignOffset' +export * from './i18nFormatter' +export * from './mapNumberToRange' +export * from './throttle' export * from './toCssValue' -export * from './calculateAverage' diff --git a/src/utils/mapNumberToRange.spec.ts b/src/utils/mapNumberToRange.spec.ts index effd1459..7038ae31 100644 --- a/src/utils/mapNumberToRange.spec.ts +++ b/src/utils/mapNumberToRange.spec.ts @@ -3,6 +3,24 @@ import { expect, it, describe } from 'vitest' import { mapNumberToRange } from '@/utils' describe('getCurrentSlideIndex', () => { + it('Keeps float values less than 1 over max', () => { + const val = 20.4 + const min = 10 + const max = 20 + const results = mapNumberToRange({ val, min, max }) + + expect(results).toBe(val) + }) + + it('When min is non zero should return correctly mapped value', () => { + const val = 5 + const min = 10 + const max = 20 + const results = mapNumberToRange({ val, min, max }) + + expect(results).toBe(16) + }) + it('When the number inside the range should return the same value', () => { const val = 5 const min = 0 @@ -29,31 +47,13 @@ describe('getCurrentSlideIndex', () => { expect(results).toBe(7) }) - - it('When min is non zero should return correctly mapped value', () => { - const val = 5 - const min = 10 - const max = 20 - const results = mapNumberToRange({ val, min, max }) - - expect(results).toBe(16) - }) - it('Keeps float values less than 1 over max', () => { - const val = 20.4 - const min = 10 - const max = 20 - const results = mapNumberToRange({ val, min, max }) - - expect(results).toBe(val) - }) - it('Wraps float values more than 1 over max', () => { const val = 21.4 const min = 10 const max = 20 const results = mapNumberToRange({ val, min, max }) - expect(results).toBe((21.4 - 11)) // 10.4 but beware float point rounding error + expect(results).toBe(21.4 - 11) // 10.4 but beware float point rounding error }) }) diff --git a/src/utils/throttle.spec.ts b/src/utils/throttle.spec.ts new file mode 100644 index 00000000..95621359 --- /dev/null +++ b/src/utils/throttle.spec.ts @@ -0,0 +1,23 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { throttle } from './throttle' + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('should call the function again after the wait time', () => { + const fn = vi.fn() + const throttledFn = throttle(fn, 0) + throttledFn() + vi.advanceTimersByTime(16) + throttledFn() + vi.advanceTimersByTime(16) + + expect(fn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/utils/toCssValue.spec.ts b/src/utils/toCssValue.spec.ts index f4eefc46..b35b93a0 100644 --- a/src/utils/toCssValue.spec.ts +++ b/src/utils/toCssValue.spec.ts @@ -7,15 +7,15 @@ describe('toCssValue', () => { expect(toCssValue(10)).toBe('10px') }) - it('should return string as is', () => { - expect(toCssValue('20%')).toBe('20%') + it('should handle empty string correctly', () => { + expect(toCssValue('')).toBe(undefined) }) it('should handle zero correctly', () => { expect(toCssValue(0)).toBe('0px') }) - it('should handle empty string correctly', () => { - expect(toCssValue('')).toBe(undefined) + it('should return string as is', () => { + expect(toCssValue('20%')).toBe('20%') }) })