From ec99beba8a16f4b5af60563f6de7e4c36fc3eb79 Mon Sep 17 00:00:00 2001 From: Ismail9k Date: Mon, 23 Dec 2024 21:15:32 +0300 Subject: [PATCH 1/6] feat: enhance Carousel component with active slide tracking and drag functionality --- src/components/Carousel/Carousel.ts | 43 ++++++++---------- src/components/Carousel/Carousel.types.ts | 1 + src/components/Slide/Slide.ts | 6 +-- src/utils/getDraggedSlidesCount.spec.ts | 55 +++++++++++++++++++++++ src/utils/getDraggedSlidesCount.ts | 25 +++++++++++ src/utils/index.ts | 1 + 6 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 src/utils/getDraggedSlidesCount.spec.ts create mode 100644 src/utils/getDraggedSlidesCount.ts diff --git a/src/components/Carousel/Carousel.ts b/src/components/Carousel/Carousel.ts index b9203a4..71f8625 100644 --- a/src/components/Carousel/Carousel.ts +++ b/src/components/Carousel/Carousel.ts @@ -36,6 +36,7 @@ import { getScrolledIndex, getTransformValues, createCloneSlides, + getDraggedSlidesCount, } from '@/utils' import { @@ -81,6 +82,9 @@ export const Carousel = defineComponent({ // slides const currentSlideIndex = ref(props.modelValue ?? 0) + const activeSlideIndex = ref(currentSlideIndex.value) + + watch(currentSlideIndex, (val) => (activeSlideIndex.value = val)) const prevSlideIndex = ref(0) const middleSlideIndex = computed(() => Math.ceil((slidesCount.value - 1) / 2)) const maxSlideIndex = computed(() => { @@ -391,32 +395,27 @@ export const Carousel = defineComponent({ const currentY = 'touches' in event ? event.touches[0].clientY : event.clientY // Calculate deltas for X and Y axes - const deltaX = currentX - startPosition.x - const deltaY = currentY - startPosition.y + dragged.x = currentX - startPosition.x + dragged.y = currentY - startPosition.y + + const draggedSlides = getDraggedSlidesCount({ + isVertical: isVertical.value, + isReversed: isReversed.value, + dragged, + effectiveSlideSize: effectiveSlideSize.value, + }) - // Update dragged state reactively - dragged.x = deltaX - dragged.y = deltaY + activeSlideIndex.value = currentSlideIndex.value + draggedSlides // Emit a drag event for further customization if needed - emit('drag', { deltaX, deltaY }) + emit('drag', { deltaX: dragged.x, deltaY: dragged.y }) }) function handleDragEnd(): void { handleDragging.cancel() - // Determine the active axis and direction multiplier - const dragAxis = isVertical.value ? 'y' : 'x' - const directionMultiplier = isReversed.value ? -1 : 1 - - // Calculate dragged slides with a tolerance to account for incomplete drags - const tolerance = Math.sign(dragged[dragAxis]) * 0.4 // Smooth out small drags - const draggedSlides = - Math.round(dragged[dragAxis] / effectiveSlideSize.value + tolerance) * - directionMultiplier - // Prevent accidental clicks when there is a slide drag - if (draggedSlides && !isTouch) { + if (activeSlideIndex.value !== currentSlideIndex.value && !isTouch) { const preventClick = (e: MouseEvent) => { e.preventDefault() window.removeEventListener('click', preventClick) @@ -424,9 +423,7 @@ export const Carousel = defineComponent({ window.addEventListener('click', preventClick) } - // Slide to the appropriate slide index - const targetSlideIndex = currentSlideIndex.value - draggedSlides - slideTo(targetSlideIndex) + slideTo(activeSlideIndex.value) // Reset drag state dragged.x = 0 @@ -482,10 +479,7 @@ export const Carousel = defineComponent({ min: minSlideIndex.value, }) - if ( - currentSlideIndex.value === currentVal || - (!skipTransition && isSliding.value) - ) { + if (!skipTransition && isSliding.value) { return } @@ -562,6 +556,7 @@ export const Carousel = defineComponent({ clonedSlidesCount, scrolledIndex, currentSlide: currentSlideIndex, + activeSlide: activeSlideIndex, maxSlide: maxSlideIndex, minSlide: minSlideIndex, slideSize, diff --git a/src/components/Carousel/Carousel.types.ts b/src/components/Carousel/Carousel.types.ts index 8ff49a3..697740b 100644 --- a/src/components/Carousel/Carousel.types.ts +++ b/src/components/Carousel/Carousel.types.ts @@ -20,6 +20,7 @@ export type InjectedCarousel = Reactive<{ slides: ShallowReactive> slidesCount: ComputedRef clonedSlidesCount: ComputedRef + activeSlide: Ref currentSlide: Ref scrolledIndex: Ref maxSlide: ComputedRef diff --git a/src/components/Slide/Slide.ts b/src/components/Slide/Slide.ts index e5db0d0..a02c2db 100644 --- a/src/components/Slide/Slide.ts +++ b/src/components/Slide/Slide.ts @@ -56,13 +56,13 @@ export const Slide = defineComponent({ }) const isActive: ComputedRef = computed( - () => currentIndex.value === carousel.currentSlide + () => currentIndex.value === carousel.activeSlide ) const isPrev: ComputedRef = computed( - () => currentIndex.value === carousel.currentSlide - 1 + () => currentIndex.value === carousel.activeSlide - 1 ) const isNext: ComputedRef = computed( - () => currentIndex.value === carousel.currentSlide + 1 + () => currentIndex.value === carousel.activeSlide + 1 ) const isVisible: ComputedRef = computed( () => diff --git a/src/utils/getDraggedSlidesCount.spec.ts b/src/utils/getDraggedSlidesCount.spec.ts new file mode 100644 index 0000000..50065d7 --- /dev/null +++ b/src/utils/getDraggedSlidesCount.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +import { getDraggedSlidesCount } from './getDraggedSlidesCount' + +describe('getDraggedSlidesCount', () => { + it('should calculate the correct number of slides for horizontal drag', () => { + const params = { + isVertical: false, + isReversed: false, + dragged: { x: 150, y: 0 }, + effectiveSlideSize: 100, + } + 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, + isReversed: true, + dragged: { x: 150, y: 0 }, + effectiveSlideSize: 100, + } + expect(getDraggedSlidesCount(params)).toBe(2) + }) + + it('should calculate the correct number of slides for reversed vertical drag', () => { + const params = { + isVertical: true, + isReversed: true, + dragged: { x: 0, y: 150 }, + effectiveSlideSize: 100, + } + expect(getDraggedSlidesCount(params)).toBe(2) + }) + + it('should handle zero drag', () => { + const params = { + isVertical: false, + isReversed: false, + dragged: { x: 0, y: 0 }, + effectiveSlideSize: 100, + } + expect(getDraggedSlidesCount(params)).toBe(0) + }) +}) diff --git a/src/utils/getDraggedSlidesCount.ts b/src/utils/getDraggedSlidesCount.ts new file mode 100644 index 0000000..869d26a --- /dev/null +++ b/src/utils/getDraggedSlidesCount.ts @@ -0,0 +1,25 @@ +interface DragParams { + isVertical: boolean + isReversed: boolean + dragged: { x: number; y: number } + effectiveSlideSize: number +} + +/** + * Calculates the number of slides to move based on drag movement + * @param params Configuration parameters for drag calculation + * @returns Number of slides to move (positive or negative) + */ +export function getDraggedSlidesCount(params: DragParams): number { + const { isVertical, isReversed, dragged, effectiveSlideSize } = params + + // Get drag value based on direction + const dragValue = isVertical ? dragged.y : dragged.x + + // If no drag, return +0 explicitly + if (dragValue === 0) return 0 + + const slidesDragged = Math.round(dragValue / effectiveSlideSize) + + return isReversed ? slidesDragged : -slidesDragged +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4030bc6..784da7d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from './except' export * from './getTransformValues' export * from './createCloneSlides' export * from './disableChildrenTabbing' +export * from './getDraggedSlidesCount' From 2ef40342a93bbe526651f11aa3cd04ba2214039a Mon Sep 17 00:00:00 2001 From: Ismail9k Date: Mon, 23 Dec 2024 21:28:14 +0300 Subject: [PATCH 2/6] feat: update configuration handling to support numeric fields and increase gap size --- playground/App.vue | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/playground/App.vue b/playground/App.vue index 80f674a..718972d 100644 --- a/playground/App.vue +++ b/playground/App.vue @@ -36,7 +36,7 @@ const defaultConfig = { height: '200', dir: 'left-to-right', breakpointMode: 'carousel', - gap: 10, + gap: 20, pauseAutoplayOnHover: true, useBreakpoints: false, } @@ -46,7 +46,14 @@ const items = reactive([...defaultSlides]) const getConfigValue = (path: string) => config[path] -const setConfigValue = (path: string, value: any) => (config[path] = value) +const setConfigValue = ({ type, path }, value: any) => { + // Convert string values to numbers for numeric fields + if (type === 'number') { + config[path] = Number(value) + } else { + config[path] = value + } +} const formFields = [ { @@ -68,6 +75,12 @@ const formFields = [ path: 'height', attrs: { step: '100', min: '200', max: '1000' }, }, + { + type: 'number', + label: 'Gap', + path: 'gap', + attrs: { step: '1', min: '0', max: '20' }, + }, ], }, { @@ -135,7 +148,9 @@ const handleReset = () => { } // Reset config values - Object.entries(defaultConfig).forEach(([key, value]) => setConfigValue(key, value)) + Object.entries(defaultConfig).forEach(([key, value]) => + setConfigValue({ path: key }, value) + ) // Reset items items.splice(0, items.length, ...defaultSlides) } @@ -250,7 +265,7 @@ const handleEvent = (eventName: string) => (data?: any) => {
-
From c7db79b6d5ccf6445054ed4cbc200527b9f74003 Mon Sep 17 00:00:00 2001 From: Ismail9k Date: Tue, 24 Dec 2024 22:03:19 +0300 Subject: [PATCH 3/6] feat: improve Carousel component with cloned slide handling to generate cloned slides as needed --- playground/App.vue | 2 +- src/components/Carousel/Carousel.css | 14 +++++ src/components/Carousel/Carousel.ts | 62 ++++++++++++------- src/components/Carousel/Carousel.types.ts | 1 - src/components/Pagination/Pagination.ts | 21 +++++-- src/components/Slide/Slide.ts | 12 ++-- src/shared/slideRegistry.ts | 7 +++ .../__snapshots__/carousel.spec.ts.snap | 23 ++----- tests/integration/pagination.spec.ts | 22 ++++--- 9 files changed, 102 insertions(+), 62 deletions(-) diff --git a/playground/App.vue b/playground/App.vue index 718972d..1dc8af5 100644 --- a/playground/App.vue +++ b/playground/App.vue @@ -36,7 +36,7 @@ const defaultConfig = { height: '200', dir: 'left-to-right', breakpointMode: 'carousel', - gap: 20, + gap: 10, pauseAutoplayOnHover: true, useBreakpoints: false, } diff --git a/src/components/Carousel/Carousel.css b/src/components/Carousel/Carousel.css index 7d1d87d..828c8d1 100644 --- a/src/components/Carousel/Carousel.css +++ b/src/components/Carousel/Carousel.css @@ -15,6 +15,8 @@ } .carousel__track { + --vc-cloned-slides-offset: 0px; + display: flex; padding: 0 !important; margin: 0 !important; @@ -49,3 +51,15 @@ flex-direction: column-reverse; } } + +.carousel.is-vertical { + .carousel__slide--clone:first-child { + margin-block-start: var(--vc-cloned-slides-offset); + } +} + +.carousel:not(.is-vertical) { + .carousel__slide--clone:first-child { + margin-inline-start: var(--vc-cloned-slides-offset); + } +} diff --git a/src/components/Carousel/Carousel.ts b/src/components/Carousel/Carousel.ts index 71f8625..d752e30 100644 --- a/src/components/Carousel/Carousel.ts +++ b/src/components/Carousel/Carousel.ts @@ -8,7 +8,6 @@ import { computed, h, watch, - VNode, SetupContext, Ref, ComputedRef, @@ -108,8 +107,6 @@ export const Carousel = defineComponent({ const isReversed = computed(() => ['rtl', 'btt'].includes(normalizedDir.value)) const isVertical = computed(() => ['ttb', 'btt'].includes(normalizedDir.value)) - const clonedSlidesCount = computed(() => Math.ceil(config.itemsToShow) + 1) - function updateBreakpointsConfig(): void { if (!mounted.value) { return @@ -553,7 +550,6 @@ export const Carousel = defineComponent({ slidesCount, viewport, slides, - clonedSlidesCount, scrolledIndex, currentSlide: currentSlideIndex, activeSlide: activeSlideIndex, @@ -652,16 +648,13 @@ export const Carousel = defineComponent({ */ const trackTransform: ComputedRef = computed(() => { // Calculate the scrolled index with wrapping offset if applicable - const cloneOffset = config.wrapAround && mounted.value ? clonedSlidesCount.value : 0 // Determine direction multiplier for orientation const directionMultiplier = isReversed.value ? -1 : 1 // Calculate the total offset for slide transformation const totalOffset = - (scrolledIndex.value + cloneOffset) * - effectiveSlideSize.value * - directionMultiplier + scrolledIndex.value * effectiveSlideSize.value * directionMultiplier // Include user drag interaction offset const dragOffset = isVertical.value ? dragged.y : dragged.x @@ -671,11 +664,49 @@ export const Carousel = defineComponent({ return `translate${translateAxis}(${dragOffset - totalOffset}px)` }) + const clonedSlidesCount = computed(() => { + if (!config.wrapAround) { + return { before: 0, after: 0 } + } + + const slidesToClone = Math.ceil(config.itemsToShow) + return { + before: Math.max(0, slidesToClone - activeSlideIndex.value), + after: Math.max( + 0, + slidesToClone - (slidesCount.value - activeSlideIndex.value - 1) + ), + } + }) + + const trackStyle = computed(() => ({ + transform: trackTransform.value, + 'transition-duration': isSliding.value ? `${config.transition}ms` : undefined, + gap: config.gap > 0 ? `${config.gap}px` : undefined, + '--vc-trk-height': trackHeight.value, + '--vc-cloned-slides-offset': `-${clonedSlidesCount.value.before * effectiveSlideSize.value}px`, + })) + return () => { const slotSlides = slots.default || slots.slides const slotAddons = slots.addons - let output: VNode[] | Array> = slotSlides?.(data) || [] + const outputSlides = slotSlides?.(data) || [] + + const { before, after } = clonedSlidesCount.value + const slidesBefore = createCloneSlides({ + slides, + position: 'before', + toShow: before, + }) + + const slidesAfter = createCloneSlides({ + slides, + position: 'after', + toShow: after, + }) + + const output = [...slidesBefore, ...outputSlides, ...slidesAfter] if (!config.enabled || !output.length) { return h( @@ -690,22 +721,11 @@ export const Carousel = defineComponent({ const addonsElements = slotAddons?.(data) || [] - if (config.wrapAround) { - const toShow = clonedSlidesCount.value - const slidesBefore = createCloneSlides({ slides, position: 'before', toShow }) - const slidesAfter = createCloneSlides({ slides, position: 'after', toShow }) - output = [...slidesBefore, ...output, ...slidesAfter] - } - const trackEl = h( 'ol', { class: 'carousel__track', - style: { - transform: trackTransform.value, - 'transition-duration': isSliding.value ? `${config.transition}ms` : undefined, - gap: config.gap > 0 ? `${config.gap}px` : undefined, - }, + style: trackStyle.value, onMousedownCapture: config.mouseDrag ? handleDragStart : null, onTouchstartPassiveCapture: config.touchDrag ? handleDragStart : null, }, diff --git a/src/components/Carousel/Carousel.types.ts b/src/components/Carousel/Carousel.types.ts index 697740b..d677b84 100644 --- a/src/components/Carousel/Carousel.types.ts +++ b/src/components/Carousel/Carousel.types.ts @@ -19,7 +19,6 @@ export type InjectedCarousel = Reactive<{ viewport: Ref slides: ShallowReactive> slidesCount: ComputedRef - clonedSlidesCount: ComputedRef activeSlide: Ref currentSlide: Ref scrolledIndex: Ref diff --git a/src/components/Pagination/Pagination.ts b/src/components/Pagination/Pagination.ts index fdf7f2a..3c0abc1 100644 --- a/src/components/Pagination/Pagination.ts +++ b/src/components/Pagination/Pagination.ts @@ -12,7 +12,7 @@ export const Pagination = defineComponent({ type: Boolean, }, paginateByItemsToShow: { - type: Boolean + type: Boolean, }, }, setup(props) { @@ -22,10 +22,14 @@ export const Pagination = defineComponent({ return () => '' // Don't render, let vue warn about the missing provide } - const offset = computed(() => calculateOffset(carousel.config.snapAlign, carousel.config.itemsToShow)) - const isPaginated = computed(() => props.paginateByItemsToShow && carousel.config.itemsToShow > 1) + const offset = computed(() => + calculateOffset(carousel.config.snapAlign, carousel.config.itemsToShow) + ) + const isPaginated = computed( + () => props.paginateByItemsToShow && carousel.config.itemsToShow > 1 + ) const currentPage = computed(() => - Math.ceil((carousel.currentSlide - offset.value) / carousel.config.itemsToShow) + Math.ceil((carousel.activeSlide - offset.value) / carousel.config.itemsToShow) ) const pageCount = computed(() => Math.ceil(carousel.slidesCount / carousel.config.itemsToShow) @@ -40,7 +44,7 @@ export const Pagination = defineComponent({ min: 0, } : { - val: carousel.currentSlide, + val: carousel.activeSlide, max: carousel.maxSlide, min: carousel.minSlide, } @@ -74,7 +78,12 @@ export const Pagination = defineComponent({ 'aria-controls': carousel.slides[slide]?.exposed?.id, title: buttonLabel, disabled: props.disableOnClick, - onClick: () => carousel.nav.slideTo(isPaginated.value ? slide * carousel.config.itemsToShow + offset.value : slide), + onClick: () => + carousel.nav.slideTo( + isPaginated.value + ? slide * carousel.config.itemsToShow + offset.value + : slide + ), }) const item = h('li', { class: 'carousel__pagination-item', key: slide }, button) children.push(item) diff --git a/src/components/Slide/Slide.ts b/src/components/Slide/Slide.ts index a02c2db..e478e52 100644 --- a/src/components/Slide/Slide.ts +++ b/src/components/Slide/Slide.ts @@ -85,12 +85,12 @@ export const Slide = defineComponent({ const instance = getCurrentInstance()! - if (!props.isClone) { - carousel.slideRegistry.registerSlide(instance, props.index) - onUnmounted(() => { - carousel.slideRegistry.unregisterSlide(instance) - }) - } else { + carousel.slideRegistry.registerSlide(instance, props.index) + onUnmounted(() => { + carousel.slideRegistry.unregisterSlide(instance) + }) + + if (props.isClone) { // Prevent cloned slides from being focusable onMounted(() => { disableChildrenTabbing(instance.vnode) diff --git a/src/shared/slideRegistry.ts b/src/shared/slideRegistry.ts index 1f99b37..d98e72d 100644 --- a/src/shared/slideRegistry.ts +++ b/src/shared/slideRegistry.ts @@ -2,6 +2,7 @@ import { ComponentInternalInstance, EmitFn, shallowReactive } from 'vue' const createSlideRegistry = (emit: EmitFn) => { const slides = shallowReactive>([]) + const clonedSlides = shallowReactive>([]) const updateSlideIndexes = (startIndex?: number) => { if (startIndex !== undefined) { @@ -19,6 +20,11 @@ const createSlideRegistry = (emit: EmitFn) => { registerSlide: (slide: ComponentInternalInstance, index?: number) => { if (!slide) return + if (slide.props.isClone) { + clonedSlides.push(slide) + return + } + const slideIndex = index ?? slides.length slides.splice(slideIndex, 0, slide) updateSlideIndexes(slideIndex) @@ -40,6 +46,7 @@ const createSlideRegistry = (emit: EmitFn) => { }, getSlides: () => slides, + getClonedSlides: () => clonedSlides, } } diff --git a/tests/integration/__snapshots__/carousel.spec.ts.snap b/tests/integration/__snapshots__/carousel.spec.ts.snap index c465c84..0d2103b 100644 --- a/tests/integration/__snapshots__/carousel.spec.ts.snap +++ b/tests/integration/__snapshots__/carousel.spec.ts.snap @@ -4,18 +4,13 @@ exports[`SSR Carousel > renders server side properly 1`] = ` "
" `; -exports[`SSR Carousel > renders server side properly 2`] = `"
"`; +exports[`SSR Carousel > renders server side properly 2`] = `"
"`; exports[`SSR Carousel > renders slotted server side properly 1`] = ` "
" `; -exports[`SSR Carousel > renders slotted server side properly 2`] = `"
"`; +exports[`SSR Carousel > renders slotted server side properly 2`] = `"
"`; exports[`Wrap around Carousel.ts > renders wrapAround correctly 1`] = ` ""`; +exports[`SSR Carousel > renders server side properly 2`] = `"
"`; exports[`SSR Carousel > renders slotted server side properly 1`] = ` "
@@ -51,7 +51,7 @@ exports[`SSR Carousel > renders slotted server side properly 1`] = `
" `; -exports[`SSR Carousel > renders slotted server side properly 2`] = `"
"`; +exports[`SSR Carousel > renders slotted server side properly 2`] = `"
"`; exports[`Wrap around Carousel.ts > renders wrapAround correctly 1`] = ` "