From 29ae7a24b502b480719f0330b4b5693f96aaeec1 Mon Sep 17 00:00:00 2001
From: "X.Q. Chen" <31237954+brenner8023@users.noreply.github.com>
Date: Tue, 21 Dec 2021 23:32:39 +0800
Subject: [PATCH] feat(comp:carousel): add carousel component
---
.../__snapshots__/carousel.spec.ts.snap | 25 +++
.../carousel/__tests__/carousel.spec.ts | 133 ++++++++++++++++
packages/components/carousel/demo/Arrow.md | 9 ++
packages/components/carousel/demo/Arrow.vue | 29 ++++
packages/components/carousel/demo/Autoplay.md | 9 ++
.../components/carousel/demo/Autoplay.vue | 19 +++
packages/components/carousel/demo/Basic.md | 10 ++
packages/components/carousel/demo/Basic.vue | 19 +++
packages/components/carousel/demo/Dot.md | 9 ++
packages/components/carousel/demo/Dot.vue | 34 ++++
.../components/carousel/demo/DotPlacement.md | 9 ++
.../components/carousel/demo/DotPlacement.vue | 32 ++++
packages/components/carousel/demo/Trigger.md | 9 ++
packages/components/carousel/demo/Trigger.vue | 19 +++
.../components/carousel/docs/Design.zh.md | 12 ++
packages/components/carousel/docs/Index.zh.md | 37 +++++
packages/components/carousel/index.ts | 16 ++
packages/components/carousel/src/Carousel.tsx | 148 ++++++++++++++++++
.../carousel/src/composables/useAutoplay.ts | 31 ++++
.../carousel/src/composables/useWalk.ts | 83 ++++++++++
packages/components/carousel/src/types.ts | 39 +++++
packages/components/carousel/style/index.less | 94 +++++++++++
packages/components/carousel/style/mixin.less | 11 ++
.../carousel/style/themes/default.less | 14 ++
.../carousel/style/themes/default.ts | 5 +
.../components/config/src/defaultConfig.ts | 9 ++
packages/components/config/src/types.ts | 9 ++
packages/components/default.less | 1 +
packages/components/index.ts | 2 +
.../components/style/variable/prefix.less | 1 +
30 files changed, 877 insertions(+)
create mode 100644 packages/components/carousel/__tests__/__snapshots__/carousel.spec.ts.snap
create mode 100644 packages/components/carousel/__tests__/carousel.spec.ts
create mode 100644 packages/components/carousel/demo/Arrow.md
create mode 100644 packages/components/carousel/demo/Arrow.vue
create mode 100644 packages/components/carousel/demo/Autoplay.md
create mode 100644 packages/components/carousel/demo/Autoplay.vue
create mode 100644 packages/components/carousel/demo/Basic.md
create mode 100644 packages/components/carousel/demo/Basic.vue
create mode 100644 packages/components/carousel/demo/Dot.md
create mode 100644 packages/components/carousel/demo/Dot.vue
create mode 100644 packages/components/carousel/demo/DotPlacement.md
create mode 100644 packages/components/carousel/demo/DotPlacement.vue
create mode 100644 packages/components/carousel/demo/Trigger.md
create mode 100644 packages/components/carousel/demo/Trigger.vue
create mode 100644 packages/components/carousel/docs/Design.zh.md
create mode 100644 packages/components/carousel/docs/Index.zh.md
create mode 100644 packages/components/carousel/index.ts
create mode 100644 packages/components/carousel/src/Carousel.tsx
create mode 100644 packages/components/carousel/src/composables/useAutoplay.ts
create mode 100644 packages/components/carousel/src/composables/useWalk.ts
create mode 100644 packages/components/carousel/src/types.ts
create mode 100644 packages/components/carousel/style/index.less
create mode 100644 packages/components/carousel/style/mixin.less
create mode 100644 packages/components/carousel/style/themes/default.less
create mode 100644 packages/components/carousel/style/themes/default.ts
diff --git a/packages/components/carousel/__tests__/__snapshots__/carousel.spec.ts.snap b/packages/components/carousel/__tests__/__snapshots__/carousel.spec.ts.snap
new file mode 100644
index 000000000..d9aac2044
--- /dev/null
+++ b/packages/components/carousel/__tests__/__snapshots__/carousel.spec.ts.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Carousel render work 1`] = `
+"
"
+`;
diff --git a/packages/components/carousel/__tests__/carousel.spec.ts b/packages/components/carousel/__tests__/carousel.spec.ts
new file mode 100644
index 000000000..ea0bcc3ea
--- /dev/null
+++ b/packages/components/carousel/__tests__/carousel.spec.ts
@@ -0,0 +1,133 @@
+import { MountingOptions, mount } from '@vue/test-utils'
+import { h } from 'vue'
+
+import { renderWork } from '@tests'
+
+import Carousel from '../src/Carousel'
+import { CarouselProps } from '../src/types'
+
+describe('Carousel', () => {
+ const sleep = (ms = 1000) => new Promise(resolve => setTimeout(() => resolve(), ms))
+ const CarouselMount = (options?: MountingOptions>) =>
+ mount(Carousel, {
+ slots: {
+ default: () => [h('div', 'card1'), h('div', 'card2'), h('div', 'card3')],
+ },
+ ...(options as MountingOptions),
+ })
+
+ renderWork(Carousel, {
+ props: {
+ autoplayTime: 100,
+ },
+ slots: {
+ default: () => [h('div', 'card1'), h('div', 'card2')],
+ },
+ })
+
+ test('prop showArrow work', async () => {
+ const onChange = jest.fn()
+ const wrapper = CarouselMount({ props: { showArrow: true, onChange } })
+
+ expect(wrapper.findAll('.ix-carousel-arrow').length).toBe(2)
+
+ await wrapper.find('.ix-carousel-arrow-next').trigger('click')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(onChange).toHaveBeenCalledTimes(1)
+
+ await wrapper.find('.ix-carousel-arrow-prev').trigger('click')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(onChange).toHaveBeenCalledTimes(2)
+
+ await wrapper.setProps({ showArrow: false })
+ expect(wrapper.find('.ix-carousel-arrow').exists()).toBeFalsy()
+ })
+
+ test('prop dotPlacement work', async () => {
+ const wrapper = CarouselMount({ props: { dotPlacement: 'none' } })
+
+ expect(wrapper.find('.ix-carousel-dot').exists()).toBeFalsy()
+
+ const placements = ['bottom', 'top', 'start', 'end']
+ for (const placement of placements) {
+ await wrapper.setProps({ dotPlacement: placement })
+ expect(wrapper.find('.ix-carousel-dot').classes()).toContain(`ix-carousel-dot-${placement}`)
+
+ if (['bottom', 'top'].includes(placement)) {
+ expect(wrapper.find('.ix-carousel').classes()).toContain('ix-carousel-horizontal')
+ } else {
+ expect(wrapper.find('.ix-carousel').classes()).toContain('ix-carousel-vertical')
+ }
+ }
+ })
+
+ test('prop autoplayTime work', async () => {
+ const wrapper = CarouselMount({ props: { autoplayTime: 100 } })
+
+ expect(wrapper.findAll('.ix-carousel-dot-item')[0].classes()).toContain('ix-carousel-dot-item-active')
+
+ await sleep(100)
+ expect(wrapper.findAll('.ix-carousel-dot-item')[0].classes()).not.toContain('ix-carousel-dot-item-active')
+ expect(wrapper.findAll('.ix-carousel-dot-item')[1].classes()).toContain('ix-carousel-dot-item-active')
+
+ await wrapper.setProps({ autoplayTime: 0 })
+ await sleep(100)
+ expect(wrapper.findAll('.ix-carousel-dot-item')[1].classes()).toContain('ix-carousel-dot-item-active')
+ })
+
+ test('prop trigger work', async () => {
+ const onChange = jest.fn()
+ const wrapper = CarouselMount({ props: { onChange } })
+
+ await wrapper.findAll('.ix-carousel-dot-item')[2].trigger('click')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(wrapper.findAll('.ix-carousel-dot-item')[2].classes()).toContain('ix-carousel-dot-item-active')
+ expect(onChange).toHaveBeenCalledTimes(1)
+
+ await wrapper.setProps({
+ trigger: 'hover',
+ })
+ await wrapper.find('.ix-carousel-dot-item').trigger('mouseenter')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(wrapper.find('.ix-carousel-dot-item').classes()).toContain('ix-carousel-dot-item-active')
+ expect(onChange).toHaveBeenCalledTimes(2)
+
+ await wrapper.findAll('.ix-carousel-dot-item')[1].trigger('mouseenter')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(wrapper.findAll('.ix-carousel-dot-item')[1].classes()).toContain('ix-carousel-dot-item-active')
+ expect(onChange).toHaveBeenCalledTimes(3)
+ })
+
+ test('prop onChange work', async () => {
+ const onChange = jest.fn()
+ const wrapper = CarouselMount({
+ props: { onChange, showArrow: true },
+ slots: {
+ default: () => [h('div', 'card1')],
+ },
+ })
+
+ await wrapper.find('.ix-carousel-arrow-prev').trigger('click')
+ expect(onChange).toHaveBeenCalledTimes(0)
+
+ await wrapper.find('.ix-carousel-arrow-next').trigger('click')
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ test('slot dot work', async () => {
+ const onChange = jest.fn()
+ const wrapper = CarouselMount({
+ props: { onChange },
+ slots: {
+ default: () => [h('div', 'card1'), h('div', 'card2')],
+ dot: () => h('div', { class: 'custom-dot' }),
+ },
+ })
+
+ expect(wrapper.findAll('.custom-dot').length === 2).toBeTruthy()
+
+ await wrapper.findAll('.custom-dot')[1].trigger('click')
+ await wrapper.find('.ix-carousel-slides').trigger('transitionend')
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/components/carousel/demo/Arrow.md b/packages/components/carousel/demo/Arrow.md
new file mode 100644
index 000000000..a06e66a7a
--- /dev/null
+++ b/packages/components/carousel/demo/Arrow.md
@@ -0,0 +1,9 @@
+---
+title:
+ zh: 箭头
+order: 3
+---
+
+## zh
+
+支持自定义箭头展示
diff --git a/packages/components/carousel/demo/Arrow.vue b/packages/components/carousel/demo/Arrow.vue
new file mode 100644
index 000000000..58fe22818
--- /dev/null
+++ b/packages/components/carousel/demo/Arrow.vue
@@ -0,0 +1,29 @@
+
+
+ 远看泰山黑糊糊
+ 上头细来下头粗
+ 如把泰山倒过来
+ 下头细来上头粗
+
+
+ 遥远的泰山,展现出阴暗的身影
+ 厚重的基础,支撑起浅薄的高层
+ 假如某一天,有人将那乾坤颠倒
+ 陈旧的传统,必将遭逢地裂山崩
+
+
+
+
+
+
+
+
diff --git a/packages/components/carousel/demo/Autoplay.md b/packages/components/carousel/demo/Autoplay.md
new file mode 100644
index 000000000..8f8ecafa6
--- /dev/null
+++ b/packages/components/carousel/demo/Autoplay.md
@@ -0,0 +1,9 @@
+---
+title:
+ zh: 自动轮播
+order: 2
+---
+
+## zh
+
+定时切换下一张。
diff --git a/packages/components/carousel/demo/Autoplay.vue b/packages/components/carousel/demo/Autoplay.vue
new file mode 100644
index 000000000..726c02eed
--- /dev/null
+++ b/packages/components/carousel/demo/Autoplay.vue
@@ -0,0 +1,19 @@
+
+
+ 遥远的泰山,展现出阴暗的身影
+ 厚重的基础,支撑起浅薄的高层
+ 假如某一天,有人将那乾坤颠倒
+ 陈旧的传统,必将遭逢地裂山崩
+
+
+
+
diff --git a/packages/components/carousel/demo/Basic.md b/packages/components/carousel/demo/Basic.md
new file mode 100644
index 000000000..971592cbc
--- /dev/null
+++ b/packages/components/carousel/demo/Basic.md
@@ -0,0 +1,10 @@
+---
+title:
+ zh: 基本使用
+ en: Basic usage
+order: 0
+---
+
+## zh
+
+最简单的用法。
diff --git a/packages/components/carousel/demo/Basic.vue b/packages/components/carousel/demo/Basic.vue
new file mode 100644
index 000000000..de19c9c9c
--- /dev/null
+++ b/packages/components/carousel/demo/Basic.vue
@@ -0,0 +1,19 @@
+
+
+ 远看泰山黑糊糊
+ 上头细来下头粗
+ 如把泰山倒过来
+ 下头细来上头粗
+
+
+
+
diff --git a/packages/components/carousel/demo/Dot.md b/packages/components/carousel/demo/Dot.md
new file mode 100644
index 000000000..a0359df44
--- /dev/null
+++ b/packages/components/carousel/demo/Dot.md
@@ -0,0 +1,9 @@
+---
+title:
+ zh: 面板指示点
+order: 5
+---
+
+## zh
+
+支持自定义面板指示点
diff --git a/packages/components/carousel/demo/Dot.vue b/packages/components/carousel/demo/Dot.vue
new file mode 100644
index 000000000..008d7fe90
--- /dev/null
+++ b/packages/components/carousel/demo/Dot.vue
@@ -0,0 +1,34 @@
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/carousel/demo/DotPlacement.md b/packages/components/carousel/demo/DotPlacement.md
new file mode 100644
index 000000000..12831a870
--- /dev/null
+++ b/packages/components/carousel/demo/DotPlacement.md
@@ -0,0 +1,9 @@
+---
+title:
+ zh: 面板指示点
+order: 1
+---
+
+## zh
+
+面板指示点的位置有4个方向。
diff --git a/packages/components/carousel/demo/DotPlacement.vue b/packages/components/carousel/demo/DotPlacement.vue
new file mode 100644
index 000000000..c12f2c8ef
--- /dev/null
+++ b/packages/components/carousel/demo/DotPlacement.vue
@@ -0,0 +1,32 @@
+
+
+ top
+ bottom
+ start
+ end
+ none
+
+
+ 1
+ 2
+ 3
+ 4
+
+
+
+
+
+
diff --git a/packages/components/carousel/demo/Trigger.md b/packages/components/carousel/demo/Trigger.md
new file mode 100644
index 000000000..49edf50ee
--- /dev/null
+++ b/packages/components/carousel/demo/Trigger.md
@@ -0,0 +1,9 @@
+---
+title:
+ zh: 指示点触发方式
+order: 4
+---
+
+## zh
+
+支持设置鼠标经过指示点时触发切换
diff --git a/packages/components/carousel/demo/Trigger.vue b/packages/components/carousel/demo/Trigger.vue
new file mode 100644
index 000000000..bdcdfcc77
--- /dev/null
+++ b/packages/components/carousel/demo/Trigger.vue
@@ -0,0 +1,19 @@
+
+
+ 遥远的泰山,展现出阴暗的身影
+ 厚重的基础,支撑起浅薄的高层
+ 假如某一天,有人将那乾坤颠倒
+ 陈旧的传统,必将遭逢地裂山崩
+
+
+
+
diff --git a/packages/components/carousel/docs/Design.zh.md b/packages/components/carousel/docs/Design.zh.md
new file mode 100644
index 000000000..eb5cc673f
--- /dev/null
+++ b/packages/components/carousel/docs/Design.zh.md
@@ -0,0 +1,12 @@
+旋转木马,通常用来循环播放同一类型的交互内容。
+
+### 什么情况下使用?
+
+- 当有一组平级的内容。
+- 当内容空间不足时,可以用走马灯的形式进行收纳,进行轮播展现。
+- 常用于一组图片或卡片轮播。
+
+### 什么情况下不使用?
+
+- 展示一组图片或卡片,空间足够的情况下不需要用轮播图。
+- 图片或卡片的内容相互有逻辑关系,不适合用轮播图。
diff --git a/packages/components/carousel/docs/Index.zh.md b/packages/components/carousel/docs/Index.zh.md
new file mode 100644
index 000000000..579942bbf
--- /dev/null
+++ b/packages/components/carousel/docs/Index.zh.md
@@ -0,0 +1,37 @@
+---
+category: components
+type: 数据展示
+title: Carousel
+subtitle: 轮播图
+order: 0
+---
+
+## API
+
+### IxCarousel
+
+#### CarouselProps
+
+| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
+| --- | --- | --- | --- | --- | --- |
+| `autoplayTime` | 控制自动轮播的时间间隔 | `number` | `0` | ✅ | 值为`0`时不开启自动轮播 |
+| `dotPlacement` | 面板指示点的位置 | `'top' \| 'start' \| 'bottom' \| 'end' \| 'none'` | `'bottom'` | ✅ | 为`'none'`时不显示面板指示点 |
+| `showArrow` | 是否显示`prev`、`next`按钮 | `boolean` | `false` | ✅ | - |
+| `trigger` | 面板指示点的触发方式 | `'click' \| 'hover'` | `'click'` | ✅ | - |
+| `onChange` | 面板切换时会触发的回调函数 | `(prevIndex: number, nextIndex: number) => void` | - | - | - |
+
+#### CarouselSlots
+
+| 名称 | 说明 | 参数类型 | 备注 |
+| --- | --- | --- | --- |
+| `default` | 面板的内容 | - | - |
+| `dot` | 面板指示点 | `{ index: number, isActive: boolean }` | `isActive`表示当前索引是否激活 |
+| `arrow` | 自定义切换按钮 | `type: 'prev' \| 'next'` | - |
+
+#### CarseouselMethods
+
+| 名称 | 说明 | 参数类型 | 备注 |
+| --- | --- | --- | --- |
+| `goTo(slideIndex: number)` | 切换到指定面板 | `(slideIndex: number) => void` | - |
+| `next()` | 切换到下一面板 | - | - |
+| `prev()` | 切换到上一面板 | - | - |
diff --git a/packages/components/carousel/index.ts b/packages/components/carousel/index.ts
new file mode 100644
index 000000000..f8c0c7687
--- /dev/null
+++ b/packages/components/carousel/index.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { CarouselComponent } from './src/types'
+
+import Carousel from './src/Carousel'
+
+const IxCarousel = Carousel as unknown as CarouselComponent
+
+export { IxCarousel }
+
+export type { CarouselInstance, CarouselPublicProps as CarouselProps, DotPlacement, DotTrigger } from './src/types'
diff --git a/packages/components/carousel/src/Carousel.tsx b/packages/components/carousel/src/Carousel.tsx
new file mode 100644
index 000000000..b52ecef2b
--- /dev/null
+++ b/packages/components/carousel/src/Carousel.tsx
@@ -0,0 +1,148 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import { cloneVNode, computed, defineComponent, normalizeClass, ref } from 'vue'
+
+import { flattenNode } from '@idux/cdk/utils'
+import { useGlobalConfig } from '@idux/components/config'
+import { IxIcon } from '@idux/components/icon'
+
+import { useAutoplay } from './composables/useAutoplay'
+import { useWalk } from './composables/useWalk'
+import { carouselProps } from './types'
+
+export default defineComponent({
+ name: 'IxCarousel',
+ props: carouselProps,
+ setup(props, { slots, expose }) {
+ const carouselRef = ref(null)
+ const common = useGlobalConfig('common')
+ const mergedPrefixCls = computed(() => `${common.prefixCls}-carousel`)
+ const config = useGlobalConfig('carousel')
+ const autoplayTime = computed(() => props.autoplayTime ?? config.autoplayTime)
+ const dotPlacement = computed(() => props.dotPlacement ?? config.dotPlacement)
+ const showArrow = computed(() => props.showArrow ?? config.showArrow)
+ const trigger = computed(() => props.trigger ?? config.trigger)
+ const children = computed(() => flattenNode(slots.default?.()))
+ const length = computed(() => children.value.length)
+ const vertical = computed(() => dotPlacement.value === 'start' || dotPlacement.value === 'end')
+ const itemClass = computed(() => `${mergedPrefixCls.value}-slide-item`)
+ const size = computed(() => {
+ const carousel = carouselRef.value
+ return {
+ width: carousel?.offsetWidth ?? 0,
+ height: carousel?.querySelector(`.${itemClass.value}`)?.offsetHeight ?? 0,
+ }
+ })
+ const total = computed(() => length.value + 2)
+
+ const slidesStyle = computed(() => {
+ const index = activeIndex.value % total.value
+ const offset = vertical.value
+ ? { top: `-${size.value.height * index}px` }
+ : { left: `-${size.value.width * index}px` }
+
+ return {
+ width: `${total.value * size.value.width}px`,
+ ...offset,
+ }
+ })
+ const slideItemStyle = computed(() => {
+ return {
+ width: `${size.value.width}px`,
+ height: '100%',
+ }
+ })
+
+ const classes = computed(() => {
+ const prefixCls = mergedPrefixCls.value
+ return normalizeClass({
+ [prefixCls]: true,
+ [`${prefixCls}-vertical`]: vertical.value,
+ [`${prefixCls}-horizontal`]: !vertical.value,
+ })
+ })
+ const slidesClass = computed(() => {
+ const prefixCls = mergedPrefixCls.value
+ return normalizeClass({
+ [`${prefixCls}-slides`]: true,
+ })
+ })
+ const dotClass = computed(() => {
+ const prefixCls = mergedPrefixCls.value
+ return normalizeClass({
+ [`${prefixCls}-dot`]: true,
+ [`${prefixCls}-dot-${dotPlacement.value}`]: true,
+ })
+ })
+
+ const { goTo, next, prev, onTransitionend, activeIndex } = useWalk(length, props)
+ expose({ goTo, next, prev })
+
+ useAutoplay(autoplayTime, next)
+
+ const onClick = (slideIndex: number) => {
+ if (trigger.value === 'click') {
+ goTo(slideIndex)
+ }
+ }
+ const onMouseenter = (slideIndex: number) => {
+ if (trigger.value === 'hover') {
+ goTo(slideIndex)
+ }
+ }
+
+ return () => {
+ const prefixCls = mergedPrefixCls.value
+ const startVNode = cloneVNode(children.value[length.value - 1])
+ const endVNode = cloneVNode(children.value[0])
+ const slides = [startVNode, ...children.value, endVNode].map(slideItem => (
+
+ {slideItem}
+
+ ))
+ const prevArrow = slots.arrow?.({ type: 'prev' }) ??
+ const nextArrow = slots.arrow?.({ type: 'next' }) ??
+ const dots = Array.from({ length: length.value }).map((_, index: number) => {
+ const isActive = index + 1 === activeIndex.value
+ const itemClass = {
+ [`${prefixCls}-dot-item`]: true,
+ [`${prefixCls}-dot-item-active`]: isActive,
+ }
+ const children = slots.dot ? (
+ slots.dot({ index, isActive })
+ ) : (
+
+ )
+ return (
+ onClick(index)} onMouseenter={() => onMouseenter(index)}>
+ {children}
+
+ )
+ })
+
+ return (
+
+
+ {slides}
+
+ {showArrow.value && (
+ <>
+
+ {prevArrow}
+
+
+ {nextArrow}
+
+ >
+ )}
+ {dotPlacement.value !== 'none' &&
}
+
+ )
+ }
+ },
+})
diff --git a/packages/components/carousel/src/composables/useAutoplay.ts b/packages/components/carousel/src/composables/useAutoplay.ts
new file mode 100644
index 000000000..7a27f9b6d
--- /dev/null
+++ b/packages/components/carousel/src/composables/useAutoplay.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import { ComputedRef, onBeforeUnmount, watch } from 'vue'
+
+export const useAutoplay = (autoplayTime: ComputedRef, next: () => void): void => {
+ let timer: number | null = null
+
+ watch(
+ autoplayTime,
+ (newVal: number) => {
+ timer && window.clearInterval(timer)
+ if (newVal) {
+ timer = window.setInterval(() => {
+ next()
+ }, newVal)
+ }
+ },
+ { immediate: true },
+ )
+
+ onBeforeUnmount(() => {
+ if (timer !== null) {
+ window.clearInterval(timer)
+ }
+ })
+}
diff --git a/packages/components/carousel/src/composables/useWalk.ts b/packages/components/carousel/src/composables/useWalk.ts
new file mode 100644
index 000000000..721bc12f9
--- /dev/null
+++ b/packages/components/carousel/src/composables/useWalk.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { CarouselProps } from '../types'
+
+import { ComputedRef, nextTick, watch } from 'vue'
+
+import { callEmit, useState } from '@idux/cdk/utils'
+
+interface WalkContext {
+ goTo(slideIndex: number): void
+ next(): void
+ prev(): void
+ onTransitionend(e: TransitionEvent): void
+ activeIndex: ComputedRef
+}
+
+export const useWalk = (length: ComputedRef, props: CarouselProps): WalkContext => {
+ const [activeIndex, setActiveIndex] = useState(1)
+ let running = false
+
+ watch(activeIndex, (newVal: number, oldVal: number) => {
+ if (newVal >= 1 && newVal <= length.value) {
+ callEmit(props.onChange, oldVal, newVal)
+ }
+ })
+
+ const goTo = (slideIndex: number) => {
+ running = true
+ if (activeIndex.value === 1 && slideIndex === length.value - 1) {
+ setActiveIndex(0)
+ } else if (activeIndex.value === length.value && slideIndex === 0) {
+ setActiveIndex(length.value + 1)
+ } else {
+ setActiveIndex(slideIndex + 1)
+ }
+ }
+
+ const next = () => {
+ if (length.value <= 1 || running) {
+ return
+ }
+ running = true
+ setActiveIndex(activeIndex.value + 1)
+ }
+ const prev = () => {
+ if (length.value <= 1 || running) {
+ return
+ }
+ running = true
+ setActiveIndex(activeIndex.value - 1)
+ }
+
+ const onTransitionend = (e: TransitionEvent) => {
+ running = false
+ if (activeIndex.value > 0 && activeIndex.value <= length.value) {
+ return
+ }
+ if (activeIndex.value === 0) {
+ setActiveIndex(length.value)
+ } else if (activeIndex.value === length.value + 1) {
+ setActiveIndex(1)
+ }
+ nextTick(() => {
+ const target = e.target as HTMLElement
+ target.style.transition = 'none'
+ void target.clientWidth
+ target.style.transition = ''
+ })
+ }
+
+ return {
+ goTo,
+ next,
+ prev,
+ onTransitionend,
+ activeIndex,
+ }
+}
diff --git a/packages/components/carousel/src/types.ts b/packages/components/carousel/src/types.ts
new file mode 100644
index 000000000..2b46a2b54
--- /dev/null
+++ b/packages/components/carousel/src/types.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { IxInnerPropTypes, IxPublicPropTypes } from '@idux/cdk/utils'
+import type { DefineComponent, HTMLAttributes } from 'vue'
+
+import { IxPropTypes } from '@idux/cdk/utils'
+
+export type DotPlacement = 'top' | 'start' | 'bottom' | 'end' | 'none'
+const dotPlacement: DotPlacement[] = ['top', 'start', 'bottom', 'end', 'none']
+
+export type DotTrigger = 'click' | 'hover'
+const dotTrigger: DotTrigger[] = ['click', 'hover']
+
+export const carouselProps = {
+ autoplayTime: IxPropTypes.number,
+ dotPlacement: IxPropTypes.oneOf(dotPlacement),
+ showArrow: IxPropTypes.bool,
+ trigger: IxPropTypes.oneOf(dotTrigger),
+ onChange: IxPropTypes.emit<(prevIndex: number, nextIndex: number) => void>(),
+}
+
+export interface CarouselBindings {
+ next: () => void
+ prev: () => void
+ goTo: (slideIndex: number) => void
+}
+
+export type CarouselProps = IxInnerPropTypes
+export type CarouselPublicProps = IxPublicPropTypes
+export type CarouselComponent = DefineComponent<
+ Omit & CarouselPublicProps,
+ CarouselBindings
+>
+export type CarouselInstance = InstanceType>
diff --git a/packages/components/carousel/style/index.less b/packages/components/carousel/style/index.less
new file mode 100644
index 000000000..52783a158
--- /dev/null
+++ b/packages/components/carousel/style/index.less
@@ -0,0 +1,94 @@
+@import '../../style/mixins/reset.less';
+@import './mixin.less';
+
+.@{carousel-prefix} {
+ .reset-component();
+
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+
+ &-slides {
+ transition: all 0.3s;
+ position: relative;
+ }
+
+ &-horizontal &-slide-item {
+ float: left;
+ }
+
+ &-arrow {
+ font-size: @carousel-arrow-size;
+ color: @carousel-arrow-color;
+ opacity: @carousel-icon-opacity;
+ cursor: pointer;
+
+ .carousel-vertical-center();
+
+ &:hover {
+ opacity: @carousel-icon-active-opacity;
+ }
+
+ &-prev {
+ left: @carousel-arrow-spacing;
+ }
+
+ &-next {
+ right: @carousel-arrow-spacing;
+ }
+ }
+
+ &-horizontal &-dot {
+ .carousel-horizontal-center();
+ }
+
+ &-vertical &-dot {
+ flex-direction: column;
+ .carousel-vertical-center();
+ }
+
+ &-dot {
+ display: flex;
+
+ &-item {
+ opacity: @carousel-icon-opacity;
+ cursor: pointer;
+
+ &:hover,
+ &-active {
+ opacity: @carousel-icon-active-opacity;
+ }
+
+ &-default {
+ width: @carousel-dot-size;
+ height: @carousel-dot-size;
+ border-radius: 50%;
+ background-color: @carousel-dot-background-color;
+ }
+ }
+
+ .@{carousel-prefix}-vertical &-item + &-item {
+ margin-top: @carousel-dot-gap;
+ }
+
+ .@{carousel-prefix}-horizontal &-item + &-item {
+ margin-left: @carousel-dot-gap;
+ }
+
+ &-start {
+ left: @carousel-dot-spacing;
+ }
+
+ &-end {
+ right: @carousel-dot-spacing;
+ }
+
+ &-top {
+ top: @carousel-dot-spacing;
+ }
+
+ &-bottom {
+ bottom: @carousel-dot-spacing;
+ }
+ }
+}
diff --git a/packages/components/carousel/style/mixin.less b/packages/components/carousel/style/mixin.less
new file mode 100644
index 000000000..75ed122bc
--- /dev/null
+++ b/packages/components/carousel/style/mixin.less
@@ -0,0 +1,11 @@
+.carousel-vertical-center () {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.carousel-horizontal-center () {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+}
diff --git a/packages/components/carousel/style/themes/default.less b/packages/components/carousel/style/themes/default.less
new file mode 100644
index 000000000..236e4c351
--- /dev/null
+++ b/packages/components/carousel/style/themes/default.less
@@ -0,0 +1,14 @@
+@import '../../../style/themes/default.less';
+@import '../index.less';
+
+@carousel-arrow-size: @font-size-2xl;
+@carousel-arrow-color: @color-white;
+@carousel-arrow-spacing: @spacing-gutter;
+
+@carousel-dot-size: @spacing-gutter;
+@carousel-dot-background-color: @color-white;
+@carousel-dot-gap: @spacing-md;
+@carousel-dot-spacing: @spacing-lg;
+
+@carousel-icon-opacity: 0.3;
+@carousel-icon-active-opacity: 0.8;
diff --git a/packages/components/carousel/style/themes/default.ts b/packages/components/carousel/style/themes/default.ts
new file mode 100644
index 000000000..8aaddc579
--- /dev/null
+++ b/packages/components/carousel/style/themes/default.ts
@@ -0,0 +1,5 @@
+// style dependencies
+import '@idux/components/style/core/default'
+import '@idux/components/icon/style/themes/default'
+
+import './default.less'
diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts
index f4af90674..4b1300db2 100644
--- a/packages/components/config/src/defaultConfig.ts
+++ b/packages/components/config/src/defaultConfig.ts
@@ -12,6 +12,7 @@ import type {
BackTopConfig,
BadgeConfig,
CardConfig,
+ CarouselConfig,
CheckboxConfig,
CollapseConfig,
CommonConfig,
@@ -311,6 +312,13 @@ const skeleton: SkeletonConfig = {
animated: true,
}
+const carousel: CarouselConfig = {
+ autoplayTime: 0,
+ dotPlacement: 'bottom',
+ showArrow: false,
+ trigger: 'click',
+}
+
const drawer: DrawerConfig = {
closable: true,
closeOnEsc: true,
@@ -390,6 +398,7 @@ export const defaultConfig: GlobalConfig = {
badge,
card,
empty,
+ carousel,
list,
collapse,
image,
diff --git a/packages/components/config/src/types.ts b/packages/components/config/src/types.ts
index 020dd886a..4658ee65c 100644
--- a/packages/components/config/src/types.ts
+++ b/packages/components/config/src/types.ts
@@ -11,6 +11,7 @@ import type { PortalTargetType } from '@idux/cdk/portal'
import type { AlertType } from '@idux/components/alert'
import type { AvatarShape, AvatarSize } from '@idux/components/avatar'
import type { CardSize } from '@idux/components/card'
+import type { DotPlacement, DotTrigger } from '@idux/components/carousel'
import type { DatePickerType } from '@idux/components/date-picker/src/types'
import type { DividerPosition, DividerType } from '@idux/components/divider'
import type { FormLabelAlign, FormLayout, FormSize } from '@idux/components/form'
@@ -327,6 +328,13 @@ export interface SkeletonConfig {
animated: boolean
}
+export interface CarouselConfig {
+ autoplayTime: number
+ dotPlacement: DotPlacement
+ showArrow: boolean
+ trigger: DotTrigger
+}
+
export interface DrawerConfig {
closable: boolean
closeIcon: string
@@ -414,6 +422,7 @@ export interface GlobalConfig {
badge: BadgeConfig
card: CardConfig
empty: EmptyConfig
+ carousel: CarouselConfig
list: ListConfig
collapse: CollapseConfig
image: ImageConfig
diff --git a/packages/components/default.less b/packages/components/default.less
index f0aa28b2d..446caadcf 100644
--- a/packages/components/default.less
+++ b/packages/components/default.less
@@ -13,6 +13,7 @@
@import './badge/style/themes/default.less';
@import './button/style/themes/default.less';
@import './card/style/themes/default.less';
+@import './carousel/style//themes/default.less';
@import './checkbox/style/themes/default.less';
@import './collapse/style/themes/default.less';
@import './date-picker/style/themes/default.less';
diff --git a/packages/components/index.ts b/packages/components/index.ts
index a59524386..40a79b1ee 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -15,6 +15,7 @@ import { IxBackTop } from '@idux/components/back-top'
import { IxBadge } from '@idux/components/badge'
import { IxButton, IxButtonGroup } from '@idux/components/button'
import { IxCard, IxCardGrid } from '@idux/components/card'
+import { IxCarousel } from '@idux/components/carousel'
import { IxCheckbox, IxCheckboxGroup } from '@idux/components/checkbox'
import { IxCollapse, IxCollapsePanel } from '@idux/components/collapse'
import { IxDatePicker } from '@idux/components/date-picker'
@@ -73,6 +74,7 @@ const components = [
IxButtonGroup,
IxCard,
IxCardGrid,
+ IxCarousel,
IxCheckbox,
IxCheckboxGroup,
IxCollapse,
diff --git a/packages/components/style/variable/prefix.less b/packages/components/style/variable/prefix.less
index 7a4797306..96335c805 100644
--- a/packages/components/style/variable/prefix.less
+++ b/packages/components/style/variable/prefix.less
@@ -27,6 +27,7 @@
@card-prefix: ~'@{idux-prefix}-card';
@list-prefix: ~'@{idux-prefix}-list';
@list-item-prefix: ~'@{idux-prefix}-list-item';
+@carousel-prefix: ~'@{idux-prefix}-carousel';
@collapse-prefix: ~'@{idux-prefix}-collapse';
@collapse-panel-prefix: ~'@{idux-prefix}-collapse-panel';
@empty-prefix: ~'@{idux-prefix}-empty';