ui
ui copied to clipboard
Rating Component Addition
A rating component would be a great addition to the library. Apart from MUI rating component, there are not many good rating components available.
@shadcn I would like to work on one. Is there a place where we can coordinate work on this and other components?
@yanisneverlies Are you already working on it? Otherwise I can spend some spare time developing it
Hey, How to create component and make it work with form? For example this rating component. Something like this:
<FormField
control={form.control}
name="rating"
render={({ field }) => (
<FormItem>
<FormLabel>Rate the product</FormLabel>
<FormControl>
<Rating {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@iamshubhamjangle you can create a Rating
component like this:
import { FieldPath, useFormContext } from 'react-hook-form'
import { FormType } from '.'
import { forwardRef } from 'react'
type RatingProps = React.InputHTMLAttributes<HTMLInputElement>
export const Rating = forwardRef<HTMLInputElement, RatingProps>(
(props, ref) => {
const name = props.name as FieldPath<FormType>
const { register, setValue, getValues } = useFormContext<FormType>()
return (
<div className="flex">
<input {...props} className="hidden" {...register(name)} ref={ref} />
{Array(5)
.fill(0)
.map((_, i) => (
<svg
key={i}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`size-5 ${i < +getValues(name) ? 'fill-primary' : 'fill-primary-foreground'}`}
onClick={() =>
setValue(name, i === +getValues(name) - 1 ? 0 : i + 1)
}
>
<circle cx="12" cy="12" r="10" />
</svg>
))}
</div>
)
},
)
Rating.displayName = 'Rating'
Here's Ratings component. Currenty working on the onHover,onChange ..
import React from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
}
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number
totalStars?: number
size?: number
fill?: boolean
Icon?: React.ReactElement
variant?: keyof typeof ratingVariants
}
const Ratings = ({ ...props }: RatingsProps) => {
const {
rating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
} = props
const fullStars = Math.floor(rating)
const partialStar =
rating % 1 > 0 ? (
<PartialStar
fillPercentage={rating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null
return (
<div className={cn("flex items-center gap-2")} {...props}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current" : "fill-transparent",
ratingVariants[variant].star
),
})
)}
{partialStar}
{[...Array(totalStars - fullStars - (partialStar ? 1 : 0))].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn(ratingVariants[variant].emptyStar),
})
)}
</div>
)
}
interface PartialStarProps {
fillPercentage: number
size: number
className?: string
Icon: React.ReactElement
}
const PartialStar = ({ ...props }: PartialStarProps) => {
const { fillPercentage, size, className, Icon } = props
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
)
}
export { Ratings }
And you can implement it as such :
<Ratings rating={1.5} />
<Ratings rating={2} variant="destructive" Icon={<Heart/>} />
<Ratings rating={2.5} variant="yellow" totalStars={8} />
Output :
import React, { useState } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
}
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number
totalStars?: number
size?: number
fill?: boolean
Icon?: React.ReactElement
variant?: keyof typeof ratingVariants
onRatingChange?: (rating: number) => void
}
export const CommentRatings = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
onRatingChange,
...props
}: RatingsProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null)
const [currentRating, setCurrentRating] = useState(initialRating)
const [isHovering, setIsHovering] = useState(false)
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
setIsHovering(true)
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
)
setHoverRating(starIndex)
}
const handleMouseLeave = () => {
setIsHovering(false)
setHoverRating(null)
}
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
)
setCurrentRating(starIndex)
setHoverRating(null)
onRatingChange?.(starIndex)
}
const displayRating = hoverRating ?? currentRating
const fullStars = Math.floor(displayRating)
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null
return (
<div
className={cn("flex w-fit items-center gap-2 ")}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current stroke-1" : "fill-transparent",
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn("stroke-1", ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + fullStars + 1,
})
)}
</div>
<span className="text-muted-foreground">
Current Rating: {`${currentRating}`}
</span>
</div>
)
}
interface PartialStarProps {
fillPercentage: number
size: number
className?: string
Icon: React.ReactElement
}
const PartialStar = ({ ...props }: PartialStarProps) => {
const { fillPercentage, size, className, Icon } = props
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
)
}
Here is a way to rendere the rating stars component with a disabled state, preventing any interactions with the stars (or any).
<CommentRatings
rating={3}
totalStars={5}
size={24}
variant="default"
disabled={true}
/>
import React, { useState } from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
};
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number;
totalStars?: number;
size?: number;
fill?: boolean;
Icon?: React.ReactElement;
variant?: keyof typeof ratingVariants;
onRatingChange?: (rating: number) => void;
disabled?: boolean; // Add disabled prop
}
export const CommentRatings = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
onRatingChange,
disabled = false, // Default to false if disabled prop is not provided
...props
}: RatingsProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [currentRating, setCurrentRating] = useState(initialRating);
const [isHovering, setIsHovering] = useState(false);
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
setIsHovering(true);
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
);
setHoverRating(starIndex);
}
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverRating(null);
};
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
);
setCurrentRating(starIndex);
setHoverRating(null);
if (onRatingChange) {
onRatingChange(starIndex);
}
}
};
const displayRating = disabled ? initialRating : hoverRating ?? currentRating;
const fullStars = Math.floor(displayRating);
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null;
return (
<div
className={cn("flex w-fit flex-col gap-2", { 'pointer-events-none': disabled })}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current stroke-1" : "fill-transparent",
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn("stroke-1", ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + fullStars + 1,
})
)}
</div>
<span className="text-xs text-muted-foreground font-semibold">
Current Rating: {`${currentRating}`}
</span>
</div>
);
};
interface PartialStarProps {
fillPercentage: number;
size: number;
className?: string;
Icon: React.ReactElement;
}
const PartialStar = ({ fillPercentage, size, className, Icon }: PartialStarProps) => {
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
);
};
The "showText" state added with initial value of "true" to have the ability to hide rating value text below the stars (icons):
Usage:
<Rating
rating={3.5}
totalStars={5}
size={24}
variant="yellow"
className="h-1"
showText={false}
disabled={true}
/>
Component code (for NextJS):
'use client';
import React, { useState } from 'react';
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
const ratingVariants = {
default: {
star: 'text-foreground',
emptyStar: 'text-muted-foreground',
},
destructive: {
star: 'text-red-500',
emptyStar: 'text-red-200',
},
yellow: {
star: 'text-yellow-500',
emptyStar: 'text-yellow-200',
},
};
interface RatingProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number;
totalStars?: number;
size?: number;
fill?: boolean;
Icon?: React.ReactElement;
variant?: keyof typeof ratingVariants;
onRatingChange?: (rating: number) => void;
showText?: boolean; // Add showText prop
disabled?: boolean;
}
export const Rating = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = 'default',
onRatingChange,
showText = true, // Default to true if disabled prop is not provided
disabled = false, // Default to false if disabled prop is not provided
...props
}: RatingProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [currentRating, setCurrentRating] = useState(initialRating);
const [isHovering, setIsHovering] = useState(false);
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
setIsHovering(true);
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || '0'
);
setHoverRating(starIndex);
}
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverRating(null);
};
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || '0'
);
setCurrentRating(starIndex);
setHoverRating(null);
if (onRatingChange) {
onRatingChange(starIndex);
}
}
};
const displayRating = disabled ? initialRating : hoverRating ?? currentRating;
const fullStars = Math.floor(displayRating);
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null;
return (
<div
className={cn('flex w-fit flex-col gap-2', {
'pointer-events-none': disabled,
})}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? 'fill-current stroke-1' : 'fill-transparent',
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
'data-star-index': i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn('stroke-1', ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
'data-star-index': i + fullStars + 1,
})
)}
</div>
{showText && (
<span className="text-xs text-muted-foreground font-semibold">
Current Rating: {`${currentRating}`}
</span>
)}
</div>
);
};
interface PartialStarProps {
fillPercentage: number;
size: number;
className?: string;
Icon: React.ReactElement;
}
const PartialStar = ({
fillPercentage,
size,
className,
Icon,
}: PartialStarProps) => {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
{React.cloneElement(Icon, {
size,
className: cn('fill-transparent', className),
})}
<div
style={{
position: 'absolute',
top: 0,
overflow: 'hidden',
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn('fill-current', className),
})}
</div>
</div>
);
};
Here is a way to rendere the rating stars component with a disabled state, preventing any interactions with the stars (or any).
<CommentRatings rating={3} totalStars={5} size={24} variant="default" disabled={true} />
import React, { useState } from "react"; import { Star } from "lucide-react"; import { cn } from "@/lib/utils" const ratingVariants = { default: { star: "text-foreground", emptyStar: "text-muted-foreground", }, destructive: { star: "text-red-500", emptyStar: "text-red-200", }, yellow: { star: "text-yellow-500", emptyStar: "text-yellow-200", }, }; interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> { rating: number; totalStars?: number; size?: number; fill?: boolean; Icon?: React.ReactElement; variant?: keyof typeof ratingVariants; onRatingChange?: (rating: number) => void; disabled?: boolean; // Add disabled prop } export const CommentRatings = ({ rating: initialRating, totalStars = 5, size = 20, fill = true, Icon = <Star />, variant = "default", onRatingChange, disabled = false, // Default to false if disabled prop is not provided ...props }: RatingsProps) => { const [hoverRating, setHoverRating] = useState<number | null>(null); const [currentRating, setCurrentRating] = useState(initialRating); const [isHovering, setIsHovering] = useState(false); const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => { if (!disabled) { setIsHovering(true); const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ); setHoverRating(starIndex); } }; const handleMouseLeave = () => { setIsHovering(false); setHoverRating(null); }; const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { if (!disabled) { const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ); setCurrentRating(starIndex); setHoverRating(null); if (onRatingChange) { onRatingChange(starIndex); } } }; const displayRating = disabled ? initialRating : hoverRating ?? currentRating; const fullStars = Math.floor(displayRating); const partialStar = displayRating % 1 > 0 ? ( <PartialStar fillPercentage={displayRating % 1} size={size} className={cn(ratingVariants[variant].star)} Icon={Icon} /> ) : null; return ( <div className={cn("flex w-fit flex-col gap-2", { 'pointer-events-none': disabled })} onMouseLeave={handleMouseLeave} {...props} > <div className="flex items-center" onMouseEnter={handleMouseEnter}> {[...Array(fullStars)].map((_, i) => React.cloneElement(Icon, { key: i, size, className: cn( fill ? "fill-current stroke-1" : "fill-transparent", ratingVariants[variant].star ), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + 1, }) )} {partialStar} {[ ...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))), ].map((_, i) => React.cloneElement(Icon, { key: i + fullStars + 1, size, className: cn("stroke-1", ratingVariants[variant].emptyStar), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + fullStars + 1, }) )} </div> <span className="text-xs text-muted-foreground font-semibold"> Current Rating: {`${currentRating}`} </span> </div> ); }; interface PartialStarProps { fillPercentage: number; size: number; className?: string; Icon: React.ReactElement; } const PartialStar = ({ fillPercentage, size, className, Icon }: PartialStarProps) => { return ( <div style={{ position: "relative", display: "inline-block" }}> {React.cloneElement(Icon, { size, className: cn("fill-transparent", className), })} <div style={{ position: "absolute", top: 0, overflow: "hidden", width: `${fillPercentage * 100}%`, }} > {React.cloneElement(Icon, { size, className: cn("fill-current", className), })} </div> </div> ); };
Thank you. When trying to change the Icon with the props, the size changes entirely and cannot be changed (very small).