diff --git a/devui/carousel/carousel.scss b/devui/carousel/carousel.scss new file mode 100644 index 0000000000000000000000000000000000000000..855639f1dbd45e223dbc17126552eded087e2c4f --- /dev/null +++ b/devui/carousel/carousel.scss @@ -0,0 +1,112 @@ +@import '../style/theme/color'; +@import '../style/theme/shadow'; +@import '../style/core/animation'; + +@mixin fixed-arrow-button() { + position: absolute; + top: -18px; + z-index: 2; + cursor: pointer; + width: 36px; + height: 36px; + border-radius: 18px; + background: $devui-highlight-overlay; + box-shadow: $devui-shadow-length-hover $devui-light-shadow; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + background: $devui-area; + } + + svg polygon { + fill: $devui-text; + } +} + +.devui-carousel-container { + display: block; + position: relative; + + .devui-carousel-arrow { + position: absolute; + width: 100%; + top: 50%; + + .arrow-left { + @include fixed-arrow-button(); + + left: 10px; + } + + .arrow-right { + @include fixed-arrow-button(); + + right: 10px; + } + } + + .devui-carousel-item-wrapper { + position: relative; + overflow: hidden; + height: 100%; + + .devui-carousel-item-container { + display: flex; + height: 100%; + position: relative; + + .d-carousel-item { + flex: 1; + position: relative; + height: 100%; + } + } + } + + .devui-carousel-dots { + position: absolute; + display: flex; + justify-content: center; + width: 100%; + + list-style: none; + + &.bottom { + bottom: 8px; + } + + &.top { + top: 8px; + } + + .dot-item { + width: 6px; + height: 6px; + border-radius: 3px; + margin-right: 8px; + background: $devui-icon-fill; + + &:hover { + cursor: pointer; + background: $devui-list-item-hover-bg; + } + + &.active { + width: 24px; + background: $devui-list-item-active-bg; + transition: all $devui-animation-duration-slow $devui-animation-ease-in-smooth; + } + } + } +} + +.devui-carousel-container { + .devui-carousel-arrow { + .arrow-left, + .arrow-right { + transition: background-color $devui-animation-duration-slow $devui-animation-ease-in-smooth; + } + } +} diff --git a/devui/carousel/carousel.tsx b/devui/carousel/carousel.tsx index 9c1640c26cf48970292eae55dcffbfea81eca000..d5e5f08445ba8bf67d94a54a51ad53b1bdaebc7e 100644 --- a/devui/carousel/carousel.tsx +++ b/devui/carousel/carousel.tsx @@ -1,12 +1,269 @@ -import { defineComponent } from 'vue' +import { defineComponent, ref, watch, onMounted, Fragment, Comment } from 'vue'; +import { carouselProps, DotTrigger } from './types'; + +import Icon from '../icon/src/icon' + +import './carousel.scss'; export default defineComponent({ - name: 'd-carousel', - props: { + name: 'DCarousel', + props: carouselProps, + emits: ['update:activeIndex'], + setup(props, { emit }) { + const { + arrowTrigger, + autoplay, + autoplaySpeed, + dotTrigger, + activeIndex, + activeIndexChange + } = props; + const transitionSpeed = 500; + + const itemCount = ref(0); + const showArrow = ref(false); + const currentIndex = ref(0); + const wrapperRef = ref(null); + const containerRef = ref(null); + const scheduledId = ref(null); + + watch( + () => arrowTrigger, + () => { + showArrow.value = arrowTrigger === 'always'; + }, + { immediate: true } + ); + watch( + () => activeIndex, + () => { + currentIndex.value = activeIndex; + }, + { immediate: true } + ); + + // 翻页位移 + const translatePosition = (size: number) => { + if (containerRef.value) containerRef.value.style.left = `${-size * 100}%`; + }; + // 调整首尾翻页后的动画 + const adjustTransition = (targetEl: HTMLElement) => { + setTimeout(() => { + if (containerRef.value) containerRef.value.style.transition = ''; + + targetEl.style.transform = ''; + translatePosition(currentIndex.value); + }, transitionSpeed); + }; + + // 调整首尾翻动时的位置 + const adjustPosition = (targetEl: HTMLElement, firstToLast: boolean) => { + if (wrapperRef.value) { + const wrapperRect = wrapperRef.value.getBoundingClientRect(); + + targetEl.style.transform = `translateX(${ + (firstToLast ? -itemCount.value : itemCount.value) * wrapperRect.width + }px)`; + } + }; + + // 指定跳转位置 + const goto = (index: number) => { + if ( + index === currentIndex.value || + !wrapperRef.value || + !containerRef.value + ) { + return; + } + + containerRef.value.style.transition = `left ${transitionSpeed}ms ease`; + + let latestIndex = currentIndex.value; + if (index < 0 && currentIndex.value === 0) { + // 第一个卡片向前切换 + latestIndex = itemCount.value - 1; + const targetEl = containerRef.value.children[ + latestIndex + ] as HTMLElement; + adjustPosition(targetEl, true); + translatePosition(-1); + adjustTransition(targetEl); + } else if ( + index >= itemCount.value && + currentIndex.value === itemCount.value - 1 + ) { + // 最后一个卡片向后切换 + latestIndex = 0; + + const targetEl = containerRef.value.children[ + latestIndex + ] as HTMLElement; + adjustPosition(targetEl, false); + translatePosition(itemCount.value); + adjustTransition(targetEl); + } else { + latestIndex = + index < 0 + ? 0 + : ( + index > itemCount.value - 1 + ? itemCount.value - 1 + : index + ) + + translatePosition(latestIndex); + } + + currentIndex.value = latestIndex; + emit('update:activeIndex', latestIndex); + activeIndexChange?.(latestIndex) + autoScheduleTransition(); + }; + // 向前切换 + const prev = () => { + goto(currentIndex.value - 1); + }; + // 向后切换 + const next = () => { + goto(currentIndex.value + 1); + }; + + // 切换箭头监听事件,用于处理hover方式 + const arrowMouseEvent = (type: 'enter' | 'leave') => { + if (arrowTrigger !== 'hover') return; + + showArrow.value = type === 'enter'; + }; + // 指示器触发切换函数 + const switchStep = (index: number, type: DotTrigger) => { + if (type === dotTrigger) goto(index); + }; + + // 清除自动轮播任务 + const clearScheduledTransition = () => { + if (scheduledId.value) { + clearTimeout(scheduledId.value); + scheduledId.value = null; + } + }; + // 自动轮播调度任务 + const autoScheduleTransition = () => { + clearScheduledTransition(); + if (autoplay && autoplaySpeed) { + scheduledId.value = setTimeout(() => { + next(); + }, autoplaySpeed); + } + }; + const changeItemCount = (val: number) => { + itemCount.value = val; + autoScheduleTransition(); + }; + + onMounted(() => { + if (containerRef.value) { + containerRef.value.style.transition = `left ${transitionSpeed}ms ease`; + containerRef.value.style.left = '0%'; + } + + autoScheduleTransition(); + }); + + return { + wrapperRef, + containerRef, + + showArrow, + currentIndex, + itemCount, + changeItemCount, + + goto, + prev, + next, + arrowMouseEvent, + switchStep, + }; }, - setup(props, ctx) { - return () => { - return
devui-carousel
+ + render() { + const { + showArrow, + currentIndex, + itemCount, + + arrowTrigger, + height, + showDots, + dotPosition, + + prev, + next, + arrowMouseEvent, + switchStep, + changeItemCount, + + $slots, + } = this; + const slot: any[] = $slots.default?.() ?? []; + + // 在jsx中,使用map生成slot项会在外层包裹一个Fragment + let children = slot; + if (children.length === 1 && children[0].type === Fragment) { + children = (children[0].children || []).filter( + (item) => item?.type !== Comment + ); + } + if (children.length !== itemCount) { + changeItemCount(children.length); } - } -}) \ No newline at end of file + + return ( + + ); + }, +}); diff --git a/devui/carousel/demo/carousel-demo.tsx b/devui/carousel/demo/carousel-demo.tsx deleted file mode 100644 index 4bb23c7ecce97b6cfd0beee80fd56d76e5b60b38..0000000000000000000000000000000000000000 --- a/devui/carousel/demo/carousel-demo.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { defineComponent } from 'vue' - -export default defineComponent({ - name: 'd-carousel-demo', - props: { - }, - setup(props, ctx) { - return () => { - return
devui-carousel-demo
- } - } -}) \ No newline at end of file diff --git a/devui/carousel/demo/carousel.route.ts b/devui/carousel/demo/carousel.route.ts deleted file mode 100644 index d82a897450de2717de2f63b4dd30e69c6907c99b..0000000000000000000000000000000000000000 --- a/devui/carousel/demo/carousel.route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import CarouselDemoComponent from './carousel-demo' -import DevUIApiComponent from '../../shared/devui-api/devui-api' - -import ApiCn from '../doc/api-cn.md' -import ApiEn from '../doc/api-en.md' -const routes = [ - { path: '', redirectTo: 'demo' }, - { path: 'demo', component: CarouselDemoComponent}, - { path: 'api', component: DevUIApiComponent, meta: { - 'zh-cn': ApiCn, - 'en-us': ApiEn - }} -] - -export default routes diff --git a/devui/carousel/doc/api-en.md b/devui/carousel/doc/api-en.md deleted file mode 100644 index f9a539ccfa8b446296921b46e1c733a44f129a48..0000000000000000000000000000000000000000 --- a/devui/carousel/doc/api-en.md +++ /dev/null @@ -1,40 +0,0 @@ -# How To Use -Import in module: -```ts -import { CarouselModule } from 'ng-devui/carousel'; -``` -In the page: -```html - - - -``` - -# d-carousel - -## d-carousel parameter - -| Parameter | Type | Default | Description | Jump to Demo | -| :-----------: | :--------------------------: | :-----: | :---------------------------------------------- | ------------------------------------------------ | -| arrowTrigger | `'hover'\|'never'\|'always'` | 'hover' | Optional. Specifying the display mode of the switching arrow | [Indicator & Toggle Arrow](demo#trigger-usage) | -| autoplay | `boolean` | false | Optional. Indicating whether to enable automatic NVOD. | [Automatic NVOD](demo#autoplay-usage) | -| autoplaySpeed | `number` | 3000 | Optional. Automatic NVOD speed, in ms. This parameter is used together with `autoplay'. | [Automatic NVOD](demo#autoplay-usage) | -| height | `string` | '100%' | Optional. NVOD container height | [Basic usage](demo#basic-usage) | -| showDots | `boolean` | true | Optional. Indicating whether to display the panel indicator | [Automatic NVOD](demo#autoplay-usage) | -| dotPosition | `'top'\|'bottom'` |'bottom' | Optional. Indicator position on the panel | [Indicator & Toggle Arrow](demo#trigger-usage) | -| dotTrigger | `click'\|'hover'` | 'click' | Optional. The indicator triggers the NVOD mode | [Indicator & Toggle Arrow](demo#trigger-usage) | -| activeIndex | `number` | 0 | Optional. Initializes the activation card index, starting from 0. `[(activeIndex)]` bidirectional binding is supported. | [Basic usage](demo#basic-usage) | - -## d-carousel event - -| Event | Type | Description | Jump to Demo | -| :----------------: | :---------------------: | :-----------------------------------------: | ------------------------------------------------- | -| activeIndexChange | `EventEmitter` | Returns the index of the current card when the card is switched. The index starts from 0. | [Basic usage](demo#basic-usage) | - -## d-carousel method - -| Method | Description | Jump to Demo | -| :---------: | :---------------------------------- | :----------------------------- | -| prev() | Switch to the previous card | [Custom Operations](demo#custom-usage) | -| next() | Switch to the next card | [Custom Operations](demo#custom-usage) | -| goTo(index) | Switch to the card with a specified index. The index starts from 0. | [Custom Operations](demo#custom-usage) | diff --git a/devui/carousel/item.tsx b/devui/carousel/item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5c367ac12ae94cc633a8a6a6006002e512ce44d --- /dev/null +++ b/devui/carousel/item.tsx @@ -0,0 +1,17 @@ +import { defineComponent } from 'vue' + +export default defineComponent({ + name: 'DCarouselItem', + render() { + const { + $slots + } = this + const children = $slots.default?.() + + return ( + + ) + } +}) \ No newline at end of file diff --git a/devui/carousel/types.ts b/devui/carousel/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc35b2d6acc85e02e68eda00f4654b671433047c --- /dev/null +++ b/devui/carousel/types.ts @@ -0,0 +1,43 @@ +import { PropType } from 'vue'; + +export type ArrowTrigger = 'hover' | 'never' | 'always'; +export type DotTrigger = 'click' | 'hover'; +export type DotPosition = 'bottom' | 'top'; + +export const carouselProps = { + arrowTrigger: { + type: String as PropType, + default: 'hover' + }, + autoplay: { + type: Boolean, + default: false, + }, + autoplaySpeed: { + type: Number, + default: 3000 + }, + height: { + type: String, + default: '100%', + }, + showDots: { + type: Boolean, + default: true + }, + dotTrigger: { + type: String as PropType, + default: 'click', + }, + dotPosition: { + type: String as PropType, + default: 'bottom', + }, + activeIndex: { + type: Number, + default: 0 + }, + activeIndexChange: { + type: Function as unknown as () => ((index: number) => void) + }, +} as const; \ No newline at end of file diff --git a/devui/vue-devui.ts b/devui/vue-devui.ts index 5dfe708b58ccfe2bd86bd99901aef251cda1b7d2..e13a3207801a3e8b37e55f882b506f3a7e709625 100644 --- a/devui/vue-devui.ts +++ b/devui/vue-devui.ts @@ -20,6 +20,8 @@ import TextInput from './text-input/src/text-input'; // 数据展示 import Avatar from './avatar/avatar'; +import Carousel from './carousel/carousel'; +import CarouselItem from './carousel/item'; function install(app: App) { const packages = [ @@ -27,7 +29,10 @@ function install(app: App) { Tabs, Alert, Checkbox, Radio, Switch, TagsInput, TextInput, + Avatar, + Carousel, + CarouselItem, ]; packages.forEach((item:any) => { if (item.install) { @@ -40,10 +45,16 @@ function install(app: App) { export { Button, Icon, Panel, + Tabs, + Alert, + Checkbox, Radio, Switch, TagsInput, TextInput, + Avatar, + Carousel, + CarouselItem, }; export default { install, version: '0.0.1' }; diff --git a/sites/.vitepress/config/sidebar.ts b/sites/.vitepress/config/sidebar.ts index 5dc1723d66251710875da660c8298e7dcb0dc338..9866743ecf258dd005a61ca4e41adf522e84e8be 100644 --- a/sites/.vitepress/config/sidebar.ts +++ b/sites/.vitepress/config/sidebar.ts @@ -35,8 +35,9 @@ const sidebar = { text: '数据展示', children: [ { text: 'Avatar 头像', link: '/components/avatar/' }, + { text: 'Carousel 走马灯', link: '/components/carousel/' }, ] - } + }, ], } diff --git a/devui/carousel/doc/api-cn.md b/sites/components/carousel/index.md similarity index 36% rename from devui/carousel/doc/api-cn.md rename to sites/components/carousel/index.md index 05132f505c917dcf2073fa263a70029ecc6cbf70..ad99fbec7e541aaacd77c9e2e9b0b18564cd9250 100644 --- a/devui/carousel/doc/api-cn.md +++ b/sites/components/carousel/index.md @@ -1,40 +1,162 @@ -# 如何使用 -在module中引入: -```ts -import { CarouselModule } from 'ng-devui/carousel'; +# Carousel 走马灯 + +一组轮播的区域。 + +### 何时使用 + +1. 用于展示图片或者卡片。 + +### 基本用法 + + {{ item }} + + +```html + + {{ item }} + +``` +```css +.d-carousel-item { + text-align: center; + line-height: 200px; + background: var(--devui-global-bg, #f3f6f8); +} ``` -在页面中使用: +### 指示器&切换箭头 +arrowTrigger设为always可以使箭头永久显示,dotTrigger设为hover可以使hover到点上就切换。 + + + {{ item }} + + ```html - - + + {{ item }} ``` +### 自动轮播 + + {{ item }} + -# d-carousel +```html + + {{ item }} + +``` +### 自定义操作 + + {{ item }} + + -## d-carousel 参数 +```html + + {{ item }} + + +``` +```css +.carousel-demo-operate{ + margin-top: 10px; + + display: flex; + align-items: center; + + > * { + margin-right: 20px; + } +} +``` + +### API +#### 参数 | 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | | :----------: | :--------------------------: | :------: | :---------------------------------------------- | ------------------------------------------------ | -| arrowTrigger | `'hover'\|'never'\|'always'` | 'hover' | 可选,指定切换箭头显示方式 | [指示器&切换箭头](demo#trigger-usage) | -| autoplay | `boolean` | false | 可选,是否自动轮播 | [自动轮播](demo#autoplay-usage) | -| autoplaySpeed | `number` | 3000 | 可选,配合`autoplay`使用,自动轮播速度,单位 ms | [自动轮播](demo#autoplay-usage) | -| height | `string` | '100%' | 可选,轮播容器高度 | [基本用法](demo#basic-usage) | -| showDots | `boolean` | true | 可选,是否显示面板指示器 | [自动轮播](demo#autoplay-usage) | -| dotPosition | `'top'\|'bottom'` | 'bottom' | 可选,面板指示器位置 | [指示器&切换箭头](demo#trigger-usage) | -| dotTrigger | `'click'\|'hover'` | 'click' | 可选,指示器触发轮播方式 | [指示器&切换箭头](demo#trigger-usage) | -| activeIndex | `number` | 0 | 可选,初始化激活卡片索引,从 0 开始,支持`[(activeIndex)]`双向绑定 | [基本用法](demo#basic-usage) | +| arrowTrigger | `'hover'\|'never'\|'always'` | 'hover' | 可选,指定切换箭头显示方式 | [指示器&切换箭头](#指示器切换箭头) | +| autoplay | `boolean` | false | 可选,是否自动轮播 | [自动轮播](#自动轮播) | +| autoplaySpeed | `number` | 3000 | 可选,配合`autoplay`使用,自动轮播速度,单位 ms | [自动轮播](#自动轮播) | +| height | `string` | '100%' | 可选,轮播容器高度 | [基本用法](#基本用法) | +| showDots | `boolean` | true | 可选,是否显示面板指示器 | [自动轮播](#自动轮播) | +| dotPosition | `'top'\|'bottom'` | 'bottom' | 可选,面板指示器位置 | [指示器&切换箭头](#指示器切换箭头) | +| dotTrigger | `'click'\|'hover'` | 'click' | 可选,指示器触发轮播方式 | [指示器&切换箭头](#指示器切换箭头) | +| activeIndex | `number` | 0 | 可选,初始化激活卡片索引,从 0 开始,支持`[(activeIndex)]`双向绑定 | [基本用法](#基本用法) | -## d-carousel 事件 +#### 事件 | 事件 | 类型 | 描述 | 跳转 Demo | | :----------------: | :---------------------: | :-----------------------------------------: | ------------------------------------------------- | -| activeIndexChange | `EventEmitter` | 卡片切换时,返回当前卡片的索引,从0开始 | [基本用法](demo#basic-usage) | +| activeIndexChange | `EventEmitter` | 卡片切换时,返回当前卡片的索引,从0开始 | [基本用法](#基本用法) | -## d-carousel 方法 +#### 方法 | 方法 | 描述 | 跳转 Demo | | :---------: | :---------------------------------- | :----------------------------- | -| prev() | 切换到上一张卡片 | [自定义操作](demo#custom-usage) | -| next() | 切换到下一张卡片 | [自定义操作](demo#custom-usage) | -| goTo(index) | 切换到指定索引的卡片,索引从 0 开始 | [自定义操作](demo#custom-usage) | +| prev() | 切换到上一张卡片 | [自定义操作](#自定义操作) | +| next() | 切换到下一张卡片 | [自定义操作](#自定义操作) | +| goTo(index) | 切换到指定索引的卡片,索引从 0 开始 | [自定义操作](#自定义操作) | + + + + \ No newline at end of file