mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-29 11:20:36 +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:
16
docs/components/content/examples/CarouselExample.vue
Normal file
16
docs/components/content/examples/CarouselExample.vue
Normal 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>
|
||||||
16
docs/components/content/examples/CarouselExampleArrows.vue
Normal file
16
docs/components/content/examples/CarouselExampleArrows.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
16
docs/components/content/examples/CarouselExampleSize.vue
Normal file
16
docs/components/content/examples/CarouselExampleSize.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
16
docs/components/content/examples/CarouselExampleSizeFull.vue
Normal file
16
docs/components/content/examples/CarouselExampleSizeFull.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
16
docs/components/content/examples/CarouselExampleSnapEnd.vue
Normal file
16
docs/components/content/examples/CarouselExampleSnapEnd.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
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]
|
```ts [app.config.ts]
|
||||||
export default defineAppConfig({
|
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.
|
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
|
```vue
|
||||||
<UButton
|
<UButton
|
||||||
|
|||||||
137
docs/content/2.elements/13.carousel.md
Normal file
137
docs/content/2.elements/13.carousel.md
Normal 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
|
||||||
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)
|
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) {
|
if (modelModifiers.value.trim) {
|
||||||
(event.target as HTMLInputElement).value = value.trim()
|
(event.target as HTMLInputElement).value = value.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default defineComponent({
|
|||||||
updateInput(value)
|
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) {
|
if (modelModifiers.value.trim) {
|
||||||
(event.target as HTMLInputElement).value = 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 progress } from './elements/progress'
|
||||||
export { default as meter } from './elements/meter'
|
export { default as meter } from './elements/meter'
|
||||||
export { default as meterGroup } from './elements/meterGroup'
|
export { default as meterGroup } from './elements/meterGroup'
|
||||||
|
export { default as carousel } from './elements/carousel'
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
export { default as input } from './forms/input'
|
export { default as input } from './forms/input'
|
||||||
|
|||||||
Reference in New Issue
Block a user