mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-19 14:31:47 +01:00
feat(Carousel): new component (#927)
Co-authored-by: Michał Hanusek <m.hanusek@myfreak.pl> Co-authored-by: Inesh Bose <dev@inesh.xyz> Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
176
src/runtime/components/elements/Carousel.vue
Normal file
176
src/runtime/components/elements/Carousel.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div :class="ui.wrapper" v-bind="attrs">
|
||||
<div ref="carouselRef" :class="ui.container" class="no-scrollbar">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:class="ui.item"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="arrows" :class="ui.arrows.wrapper">
|
||||
<slot name="prev" :on-click="onClickPrev" :disabled="isFirst">
|
||||
<UButton
|
||||
v-if="prevButton"
|
||||
:disabled="isFirst"
|
||||
v-bind="{ ...ui.default.prevButton, ...prevButton }"
|
||||
:class="twMerge(ui.default.prevButton.class, prevButton?.class)"
|
||||
aria-label="Prev"
|
||||
@click="onClickPrev"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<slot name="next" :on-click="onClickNext" :disabled="isLast">
|
||||
<UButton
|
||||
v-if="nextButton"
|
||||
:disabled="isLast"
|
||||
v-bind="{ ...ui.default.nextButton, ...nextButton }"
|
||||
:class="twMerge(ui.default.nextButton.class, nextButton?.class)"
|
||||
aria-label="Next"
|
||||
@click="onClickNext"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-if="indicators" :class="ui.indicators.wrapper">
|
||||
<template v-for="index in indicatorsCount" :key="index">
|
||||
<slot name="indicator" :on-click="onClick" :active="index === currentIndex" :index="index">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
ui.indicators.base,
|
||||
index === currentIndex ? ui.indicators.active : ui.indicators.inactive
|
||||
]"
|
||||
:aria-label="`set slide ${index}`"
|
||||
@click="onClick(index)"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, toRef, toRefs, computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { mergeConfig } from '../../utils'
|
||||
import UButton from '../elements/Button.vue'
|
||||
import type { Strategy, Button } from '../../types'
|
||||
import { useUI } from '../../composables/useUI'
|
||||
import { useCarouselScroll } from '../../composables/useCarouselScroll'
|
||||
import { useScroll, useResizeObserver, useElementSize } from '@vueuse/core'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
import { carousel } from '#ui/ui.config'
|
||||
|
||||
const config = mergeConfig<typeof carousel>(appConfig.ui.strategy, appConfig.ui.carousel, carousel)
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UButton
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => []
|
||||
},
|
||||
arrows: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
indicators: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
prevButton: {
|
||||
type: Object as PropType<Button & { class?: string }>,
|
||||
default: () => config.default.prevButton as Button & { class?: string }
|
||||
},
|
||||
nextButton: {
|
||||
type: Object as PropType<Button & { class?: string }>,
|
||||
default: () => config.default.nextButton as Button & { class?: string }
|
||||
},
|
||||
class: {
|
||||
type: [String, Object, Array] as PropType<any>,
|
||||
default: () => ''
|
||||
},
|
||||
ui: {
|
||||
type: Object as PropType<Partial<typeof config & { strategy?: Strategy }>>,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
const { ui, attrs } = useUI('carousel', toRef(props, 'ui'), config, toRef(props, 'class'))
|
||||
|
||||
const carouselRef = ref<HTMLElement>()
|
||||
const itemWidth = ref(0)
|
||||
|
||||
const { x, arrivedState } = useScroll(carouselRef, { behavior: 'smooth' })
|
||||
const { width: carouselWidth } = useElementSize(carouselRef)
|
||||
|
||||
const { left: isFirst, right: isLast } = toRefs(arrivedState)
|
||||
|
||||
useCarouselScroll(carouselRef)
|
||||
|
||||
useResizeObserver(carouselRef, (entries) => {
|
||||
const [entry] = entries
|
||||
|
||||
itemWidth.value = entry?.target?.firstElementChild?.clientWidth || 0
|
||||
})
|
||||
|
||||
const currentIndex = computed(() => Math.round(x.value / itemWidth.value) + 1)
|
||||
|
||||
const indicatorsCount = computed(() => {
|
||||
if (!itemWidth.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return props.items.length - Math.round(carouselWidth.value / itemWidth.value) + 1
|
||||
})
|
||||
|
||||
function onClickNext () {
|
||||
x.value += itemWidth.value
|
||||
}
|
||||
|
||||
function onClickPrev () {
|
||||
x.value -= itemWidth.value
|
||||
}
|
||||
|
||||
function onClick (index: number) {
|
||||
x.value = (index - 1) * itemWidth.value
|
||||
}
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
ui,
|
||||
attrs,
|
||||
isFirst,
|
||||
isLast,
|
||||
carouselRef,
|
||||
indicatorsCount,
|
||||
currentIndex,
|
||||
onClickNext,
|
||||
onClickPrev,
|
||||
onClick,
|
||||
twMerge
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
</style>
|
||||
@@ -211,7 +211,7 @@ export default defineComponent({
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed input so that it has same behaviour as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.value.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export default defineComponent({
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed input so that it has same behaviour as native input
|
||||
// Update trimmed input so that it has same behavior as native input
|
||||
if (modelModifiers.value.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
|
||||
49
src/runtime/composables/useCarouselScroll.ts
Normal file
49
src/runtime/composables/useCarouselScroll.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref, type Ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export const useCarouselScroll = (el: Ref<HTMLElement>) => {
|
||||
const x = ref<number>(0)
|
||||
|
||||
function onMouseDown (e) {
|
||||
el.value.style.scrollSnapType = 'none'
|
||||
el.value.style.scrollBehavior = 'auto'
|
||||
|
||||
x.value = e.pageX
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseUp () {
|
||||
el.value.style.removeProperty('scroll-behavior')
|
||||
el.value.style.removeProperty('scroll-snap-type')
|
||||
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseMove (e) {
|
||||
e.preventDefault()
|
||||
|
||||
const delta = e.pageX - x.value
|
||||
|
||||
x.value = e.pageX
|
||||
|
||||
el.value.scrollBy(-delta, 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) {
|
||||
return
|
||||
}
|
||||
|
||||
el.value.addEventListener('mousedown', onMouseDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!el.value) {
|
||||
return
|
||||
}
|
||||
|
||||
el.value.removeEventListener('mousedown', onMouseDown)
|
||||
})
|
||||
}
|
||||
26
src/runtime/ui.config/elements/carousel.ts
Normal file
26
src/runtime/ui.config/elements/carousel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
wrapper: 'relative',
|
||||
container: 'relative w-full flex overflow-x-auto snap-x snap-mandatory scroll-smooth',
|
||||
item: 'flex flex-none snap-center',
|
||||
arrows: {
|
||||
wrapper: 'flex items-center justify-between'
|
||||
},
|
||||
indicators: {
|
||||
wrapper: 'absolute flex items-center justify-center gap-3 bottom-4 inset-x-0',
|
||||
base: 'rounded-full h-3 w-3',
|
||||
active: 'bg-primary-500 dark:bg-primary-400',
|
||||
inactive: 'bg-gray-100 dark:bg-gray-800 mix-blend-overlay'
|
||||
},
|
||||
default: {
|
||||
prevButton: {
|
||||
color: 'black' as const,
|
||||
class: 'rtl:[&_span:first-child]:rotate-180 absolute left-4 top-1/2 transform -translate-y-1/2 rounded-full',
|
||||
icon: 'i-heroicons-chevron-left-20-solid'
|
||||
},
|
||||
nextButton: {
|
||||
color: 'black' as const,
|
||||
class: 'rtl:[&_span:last-child]:rotate-180 absolute right-4 top-1/2 transform -translate-y-1/2 rounded-full',
|
||||
icon: 'i-heroicons-chevron-right-20-solid '
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export { default as kbd } from './elements/kbd'
|
||||
export { default as progress } from './elements/progress'
|
||||
export { default as meter } from './elements/meter'
|
||||
export { default as meterGroup } from './elements/meterGroup'
|
||||
export { default as carousel } from './elements/carousel'
|
||||
|
||||
// Forms
|
||||
export { default as input } from './forms/input'
|
||||
|
||||
Reference in New Issue
Block a user