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"

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/carousel.json

Зависимости реестра

npm-зависимости

  • embla-carousel
  • embla-carousel-vue
  • reka-ui

Источник: порт из ReUI (Keenthemes, MIT)