primitives
Carousel
Carousel — базовый примитив RevueUI (Reka UI + shadcn-совместимый API).
Загрузка превью…
src/ui/carousel/Carousel.vue
<script setup lang="ts">
import type { EmblaCarouselType, EmblaOptionsType } from "embla-carousel"
import type { HTMLAttributes } from "vue"
import { onUnmounted, ref, watch, watchEffect } from "vue"
import emblaCarouselVue from "embla-carousel-vue"
import { cn } from "@/lib/utils"
import { type CarouselPlugin, provideCarouselContext } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
opts?: EmblaOptionsType
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: EmblaCarouselType | undefined) => void
}>(),
{
orientation: "horizontal",
}
)
const [carouselRef, api] = emblaCarouselVue(
{
...props.opts,
axis: props.orientation === "horizontal" ? "x" : "y",
},
props.plugins
)
const canScrollPrev = ref(false)
const canScrollNext = ref(false)
function onSelect(emblaApi: EmblaCarouselType | undefined) {
if (!emblaApi) return
canScrollPrev.value = emblaApi.canScrollPrev()
canScrollNext.value = emblaApi.canScrollNext()
}
function scrollPrev() {
api.value?.scrollPrev()
}
function scrollNext() {
api.value?.scrollNext()
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
}
watchEffect(() => {
if (!api.value || !props.setApi) return
props.setApi(api.value)
})
let cleanup: (() => void) | undefined
watch(
api,
(emblaApi) => {
cleanup?.()
cleanup = undefined
if (!emblaApi) return
onSelect(emblaApi)
emblaApi.on("reInit", onSelect)
emblaApi.on("select", onSelect)
cleanup = () => emblaApi.off("select", onSelect)
},
{ immediate: true }
)
onUnmounted(() => {
cleanup?.()
})
provideCarouselContext({
carouselRef,
api,
opts: props.opts,
orientation: props.orientation || (props.opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
})
</script>
<template>
<div
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
data-slot="carousel"
@keydown.capture="handleKeyDown"
>
<slot />
</div>
</template>
src/ui/carousel/CarouselContent.vue
<script setup lang="ts">
import type { ComponentPublicInstance, HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useCarousel } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { carouselRef, orientation } = useCarousel()
function setCarouselRef(el: Element | ComponentPublicInstance | null) {
carouselRef.value = (el as HTMLElement | null) ?? undefined
}
</script>
<template>
<div :ref="setCarouselRef" class="overflow-hidden" data-slot="carousel-content">
<div :class="cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', props.class)">
<slot />
</div>
</div>
</template>
src/ui/carousel/CarouselItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useCarousel } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { orientation } = useCarousel()
</script>
<template>
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
:class="cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', props.class)"
>
<slot />
</div>
</template>
src/ui/carousel/CarouselNext.vue
<script setup lang="ts">
/**
* Тот же блокер, что и в CarouselPrevious.vue — IconPlaceholder заменён
* инлайновым <svg> (путь lucide "chevron-right").
*/
import type { HTMLAttributes } from "vue"
import { Button, type ButtonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useCarousel } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}>(),
{
variant: "outline",
size: "icon-sm",
}
)
const { orientation, scrollNext, canScrollNext } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-next"
:variant="props.variant"
:size="props.size"
:class="
cn(
'cn-carousel-next absolute touch-manipulation',
orientation === 'horizontal' ? 'inset-y-0 -right-12 my-auto' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class
)
"
:disabled="!canScrollNext"
@click="scrollNext"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cn-rtl-flip"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span class="sr-only">Next slide</span>
</Button>
</template>
src/ui/carousel/CarouselPrevious.vue
<script setup lang="ts">
/**
* Индикатор-иконка в оригинале — IconPlaceholder (тот же блокер, что и
* везде, см. docs/PORTING.md §5). Всегда смонтирован (кнопка не скрыта
* условным рендером — только `disabled`), заменён инлайновым <svg>
* (путь lucide "chevron-left").
*/
import type { HTMLAttributes } from "vue"
import { Button, type ButtonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useCarousel } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}>(),
{
variant: "outline",
size: "icon-sm",
}
)
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-previous"
:variant="props.variant"
:size="props.size"
:class="
cn(
'cn-carousel-previous absolute touch-manipulation',
orientation === 'horizontal' ? 'inset-y-0 -left-12 my-auto' : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class
)
"
:disabled="!canScrollPrev"
@click="scrollPrev"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cn-rtl-flip"
>
<path d="m15 18-6-6 6-6" />
</svg>
<span class="sr-only">Previous slide</span>
</Button>
</template>
src/ui/carousel/context.ts
import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from "embla-carousel"
import type { Ref } from "vue"
import { createContext } from "reka-ui"
/**
* Апстрим (registry/bases/radix/ui/carousel.tsx) построен на
* `embla-carousel-react`. У Embla есть официальный Vue-пакет
* `embla-carousel-vue` — тот же движок (embla-carousel core), контекст
* ниже воспроизводит React-контекст оригинала (`CarouselContext`) один в
* один, только на Vue provide/inject (createContext из reka-ui, тот же
* приём, что и в ui/command).
*/
export interface CarouselContextValue {
carouselRef: Ref<HTMLElement | undefined>
api: Ref<EmblaCarouselType | undefined>
opts?: EmblaOptionsType
orientation: "horizontal" | "vertical"
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: Ref<boolean>
canScrollNext: Ref<boolean>
}
export type CarouselPlugin = EmblaPluginType[]
export const [useCarousel, provideCarouselContext] = createContext<CarouselContextValue>("Carousel")
src/ui/carousel/index.ts
export { default as Carousel } from "./Carousel.vue"
export { default as CarouselContent } from "./CarouselContent.vue"
export { default as CarouselItem } from "./CarouselItem.vue"
export { default as CarouselNext } from "./CarouselNext.vue"
export { default as CarouselPrevious } from "./CarouselPrevious.vue"
export { useCarousel } from "./context"
export type { CarouselPlugin } from "./context"