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:
Michał Hanusek
2024-01-22 17:47:14 +01:00
committed by GitHub
parent ea58c88baa
commit f37b043138
21 changed files with 676 additions and 4 deletions

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/800?random=1',
'https://picsum.photos/600/800?random=2',
'https://picsum.photos/600/800?random=3',
'https://picsum.photos/600/800?random=4',
'https://picsum.photos/600/800?random=5',
'https://picsum.photos/600/800?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items">
<img :src="item" width="300" height="400">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/1920/1080?random=1',
'https://picsum.photos/1920/1080?random=2',
'https://picsum.photos/1920/1080?random=3',
'https://picsum.photos/1920/1080?random=4',
'https://picsum.photos/1920/1080?random=5',
'https://picsum.photos/1920/1080?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full' }" class="rounded-lg overflow-hidden" arrows>
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/800?random=1',
'https://picsum.photos/600/800?random=2',
'https://picsum.photos/600/800?random=3',
'https://picsum.photos/600/800?random=4',
'https://picsum.photos/600/800?random=5',
'https://picsum.photos/600/800?random=6'
]
</script>
<template>
<UCarousel
v-slot="{ item }"
:items="items"
:ui="{
item: 'basis-full',
container: 'rounded-lg'
}"
:prev-button="{
color: 'gray',
icon: 'i-heroicons-arrow-left-20-solid',
class: '-left-12'
}"
:next-button="{
color: 'gray',
icon: 'i-heroicons-arrow-right-20-solid',
class: '-right-12'
}"
arrows
class="w-64 mx-auto"
>
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/1920/1080?random=1',
'https://picsum.photos/1920/1080?random=2',
'https://picsum.photos/1920/1080?random=3',
'https://picsum.photos/1920/1080?random=4',
'https://picsum.photos/1920/1080?random=5',
'https://picsum.photos/1920/1080?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full' }" class="rounded-lg overflow-hidden" indicators>
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/600?random=1',
'https://picsum.photos/600/600?random=2',
'https://picsum.photos/600/600?random=3',
'https://picsum.photos/600/600?random=4',
'https://picsum.photos/600/600?random=5',
'https://picsum.photos/600/600?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full md:basis-1/2 lg:basis-1/3' }" indicators class="rounded-lg overflow-hidden">
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/600?random=1',
'https://picsum.photos/600/600?random=2',
'https://picsum.photos/600/600?random=3',
'https://picsum.photos/600/600?random=4',
'https://picsum.photos/600/600?random=5',
'https://picsum.photos/600/600?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full md:basis-1/2 lg:basis-1/3' }">
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/800?random=1',
'https://picsum.photos/600/800?random=2',
'https://picsum.photos/600/800?random=3',
'https://picsum.photos/600/800?random=4',
'https://picsum.photos/600/800?random=5',
'https://picsum.photos/600/800?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full' }" class="w-64 mx-auto rounded-lg overflow-hidden">
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/1920/1080?random=1',
'https://picsum.photos/1920/1080?random=2',
'https://picsum.photos/1920/1080?random=3',
'https://picsum.photos/1920/1080?random=4',
'https://picsum.photos/1920/1080?random=5',
'https://picsum.photos/1920/1080?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'basis-full' }" class="rounded-lg overflow-hidden">
<img :src="item" class="w-full">
</UCarousel>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const items = [{
name: 'Sébastien Chopin',
to: 'https://github.com/Atinux',
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/atinux' }
}, {
name: 'Pooya Parsa',
to: 'https://github.com/pi0',
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/pi0' }
}, {
name: 'Daniel Roe',
to: 'https://github.com/danielroe',
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/danielroe' }
}, {
name: 'Anthony Fu',
to: 'https://github.com/antfu',
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/antfu' }
}]
</script>
<template>
<UCarousel :items="items" :ui="{ item: 'w-full' }">
<template #default="{ item, index }">
<div class="text-center mx-auto">
<img :src="item.avatar.src" :alt="item.name" class="rounded-full w-48 h-48 mb-2">
<p class="font-semibold">
{{ index + 1 }}. {{ item.name }}
</p>
</div>
</template>
</UCarousel>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/800?random=1',
'https://picsum.photos/600/800?random=2',
'https://picsum.photos/600/800?random=3',
'https://picsum.photos/600/800?random=4',
'https://picsum.photos/600/800?random=5',
'https://picsum.photos/600/800?random=6'
]
</script>
<template>
<UCarousel
:items="items"
:ui="{
item: 'basis-full',
container: 'rounded-lg',
indicators: {
wrapper: 'relative bottom-0 mt-4'
}
}"
indicators
class="w-64 mx-auto"
>
<template #default="{ item }">
<img :src="item" class="w-full">
</template>
<template #indicator="{ onClick, index, active }">
<UButton :label="index" :variant="active ? 'solid' : 'outline'" size="2xs" class="rounded-full min-w-6 justify-center" @click="onClick(index)" />
</template>
</UCarousel>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/600/800?random=1',
'https://picsum.photos/600/800?random=2',
'https://picsum.photos/600/800?random=3',
'https://picsum.photos/600/800?random=4',
'https://picsum.photos/600/800?random=5',
'https://picsum.photos/600/800?random=6'
]
</script>
<template>
<UCarousel
:items="items"
:ui="{
item: 'basis-full',
container: 'rounded-lg'
}"
arrows
class="w-64 mx-auto"
>
<template #default="{ item }">
<img :src="item" class="w-full">
</template>
<template #prev="{ onClick, disabled }">
<button :disabled="disabled" @click="onClick">
Prev
</button>
</template>
<template #next="{ onClick, disabled }">
<button :disabled="disabled" class="" @click="onClick">
Next
</button>
</template>
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/400/600?random=1',
'https://picsum.photos/400/600?random=2',
'https://picsum.photos/400/600?random=3',
'https://picsum.photos/400/600?random=4',
'https://picsum.photos/400/600?random=5',
'https://picsum.photos/400/600?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'snap-end' }">
<img :src="item" width="200" height="300">
</UCarousel>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const items = [
'https://picsum.photos/400/600?random=1',
'https://picsum.photos/400/600?random=2',
'https://picsum.photos/400/600?random=3',
'https://picsum.photos/400/600?random=4',
'https://picsum.photos/400/600?random=5',
'https://picsum.photos/400/600?random=6'
]
</script>
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'snap-start' }">
<img :src="item" width="200" height="300">
</UCarousel>
</template>

View File

@@ -122,7 +122,7 @@ export default defineAppConfig({
Thanks to [tailwind-merge](https://github.com/dcastil/tailwind-merge), the `app.config.ts` is smartly merged with the default config. This means you don't have to rewrite everything.
You can change this behaviour by setting `strategy` to `override` in your `app.config.ts`:
You can change this behavior by setting `strategy` to `override` in your `app.config.ts`:
```ts [app.config.ts]
export default defineAppConfig({
@@ -175,7 +175,7 @@ To change the font of the `label`, you only need to write:
This will smartly replace the `font-medium` by `font-semibold` and prevent any class duplication and any class priority issue.
You can change this behaviour by setting `strategy` to `override` inside the `ui` prop:
You can change this behavior by setting `strategy` to `override` inside the `ui` prop:
```vue
<UButton

View File

@@ -0,0 +1,137 @@
---
description: Display images or content in a scrollable area.
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/blob/dev/src/runtime/components/elements/Carousel.vue
navigation:
badge: New
---
## Usage
Drag with your mouse, scroll with your mouse wheel or use the navigation arrows to navigate.
Pass an array to the `items` prop and use the default slot to display the content of each item.
:component-example{component="carousel-example"}
### Snap
The carousel will snap the item to the center (`snap-center`). You can use the `snap` utility classes to change this behavior using the `ui` prop:
#### Snap to start
::component-example
---
component: 'carousel-example-snap-start'
---
#code
```vue
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'snap-start' }">
<img :src="item" width="200" height="300">
</UCarousel>
</template>
```
::
#### Snap to end
::component-example
---
component: 'carousel-example-snap-end'
---
#code
```vue
<template>
<UCarousel v-slot="{ item }" :items="items" :ui="{ item: 'snap-end' }">
<img :src="item" width="200" height="300">
</UCarousel>
</template>
```
::
::callout{icon="i-heroicons-light-bulb" to="https://tailwindcss.com/docs/scroll-snap-align" target="_blank"}
Learn more about the `scroll-snap-align` property on the Tailwind CSS documentation.
::
### Size
Each item will take its own size by default in the carousel. You can use the `basis` / `width` utility classes to change this behavior using the `ui` prop:
:component-example{component="carousel-example-size"}
In this example, we used `basis-1/3` to display 3 items at a time but you can also use this to make the carousel full width using `basis-full` and display only one item at a time:
:component-example{component="carousel-example-size-full"}
You can also set a width on the container to center the carousel:
:component-example{component="carousel-example-size-center"}
::callout{icon="i-heroicons-light-bulb" to="https://tailwindcss.com/docs/flex-basis" target="_blank"}
Learn more about the `flex-basis` property on the Tailwind CSS documentation.
::
## Navigation
### Arrows
Use the `arrows` prop to enable prev and next buttons, they will be automatically disabled when the carousel reaches the first or last item.
:component-example{component="carousel-example-arrows"}
You can also customize the prev and next buttons using the `prev-button` and `next-button` props:
:component-example{component="carousel-example-arrows-center"}
In this example, we move the buttons outside of the carousel container. You can also change this globally in `ui.carousel.default.prevButton` and `ui.carousel.default.nextButton`.
### Indicators
Use the `indicators` prop to display a list of buttons at the bottom of the carousel to navigate between items.
:component-example{component="carousel-example-indicators"}
The number of indicators will be automatically generated based on the number of items:
:component-example{component="carousel-example-indicators-size"}
## Slots
### `default`
You can put anything inside the default slot, not only images. You will have access to the `item` and `index` properties in the slot scope.
:component-example{component="carousel-example-slots-default"}
### `prev` / `next`
With the `arrows` prop enabled, use the `#prev` and `#next` slots to set the content of the previous and next buttons. You will have access to the `disabled` property and `on-click` method in the slot scope.
:component-example{component="carousel-example-slots-prev-next"}
::callout{icon="i-heroicons-light-bulb"}
You can customize the position of the buttons through `ui.arrows.wrapper`.
::
### `indicator`
With the `indicators` prop enabled, use the `#indicator` slot to set the content of the indicators. You will have access to the `active`, `index` properties and `on-click` method in the slot scope.
:component-example{component="carousel-example-slots-indicator"}
::callout{icon="i-heroicons-light-bulb"}
You can customize the position of the buttons through `ui.indicators.wrapper`.
::
## Props
:component-props
## Config
:component-preset

View 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>

View File

@@ -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()
}

View File

@@ -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()
}

View 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)
})
}

View 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 '
}
}
}

View File

@@ -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'