mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-17 13:38:07 +01:00
feat(Carousel): implement component (#2288)
This commit is contained in:
@@ -5,7 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/breadcrumb'
|
||||
import type { AvatarProps, LinkProps } from '../types'
|
||||
import type { DynamicSlots } from '../types/utils'
|
||||
import type { DynamicSlots, PartialString } from '../types/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { breadcrumb: Partial<typeof theme> } }
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface BreadcrumbProps<T> {
|
||||
*/
|
||||
separatorIcon?: string
|
||||
class?: any
|
||||
ui?: Partial<typeof breadcrumb.slots>
|
||||
ui?: PartialString<typeof breadcrumb.slots>
|
||||
}
|
||||
|
||||
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any
|
||||
|
||||
306
src/runtime/components/Carousel.vue
Normal file
306
src/runtime/components/Carousel.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<!-- eslint-disable vue/block-tag-newline -->
|
||||
<script lang="ts">
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
|
||||
import type { AutoplayOptionsType } from 'embla-carousel-autoplay'
|
||||
import type { AutoScrollOptionsType } from 'embla-carousel-auto-scroll'
|
||||
import type { AutoHeightOptionsType } from 'embla-carousel-auto-height'
|
||||
import type { ClassNamesOptionsType } from 'embla-carousel-class-names'
|
||||
import type { FadeOptionsType } from 'embla-carousel-fade'
|
||||
import type { WheelGesturesPluginOptions } from 'embla-carousel-wheel-gestures'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/carousel'
|
||||
import type { ButtonProps } from '../types'
|
||||
import type { AcceptableValue, PartialString } from '../types/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { carousel: Partial<typeof theme> } }
|
||||
|
||||
const carousel = tv({ extend: tv(theme), ...(appConfig.ui?.carousel || {}) })
|
||||
|
||||
type CarouselVariants = VariantProps<typeof carousel>
|
||||
|
||||
export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'container' | 'slides' | 'direction'> {
|
||||
/**
|
||||
* Configure the prev button when arrows are enabled.
|
||||
* @defaultValue { size: 'md', color: 'neutral', variant: 'link' }
|
||||
*/
|
||||
prev?: ButtonProps
|
||||
/**
|
||||
* The icon displayed in the prev button.
|
||||
* @defaultValue appConfig.ui.icons.arrowLeft
|
||||
*/
|
||||
prevIcon?: string
|
||||
/**
|
||||
* Configure the next button when arrows are enabled.
|
||||
* @defaultValue { size: 'md', color: 'neutral', variant: 'link' }
|
||||
*/
|
||||
next?: ButtonProps
|
||||
/**
|
||||
* The icon displayed in the next button.
|
||||
* @defaultValue appConfig.ui.icons.arrowRight
|
||||
*/
|
||||
nextIcon?: string
|
||||
/**
|
||||
* Display prev and next buttons to scroll the carousel.
|
||||
* @defaultValue false
|
||||
*/
|
||||
arrows?: boolean
|
||||
/**
|
||||
* Display dots to scroll to a specific slide.
|
||||
* @defaultValue false
|
||||
*/
|
||||
dots?: boolean
|
||||
orientation?: CarouselVariants['orientation']
|
||||
items?: T[]
|
||||
/**
|
||||
* Enable Autoplay plugin
|
||||
* @see https://www.embla-carousel.com/plugins/autoplay/
|
||||
*/
|
||||
autoplay?: boolean | AutoplayOptionsType
|
||||
/**
|
||||
* Enable Auto Scroll plugin
|
||||
* @see https://www.embla-carousel.com/plugins/auto-scroll/
|
||||
*/
|
||||
autoScroll?: boolean | AutoScrollOptionsType
|
||||
/**
|
||||
* Enable Auto Height plugin
|
||||
* @see https://www.embla-carousel.com/plugins/auto-height/
|
||||
*/
|
||||
autoHeight?: boolean | AutoHeightOptionsType
|
||||
/**
|
||||
* Enable Class Names plugin
|
||||
* @see https://www.embla-carousel.com/plugins/class-names/
|
||||
*/
|
||||
classNames?: boolean | ClassNamesOptionsType
|
||||
/**
|
||||
* Enable Fade plugin
|
||||
* @see https://www.embla-carousel.com/plugins/fade/
|
||||
*/
|
||||
fade?: boolean | FadeOptionsType
|
||||
/**
|
||||
* Enable Wheel Gestures plugin
|
||||
* @see https://www.embla-carousel.com/plugins/wheel-gestures/
|
||||
*/
|
||||
wheelGestures?: boolean | WheelGesturesPluginOptions
|
||||
class?: any
|
||||
ui?: PartialString<typeof carousel.slots>
|
||||
}
|
||||
|
||||
export type CarouselSlots<T> = {
|
||||
default(props: { item: T, index: number }): any
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue">
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import useEmblaCarousel from 'embla-carousel-vue'
|
||||
import { useForwardProps } from 'radix-vue'
|
||||
import { reactivePick, computedAsync } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import UButton from './Button.vue'
|
||||
|
||||
const props = withDefaults(defineProps<CarouselProps<T>>(), {
|
||||
orientation: 'horizontal',
|
||||
arrows: false,
|
||||
dots: false,
|
||||
// Embla Options
|
||||
active: true,
|
||||
align: 'center',
|
||||
breakpoints: () => ({}),
|
||||
containScroll: 'trimSnaps',
|
||||
dragFree: false,
|
||||
dragThreshold: 10,
|
||||
duration: 25,
|
||||
inViewThreshold: 0,
|
||||
loop: false,
|
||||
skipSnaps: false,
|
||||
slidesToScroll: 1,
|
||||
startIndex: 0,
|
||||
watchDrag: true,
|
||||
watchResize: true,
|
||||
watchSlides: true,
|
||||
watchFocus: true,
|
||||
// Embla Plugins
|
||||
autoplay: false,
|
||||
autoScroll: false,
|
||||
autoHeight: false,
|
||||
classNames: false,
|
||||
fade: false,
|
||||
wheelGestures: false
|
||||
})
|
||||
defineSlots<CarouselSlots<T>>()
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const rootProps = useForwardProps(reactivePick(props, 'active', 'align', 'breakpoints', 'containScroll', 'dragFree', 'dragThreshold', 'duration', 'inViewThreshold', 'loop', 'skipSnaps', 'slidesToScroll', 'startIndex', 'watchDrag', 'watchResize', 'watchSlides', 'watchFocus'))
|
||||
|
||||
const ui = computed(() => carousel({
|
||||
orientation: props.orientation
|
||||
}))
|
||||
|
||||
const options = computed<EmblaOptionsType>(() => ({
|
||||
...(props.fade ? { align: 'center', containScroll: false } : {}),
|
||||
...rootProps.value,
|
||||
axis: props.orientation === 'horizontal' ? 'x' : 'y',
|
||||
// TODO: Get from ConfigProvider
|
||||
direction: 'ltr'
|
||||
}))
|
||||
|
||||
const plugins = computedAsync<EmblaPluginType[]>(async () => {
|
||||
const plugins = []
|
||||
|
||||
if (props.autoplay) {
|
||||
const AutoplayPlugin = await import('embla-carousel-autoplay').then(r => r.default)
|
||||
plugins.push(AutoplayPlugin(typeof props.autoplay === 'boolean' ? {} : props.autoplay))
|
||||
}
|
||||
|
||||
if (props.autoScroll) {
|
||||
const AutoScrollPlugin = await import('embla-carousel-auto-scroll').then(r => r.default)
|
||||
plugins.push(AutoScrollPlugin(typeof props.autoScroll === 'boolean' ? {} : props.autoScroll))
|
||||
}
|
||||
|
||||
if (props.autoHeight) {
|
||||
const AutoHeightPlugin = await import('embla-carousel-auto-height').then(r => r.default)
|
||||
plugins.push(AutoHeightPlugin(typeof props.autoHeight === 'boolean' ? {} : props.autoHeight))
|
||||
}
|
||||
|
||||
if (props.classNames) {
|
||||
const ClassNamesPlugin = await import('embla-carousel-class-names').then(r => r.default)
|
||||
plugins.push(ClassNamesPlugin(typeof props.classNames === 'boolean' ? {} : props.classNames))
|
||||
}
|
||||
|
||||
if (props.fade) {
|
||||
const FadePlugin = await import('embla-carousel-fade').then(r => r.default)
|
||||
plugins.push(FadePlugin(typeof props.fade === 'boolean' ? {} : props.fade))
|
||||
}
|
||||
|
||||
if (props.wheelGestures) {
|
||||
const { WheelGesturesPlugin } = await import('embla-carousel-wheel-gestures')
|
||||
plugins.push(WheelGesturesPlugin(typeof props.wheelGestures === 'boolean' ? {} : props.wheelGestures))
|
||||
}
|
||||
|
||||
return plugins
|
||||
})
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options.value, plugins.value)
|
||||
|
||||
watch([options, plugins], () => {
|
||||
emblaApi.value?.reInit(options.value, plugins.value)
|
||||
})
|
||||
|
||||
function scrollPrev() {
|
||||
emblaApi.value?.scrollPrev()
|
||||
}
|
||||
function scrollNext() {
|
||||
emblaApi.value?.scrollNext()
|
||||
}
|
||||
function scrollTo(index: number) {
|
||||
emblaApi.value?.scrollTo(index)
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
|
||||
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
|
||||
|
||||
if (event.key === prevKey) {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === nextKey) {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
}
|
||||
|
||||
const canScrollNext = ref(false)
|
||||
const canScrollPrev = ref(false)
|
||||
const selectedIndex = ref<number>(0)
|
||||
const scrollSnaps = ref<number[]>([])
|
||||
|
||||
function onInit(api: EmblaCarouselType) {
|
||||
scrollSnaps.value = api?.scrollSnapList() || []
|
||||
}
|
||||
function onSelect(api: EmblaCarouselType) {
|
||||
canScrollNext.value = api?.canScrollNext() || false
|
||||
canScrollPrev.value = api?.canScrollPrev() || false
|
||||
selectedIndex.value = api?.selectedScrollSnap() || 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!emblaApi.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emblaApi.value?.on('init', onInit)
|
||||
emblaApi.value?.on('init', onSelect)
|
||||
emblaApi.value?.on('reInit', onInit)
|
||||
emblaApi.value?.on('reInit', onSelect)
|
||||
emblaApi.value?.on('select', onSelect)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
emblaRef,
|
||||
emblaApi
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
tabindex="0"
|
||||
:class="ui.root({ class: [props.class, props.ui?.root] })"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<div ref="emblaRef" :class="ui.viewport({ class: props.ui?.viewport })">
|
||||
<div :class="ui.container({ class: props.ui?.container })">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
:class="ui.item({ class: props.ui?.item })"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="arrows || dots" :class="ui.controls({ class: props.ui?.controls })">
|
||||
<div v-if="arrows" :class="ui.arrows({ class: props.ui?.arrows })">
|
||||
<UButton
|
||||
:disabled="!canScrollPrev"
|
||||
:icon="prevIcon || appConfig.ui.icons.arrowLeft"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
aria-label="Prev"
|
||||
v-bind="typeof prev === 'object' ? prev : undefined"
|
||||
:class="ui.prev({ class: props.ui?.prev })"
|
||||
@click="scrollPrev"
|
||||
/>
|
||||
<UButton
|
||||
:disabled="!canScrollNext"
|
||||
:icon="nextIcon || appConfig.ui.icons.arrowRight"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
aria-label="Next"
|
||||
v-bind="typeof next === 'object' ? next : undefined"
|
||||
:class="ui.next({ class: props.ui?.next })"
|
||||
@click="scrollNext"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="dots" :class="ui.dots({ class: props.ui?.dots })">
|
||||
<template v-for="(_, index) in scrollSnaps" :key="index">
|
||||
<button :class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })" @click="scrollTo(index)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/context-menu'
|
||||
import type { AvatarProps, KbdProps, LinkProps } from '../types'
|
||||
import type { DynamicSlots } from '../types/utils'
|
||||
import type { DynamicSlots, PartialString } from '../types/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { contextMenu: Partial<typeof theme> } }
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'>,
|
||||
*/
|
||||
portal?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof contextMenu.slots>
|
||||
ui?: PartialString<typeof contextMenu.slots>
|
||||
}
|
||||
|
||||
export interface ContextMenuEmits extends ContextMenuRootEmits {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/dropdown-menu'
|
||||
import type { AvatarProps, KbdProps, LinkProps } from '../types'
|
||||
import type { DynamicSlots } from '../types/utils'
|
||||
import type { DynamicSlots, PartialString } from '../types/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof theme> } }
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'>
|
||||
*/
|
||||
portal?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof dropdownMenu.slots>
|
||||
ui?: PartialString<typeof dropdownMenu.slots>
|
||||
}
|
||||
|
||||
export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AppConfig } from '@nuxt/schema'
|
||||
import _appConfig from '#build/app.config'
|
||||
import theme from '#build/ui/navigation-menu'
|
||||
import type { AvatarProps, BadgeProps, LinkProps } from '../types'
|
||||
import type { DynamicSlots } from '../types/utils'
|
||||
import type { DynamicSlots, PartialString } from '../types/utils'
|
||||
|
||||
const appConfig = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'd
|
||||
*/
|
||||
arrow?: boolean
|
||||
class?: any
|
||||
ui?: Partial<typeof navigationMenu.slots>
|
||||
ui?: PartialString<typeof navigationMenu.slots>
|
||||
}
|
||||
|
||||
export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from '../components/Badge.vue'
|
||||
export * from '../components/Breadcrumb.vue'
|
||||
export * from '../components/Button.vue'
|
||||
export * from '../components/Card.vue'
|
||||
export * from '../components/Carousel.vue'
|
||||
export * from '../components/Checkbox.vue'
|
||||
export * from '../components/Chip.vue'
|
||||
export * from '../components/Collapsible.vue'
|
||||
|
||||
Reference in New Issue
Block a user