reui

Rating

Rating — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).

Загрузка превью…

src/reui/rating/Rating.vue

<script setup lang="ts">
/**
 * Порт ReUI Rating (registry-reui/bases/radix/reui/rating.tsx, MIT).
 *
 * Отличие от оригинала (осознанное): React-версия рисует звёзды через
 * `IconPlaceholder` — dev-инструмент апстрима, который на клиенте читает
 * выбранную библиотеку иконок из localStorage и лениво подгружает
 * lucide/tabler/hugeicons/phosphor/remixicon. Это чисто демо-обвязка сайта
 * ReUI, а не часть переносимой логики компонента, и у неё нет осмысленного
 * Vue-аналога (завязана на React lazy/Suspense и store апстрима). Здесь она
 * заменена на инлайновый `<svg>` (путь lucide "star"), зафиксированный как
 * есть — тот же SVG используется и в `react.tsx`-эталоне кейса, поэтому
 * визуальный диф сравнивает сам компонент, а не выбор библиотеки иконок.
 */
import type { HTMLAttributes } from "vue"
import { computed, ref } from "vue"
import { cn } from "@/lib/utils"
import {
  ratingVariants,
  starVariants,
  valueVariants,
  type RatingVariants,
} from "./variants"

const props = withDefaults(
  defineProps<{
    /** Доп. классы. Имя `class` — соглашение shadcn-vue (не `className`). */
    class?: HTMLAttributes["class"]
    size?: RatingVariants["size"]
    /** Максимальное значение рейтинга (число отображаемых звёзд). */
    maxRating?: number
    /** Показывать ли числовое значение рейтинга. */
    showValue?: boolean
    /** Класс для span со значением. */
    starClassName?: HTMLAttributes["class"]
    /** Можно ли менять рейтинг кликом. */
    editable?: boolean
  }>(),
  {
    maxRating: 5,
    showValue: false,
    editable: false,
  }
)

/**
 * Текущее значение рейтинга (поддерживает дробные значения для частичной
 * заливки звёзд). Соответствие `onRatingChange` из React — `defineModel`
 * с именем `rating`, даёт проп `rating` + событие `update:rating`.
 */
const rating = defineModel<number>("rating", { required: true })

const hoveredRating = ref<number | null>(null)

const displayRating = computed(() =>
  props.editable && hoveredRating.value !== null
    ? hoveredRating.value
    : rating.value
)

const stars = computed(() => {
  const items: { index: number; filled: boolean; width: string }[] = []
  for (let i = 1; i <= props.maxRating; i++) {
    const filled = displayRating.value >= i
    const partiallyFilled = displayRating.value > i - 1 && displayRating.value < i
    const fillPercentage = partiallyFilled ? (displayRating.value - (i - 1)) * 100 : 0
    items.push({
      index: i,
      filled,
      width: filled ? "100%" : `${fillPercentage}%`,
    })
  }
  return items
})

function handleStarClick(starRating: number) {
  if (props.editable) {
    rating.value = starRating
  }
}

function handleStarMouseEnter(starRating: number) {
  if (props.editable) {
    hoveredRating.value = starRating
  }
}

function handleStarMouseLeave() {
  if (props.editable) {
    hoveredRating.value = null
  }
}
</script>

<template>
  <div
    data-slot="rating"
    :class="cn(ratingVariants({ size: props.size }), props.class)"
  >
    <div class="flex items-center">
      <div
        v-for="star in stars"
        :key="star.index"
        :class="cn('relative', props.editable && 'cursor-pointer')"
        @click="handleStarClick(star.index)"
        @mouseenter="handleStarMouseEnter(star.index)"
        @mouseleave="handleStarMouseLeave"
      >
        <svg
          data-slot="rating-star-empty"
          :class="cn(starVariants({ size: props.size }), 'text-muted-foreground/30')"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <path
            d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
          />
        </svg>

        <div class="absolute inset-0 overflow-hidden" :style="{ width: star.width }">
          <svg
            data-slot="rating-star-filled"
            :class="cn(starVariants({ size: props.size }), 'fill-yellow-400 text-yellow-400')"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          >
            <path
              d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
            />
          </svg>
        </div>
      </div>
    </div>

    <span
      v-if="props.showValue"
      data-slot="rating-value"
      :class="cn(valueVariants({ size: props.size }), props.starClassName)"
    >{{ displayRating.toFixed(1) }}</span>
  </div>
</template>

src/reui/rating/index.ts

export { default as Rating } from "./Rating.vue"
export {
  ratingVariants,
  starVariants,
  valueVariants,
  type RatingVariants,
} from "./variants"

src/reui/rating/variants.ts

import { cva, type VariantProps } from "class-variance-authority"

/**
 * Перенесено дословно из ReUI (registry-reui/bases/radix/reui/rating.tsx, MIT).
 *
 * cva-блоки — чистые JS-объекты и переносятся между React и Vue без единой
 * правки. Это и есть главный рычаг всего порта: вся визуальная семантика
 * живёт здесь, а фреймворк-специфичной остаётся только разметка.
 */
export const ratingVariants = cva("flex items-center", {
  variants: {
    size: {
      sm: "gap-2",
      default: "gap-2.5",
      lg: "gap-3",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

export const starVariants = cva("", {
  variants: {
    size: {
      sm: "w-4 h-4",
      default: "w-5 h-5",
      lg: "w-6 h-6",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

export const valueVariants = cva("text-muted-foreground w-5", {
  variants: {
    size: {
      sm: "text-xs",
      default: "text-sm",
      lg: "text-base",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

export type RatingVariants = VariantProps<typeof ratingVariants>

Установка

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

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

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

  • class-variance-authority

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