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)