diff --git a/packages/components/carousel/__tests__/carousel.spec.tsx b/packages/components/carousel/__tests__/carousel.spec.tsx index 170f991720..3d1f2400f8 100644 --- a/packages/components/carousel/__tests__/carousel.spec.tsx +++ b/packages/components/carousel/__tests__/carousel.spec.tsx @@ -2,27 +2,49 @@ import { nextTick, reactive } from 'vue' import { mount } from '@vue/test-utils' import Carousel from '../src/carousel.vue' import CarouselItem from '../src/carousel-item.vue' + +import type { VueWrapper } from '@vue/test-utils' import type { CarouselInstance } from '../src/carousel' const wait = (ms = 100) => new Promise((resolve) => setTimeout(() => resolve(0), ms)) -const generateCarouselItems = (count = 3) => { +const generateCarouselItems = (count = 3, hasLabel = false) => { const list = Array.from({ length: count }, (_, index) => index + 1) - return list.map((i) => ) + return list.map((i) => + hasLabel ? : + ) } describe('Carousel', () => { - it('create', () => { - const wrapper = mount({ + let wrapper: VueWrapper + + const createComponent = ( + props: any = {}, + count?: number, + hasLabel?: boolean + ) => { + return mount({ setup() { return () => (
- {generateCarouselItems()} + + {generateCarouselItems(count, hasLabel)} +
) }, }) + } + + afterEach(() => { + wrapper.unmount() + }) + + it('create', () => { + wrapper = createComponent({ + ref: 'carousel', + }) const carousel = wrapper.findComponent({ ref: 'carousel' }) .vm as CarouselInstance @@ -31,11 +53,9 @@ describe('Carousel', () => { }) it('auto play', async (done) => { - const wrapper = mount(() => ( -
- {generateCarouselItems()} -
- )) + wrapper = createComponent({ + interval: 50, + }) await nextTick() await wait(10) @@ -47,13 +67,10 @@ describe('Carousel', () => { }) it('initial index', async (done) => { - const wrapper = mount(() => ( -
- - {generateCarouselItems()} - -
- )) + wrapper = createComponent({ + autoplay: false, + 'initial-index': 1, + }) await nextTick() await wait(10) @@ -67,11 +84,9 @@ describe('Carousel', () => { }) it('reset timer', async (done) => { - const wrapper = mount(() => ( -
- {generateCarouselItems()} -
- )) + wrapper = createComponent({ + interval: 500, + }) await nextTick() const items = wrapper.vm.$el.querySelectorAll('.el-carousel__item') await wrapper.trigger('mouseenter') @@ -90,21 +105,12 @@ describe('Carousel', () => { oldVal: -1, }) - mount({ - setup() { - const handleChange = (val: number, oldVal: number) => { - state.val = val - state.oldVal = oldVal - } - - return () => ( -
- - {generateCarouselItems()} - -
- ) + wrapper = createComponent({ + onChange(val: number, prevVal: number) { + state.val = val + state.oldVal = prevVal }, + interval: 50, }) await nextTick() @@ -115,15 +121,7 @@ describe('Carousel', () => { }) it('label', async (done) => { - const wrapper = mount(() => ( -
- - {[1, 2, 3].map((i) => ( - - ))} - -
- )) + wrapper = createComponent(undefined, 3, true) await nextTick() expect(wrapper.find('.el-carousel__button span').text()).toBe('1') done() @@ -131,11 +129,9 @@ describe('Carousel', () => { describe('manual control', () => { it('hover', async (done) => { - const wrapper = mount(() => ( -
- {generateCarouselItems()} -
- )) + wrapper = createComponent({ + autoplay: false, + }) await nextTick() await wait() @@ -152,13 +148,14 @@ describe('Carousel', () => { }) it('card', async (done) => { - const wrapper = mount(() => ( -
- - {generateCarouselItems(7)} - -
- )) + wrapper = createComponent( + { + autoplay: false, + type: 'card', + }, + 7 + ) + await nextTick() await wait() const items = wrapper.vm.$el.querySelectorAll('.el-carousel__item') @@ -178,21 +175,11 @@ describe('Carousel', () => { }) it('vertical direction', () => { - const wrapper = mount({ - setup() { - return () => ( -
- - {generateCarouselItems()} - -
- ) - }, + wrapper = createComponent({ + ref: 'carousel', + autoplay: false, + direction: 'vertical', + height: '100px', }) const items = wrapper.vm.$el.querySelectorAll('.el-carousel__item') const carousel = wrapper.findComponent({ ref: 'carousel' }) @@ -202,13 +189,10 @@ describe('Carousel', () => { }) it('pause auto play on hover', async (done) => { - const wrapper = mount(() => ( -
- - {generateCarouselItems()} - -
- )) + wrapper = createComponent({ + interval: 50, + 'pause-on-hover': false, + }) await nextTick() await wrapper.find('.el-carousel').trigger('mouseenter') diff --git a/packages/components/carousel/src/carousel-item.vue b/packages/components/carousel/src/carousel-item.vue index 622cd06dda..fc1ec114e8 100644 --- a/packages/components/carousel/src/carousel-item.vue +++ b/packages/components/carousel/src/carousel-item.vue @@ -7,12 +7,12 @@ ns.is('in-stage', inStage), ns.is('hover', hover), ns.is('animating', animating), - { [ns.em('item', 'card')]: type === 'card' }, + { [ns.em('item', 'card')]: isCardType }, ]" :style="itemStyle" @click="handleItemClick" > -
+
@@ -25,11 +25,14 @@ import { getCurrentInstance, onUnmounted, ref, + reactive, + unref, } from 'vue' -import { debugWarn } from '@element-plus/utils' +import { debugWarn, isUndefined } from '@element-plus/utils' import { useNamespace } from '@element-plus/hooks' import { carouselContextKey } from '@element-plus/tokens' import { carouselItemProps } from './carousel-item' + import type { CSSProperties } from 'vue' defineOptions({ @@ -37,16 +40,27 @@ defineOptions({ }) const props = defineProps(carouselItemProps) - -// inject -const carouselContext = inject(carouselContextKey) - const ns = useNamespace('carousel') -const CARD_SCALE = 0.83 -const type = carouselContext?.type - +const COMPONENT_NAME = 'ElCarouselItem' +// inject +const carouselContext = inject(carouselContextKey)! // instance -const instance = getCurrentInstance() +const instance = getCurrentInstance()! +if (!carouselContext) { + debugWarn( + COMPONENT_NAME, + 'usage: ' + ) +} + +if (!instance) { + debugWarn( + COMPONENT_NAME, + 'compositional hook can only be invoked inside setups' + ) +} + +const CARD_SCALE = 0.83 const hover = ref(false) const translate = ref(0) @@ -57,37 +71,41 @@ const inStage = ref(false) const animating = ref(false) // computed -const parentDirection = computed(() => { - return carouselContext?.direction -}) +const { isCardType, isVertical } = carouselContext -const itemStyle = computed(() => { - const translateType = - parentDirection.value === 'vertical' ? 'translateY' : 'translateX' - const value = `${translateType}(${translate.value}px) scale(${scale.value})` - const style: CSSProperties = { - transform: value, +const itemStyle = computed(() => { + const translateType = `translate${unref(isVertical) ? 'Y' : 'X'}` + const _translate = `${translateType}(${unref(translate)}px)` + const _scale = `scale(${unref(scale)})` + const transform = [_translate, _scale].join(' ') + + return { + transform, } - return style }) // methods -function processIndex(index, activeIndex, length) { - if (activeIndex === 0 && index === length - 1) { +function processIndex(index: number, activeIndex: number, length: number) { + const lastItemIndex = length - 1 + const prevItemIndex = activeIndex - 1 + const nextItemIndex = activeIndex + 1 + const halfItemIndex = length / 2 + + if (activeIndex === 0 && index === lastItemIndex) { return -1 - } else if (activeIndex === length - 1 && index === 0) { + } else if (activeIndex === lastItemIndex && index === 0) { return length - } else if (index < activeIndex - 1 && activeIndex - index >= length / 2) { + } else if (index < prevItemIndex && activeIndex - index >= halfItemIndex) { return length + 1 - } else if (index > activeIndex + 1 && index - activeIndex >= length / 2) { + } else if (index > nextItemIndex && index - activeIndex >= halfItemIndex) { return -2 } return index } -function calcCardTranslate(index, activeIndex) { - const parentWidth = carouselContext?.root.value?.offsetWidth || 0 +function calcCardTranslate(index: number, activeIndex: number) { + const parentWidth = carouselContext.root.value?.offsetWidth || 0 if (inStage.value) { return (parentWidth * ((2 - CARD_SCALE) * (index - activeIndex) + 1)) / 4 } else if (index < activeIndex) { @@ -97,58 +115,66 @@ function calcCardTranslate(index, activeIndex) { } } -function calcTranslate(index, activeIndex, isVertical) { - const distance = - (isVertical - ? carouselContext?.root.value?.offsetHeight - : carouselContext?.root.value?.offsetWidth) || 0 +function calcTranslate( + index: number, + activeIndex: number, + isVertical: boolean +) { + const rootEl = carouselContext.root.value + if (!rootEl) return 0 + + const distance = (isVertical ? rootEl.offsetHeight : rootEl.offsetWidth) || 0 return distance * (index - activeIndex) } const translateItem = ( index: number, activeIndex: number, - oldIndex: number + oldIndex?: number ) => { - const parentType = carouselContext?.type - const length = carouselContext?.items.value.length ?? Number.NaN - if (parentType !== 'card' && oldIndex !== undefined) { - animating.value = index === activeIndex || index === oldIndex + const _isCardType = unref(isCardType) + const carouselItemLength = carouselContext.items.value.length ?? Number.NaN + + const isActive = index === activeIndex + if (!_isCardType && !isUndefined(oldIndex)) { + animating.value = isActive || index === oldIndex } - if (index !== activeIndex && length > 2 && carouselContext?.loop) { - index = processIndex(index, activeIndex, length) + + if (!isActive && carouselItemLength > 2 && carouselContext.loop) { + index = processIndex(index, activeIndex, carouselItemLength) } - if (parentType === 'card') { - if (parentDirection.value === 'vertical') { - debugWarn('Carousel', 'vertical direction is not supported in card mode') + + const _isVertical = unref(isVertical) + active.value = isActive + + if (_isCardType) { + if (_isVertical) { + debugWarn('Carousel', 'vertical direction is not supported for card mode') } inStage.value = Math.round(Math.abs(index - activeIndex)) <= 1 - active.value = index === activeIndex translate.value = calcCardTranslate(index, activeIndex) - scale.value = active.value ? 1 : CARD_SCALE + scale.value = unref(active) ? 1 : CARD_SCALE } else { - active.value = index === activeIndex - const isVertical = parentDirection.value === 'vertical' - translate.value = calcTranslate(index, activeIndex, isVertical) + translate.value = calcTranslate(index, activeIndex, _isVertical) } + ready.value = true } function handleItemClick() { - if (carouselContext && carouselContext?.type === 'card') { - const index = carouselContext?.items.value - .map((d) => d.uid) - .indexOf(instance?.uid) - carouselContext?.setActiveItem(index) + if (carouselContext && unref(isCardType)) { + const index = carouselContext.items.value.findIndex( + ({ uid }) => uid === instance.uid + ) + carouselContext.setActiveItem(index) } } // lifecycle onMounted(() => { - if (carouselContext?.addItem) { - carouselContext?.addItem({ - uid: instance?.uid, - ...props, + carouselContext.addItem({ + props, + states: reactive({ hover, translate, scale, @@ -156,14 +182,13 @@ onMounted(() => { ready, inStage, animating, - translateItem, - }) - } + }), + uid: instance.uid, + translateItem, + }) }) onUnmounted(() => { - if (carouselContext?.removeItem) { - carouselContext?.removeItem(instance?.uid) - } + carouselContext.removeItem(instance.uid) }) diff --git a/packages/components/carousel/src/carousel.vue b/packages/components/carousel/src/carousel.vue index a51833d9d3..f57afbff67 100644 --- a/packages/components/carousel/src/carousel.vue +++ b/packages/components/carousel/src/carousel.vue @@ -54,7 +54,7 @@ @click.stop="handleIndicatorClick(index)" > @@ -70,10 +70,12 @@ import { onBeforeUnmount, watch, nextTick, + shallowRef, + unref, } from 'vue' import { throttle } from 'lodash-unified' import { useResizeObserver } from '@vueuse/core' -import { debugWarn } from '@element-plus/utils' +import { debugWarn, isString } from '@element-plus/utils' import { ElIcon } from '@element-plus/components/icon' import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue' import { useNamespace } from '@element-plus/hooks' @@ -84,29 +86,32 @@ import type { CarouselItemContext } from '@element-plus/tokens' defineOptions({ name: 'ElCarousel', }) + const props = defineProps(carouselProps) const emit = defineEmits(carouselEmits) const ns = useNamespace('carousel') +const COMPONENT_NAME = 'ElCarousel' +const THROTTLE_TIME = 300 // refs const activeIndex = ref(-1) const timer = ref | null>(null) const hover = ref(false) const root = ref() -const items = ref([]) +const items = ref>([]) // computed const arrowDisplay = computed( - () => props.arrow !== 'never' && props.direction !== 'vertical' + () => props.arrow !== 'never' && !unref(isVertical) ) const hasLabel = computed(() => { - return items.value.some((item) => item.label.toString().length > 0) + return items.value.some((item) => item.props.label.toString().length > 0) }) const carouselClasses = computed(() => { const classes = [ns.b(), ns.m(props.direction)] - if (props.type === 'card') { + if (unref(isCardType)) { classes.push(ns.m('card')) } return classes @@ -117,24 +122,27 @@ const indicatorsClasses = computed(() => { if (hasLabel.value) { classes.push(ns.em('indicators', 'labels')) } - if (props.indicatorPosition === 'outside' || props.type === 'card') { + if (props.indicatorPosition === 'outside' || unref(isCardType)) { classes.push(ns.em('indicators', 'outside')) } return classes }) +const isCardType = computed(() => props.type === 'card') +const isVertical = computed(() => props.direction === 'vertical') + // methods const throttledArrowClick = throttle( - (index) => { + (index: number) => { setActiveItem(index) }, - 300, + THROTTLE_TIME, { trailing: true } ) -const throttledIndicatorHover = throttle((index) => { +const throttledIndicatorHover = throttle((index: number) => { handleIndicatorHover(index) -}, 300) +}, THROTTLE_TIME) function pauseTimer() { if (timer.value) { @@ -156,24 +164,26 @@ const playSlides = () => { } } -function setActiveItem(index) { - if (typeof index === 'string') { - const filteredItems = items.value.filter((item) => item.name === index) +function setActiveItem(index: number | string) { + if (isString(index)) { + const filteredItems = items.value.filter( + (item) => item.props.name === index + ) if (filteredItems.length > 0) { index = items.value.indexOf(filteredItems[0]) } } index = Number(index) if (Number.isNaN(index) || index !== Math.floor(index)) { - debugWarn('Carousel', 'index must be an integer.') + debugWarn(COMPONENT_NAME, 'index must be integer.') return } - const length = items.value.length + const itemCount = items.value.length const oldIndex = activeIndex.value if (index < 0) { - activeIndex.value = props.loop ? length - 1 : 0 - } else if (index >= length) { - activeIndex.value = props.loop ? 0 : length - 1 + activeIndex.value = props.loop ? itemCount - 1 : 0 + } else if (index >= itemCount) { + activeIndex.value = props.loop ? 0 : itemCount - 1 } else { activeIndex.value = index } @@ -182,17 +192,17 @@ function setActiveItem(index) { } } -function resetItemPosition(oldIndex) { +function resetItemPosition(oldIndex?: number) { items.value.forEach((item, index) => { item.translateItem(index, activeIndex.value, oldIndex) }) } -function addItem(item) { +function addItem(item: CarouselItemContext) { items.value.push(item) } -function removeItem(uid) { +function removeItem(uid?: number) { const index = items.value.findIndex((item) => item.uid === uid) if (index !== -1) { items.value.splice(index, 1) @@ -200,17 +210,21 @@ function removeItem(uid) { } } -function itemInStage(item, index) { - const length = items.value.length - if ( - (index === length - 1 && item.inStage && items.value[0].active) || - (item.inStage && items.value[index + 1] && items.value[index + 1].active) - ) { +function itemInStage(item: CarouselItemContext, index: number) { + const _items = unref(items) + const itemCount = _items.length + if (itemCount === 0 || !item.states.inStage) return false + const nextItemIndex = index + 1 + const prevItemIndex = index - 1 + const lastItemIndex = itemCount - 1 + const isLastItemActive = _items[lastItemIndex].states.active + const isFirstItemActive = _items[0].states.active + const isNextItemActive = _items[nextItemIndex]?.states?.active + const isPrevItemActive = _items[prevItemIndex]?.states?.active + + if ((index === lastItemIndex && isFirstItemActive) || isNextItemActive) { return 'left' - } else if ( - (index === 0 && item.inStage && items.value[length - 1].active) || - (item.inStage && items.value[index - 1] && items.value[index - 1].active) - ) { + } else if ((index === 0 && isLastItemActive) || isPrevItemActive) { return 'right' } return false @@ -228,27 +242,27 @@ function handleMouseLeave() { startTimer() } -function handleButtonEnter(arrow) { - if (props.direction === 'vertical') return +function handleButtonEnter(arrow: 'left' | 'right') { + if (unref(isVertical)) return items.value.forEach((item, index) => { if (arrow === itemInStage(item, index)) { - item.hover = true + item.states.hover = true } }) } function handleButtonLeave() { - if (props.direction === 'vertical') return + if (unref(isVertical)) return items.value.forEach((item) => { - item.hover = false + item.states.hover = false }) } -function handleIndicatorClick(index) { +function handleIndicatorClick(index: number) { activeIndex.value = index } -function handleIndicatorHover(index) { +function handleIndicatorHover(index: number) { if (props.trigger === 'hover' && index !== activeIndex.value) { activeIndex.value = index } @@ -274,8 +288,8 @@ watch( ) watch( () => props.autoplay, - (current) => { - current ? startTimer() : pauseTimer() + (autoplay) => { + autoplay ? startTimer() : pauseTimer() } ) watch( @@ -285,27 +299,30 @@ watch( } ) +const resizeObserver = shallowRef>() // lifecycle -onMounted(() => { - nextTick(() => { - const resizeObserver = useResizeObserver(root.value, resetItemPosition) - if (props.initialIndex < items.value.length && props.initialIndex >= 0) { - activeIndex.value = props.initialIndex - } - startTimer() +onMounted(async () => { + await nextTick() - onBeforeUnmount(() => { - if (root.value) resizeObserver.stop() - pauseTimer() - }) + resizeObserver.value = useResizeObserver(root.value, () => { + resetItemPosition() }) + if (props.initialIndex < items.value.length && props.initialIndex >= 0) { + activeIndex.value = props.initialIndex + } + startTimer() +}) + +onBeforeUnmount(() => { + pauseTimer() + if (root.value && resizeObserver.value) resizeObserver.value.stop() }) // provide provide(carouselContextKey, { root, - direction: props.direction, - type: props.type, + isCardType, + isVertical, items, loop: props.loop, addItem, diff --git a/packages/tokens/carousel.ts b/packages/tokens/carousel.ts index c711c33bc4..a53bc8e81d 100644 --- a/packages/tokens/carousel.ts +++ b/packages/tokens/carousel.ts @@ -1,30 +1,29 @@ -import type { InjectionKey, Ref, ToRefs, UnwrapRef } from 'vue' +import type { InjectionKey, Ref } from 'vue' import type { CarouselItemProps } from '@element-plus/components/carousel' -export type CarouselItemContext = CarouselItemProps & - ToRefs<{ - hover: boolean - translate: number - scale: number - active: boolean - ready: boolean - inStage: boolean - animating: boolean - }> & { - uid: number | undefined - translateItem: ( - index: number, - activeIndex: number, - oldIndex: number - ) => void - } +export type CarouselItemStates = { + hover: boolean + translate: number + scale: number + active: boolean + ready: boolean + inStage: boolean + animating: boolean +} + +export type CarouselItemContext = { + props: CarouselItemProps + states: CarouselItemStates + uid: number | undefined + translateItem: (index: number, activeIndex: number, oldIndex?: number) => void +} export type CarouselContext = { root: Ref - direction: string - type: string - items: Ref> + items: Ref + isCardType: Ref + isVertical: Ref loop: boolean addItem: (item: CarouselItemContext) => void removeItem: (uid: number | undefined) => void