Created
September 1, 2025 14:14
-
-
Save JPaulDuncan/2df1068568c19df46fa5d28af705a30e to your computer and use it in GitHub Desktop.
Express+Typescript: Accessible, reusable 1–5 interactive star rating component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useMemo, useRef, useState } from "react"; | |
| import { Star } from "lucide-react"; | |
| export type InteractiveStarsProps = { | |
| /** Current rating (0–max). Controlled. */ | |
| value: number; | |
| /** Called when the user selects a rating. */ | |
| onChange: (val: number) => void | Promise<void>; | |
| /** Number of stars to render (default 5). */ | |
| max?: number; | |
| /** Icon size in px (default 16). */ | |
| size?: number; | |
| /** Disable interaction but keep visual (default false). */ | |
| disabled?: boolean; | |
| /** Read-only: same as disabled, but aria reflects readOnly control. */ | |
| readOnly?: boolean; | |
| /** Allow clearing by clicking the same value again (default true). */ | |
| allowClear?: boolean; | |
| /** Label announced to screen readers (default "Rate"). */ | |
| ariaLabel?: string; | |
| /** Optional className on the container. */ | |
| className?: string; | |
| }; | |
| export function InteractiveStars({ | |
| value, | |
| onChange, | |
| max = 5, | |
| size = 16, | |
| disabled = false, | |
| readOnly = false, | |
| allowClear = true, | |
| ariaLabel = "Rate", | |
| className, | |
| }: InteractiveStarsProps){ | |
| const [hover, setHover] = useState<number | null>(null); | |
| const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]); | |
| const isInteractive = !disabled && !readOnly; | |
| const stars = useMemo(() => Array.from({ length: max }, (_, i) => i + 1), [max]); | |
| const display = hover ?? value; | |
| function commit(next: number){ | |
| if (!isInteractive) return; | |
| if (allowClear && next === value) { | |
| onChange(0); | |
| } else { | |
| onChange(next); | |
| } | |
| } | |
| function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>){ | |
| if (!isInteractive) return; | |
| const key = e.key; | |
| if (key === 'ArrowRight' || key === 'ArrowUp'){ | |
| e.preventDefault(); | |
| const next = Math.min(max, (value || 0) + 1); | |
| commit(next); | |
| buttonsRef.current[next-1]?.focus(); | |
| } else if (key === 'ArrowLeft' || key === 'ArrowDown'){ | |
| e.preventDefault(); | |
| const next = Math.max(0, (value || 0) - 1); | |
| commit(next); | |
| if (next === 0) buttonsRef.current[0]?.focus(); else buttonsRef.current[next-1]?.focus(); | |
| } else if (key === 'Home'){ | |
| e.preventDefault(); | |
| commit(0); | |
| buttonsRef.current[0]?.focus(); | |
| } else if (key === 'End'){ | |
| e.preventDefault(); | |
| commit(max); | |
| buttonsRef.current[max-1]?.focus(); | |
| } else if (key === '0' || key === 'Backspace' || key === 'Delete'){ | |
| e.preventDefault(); | |
| commit(0); | |
| buttonsRef.current[0]?.focus(); | |
| } | |
| } | |
| return ( | |
| <div | |
| className={className} | |
| role="radiogroup" | |
| aria-label={ariaLabel} | |
| aria-readonly={readOnly || undefined} | |
| onKeyDown={handleKeyDown} | |
| > | |
| <div className="flex items-center"> | |
| {stars.map((n, idx) => { | |
| const active = n <= (display || 0); | |
| return ( | |
| <button | |
| key={n} | |
| type="button" | |
| ref={(el)=> (buttonsRef.current[idx] = el)} | |
| role="radio" | |
| aria-checked={value === n} | |
| aria-label={`${n} star${n>1 ? 's' : ''}`} | |
| disabled={!isInteractive} | |
| className={`p-0.5 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 ${!isInteractive ? 'cursor-default opacity-75' : ''}`} | |
| onMouseEnter={()=> isInteractive && setHover(n)} | |
| onMouseLeave={()=> isInteractive && setHover(null)} | |
| onFocus={()=> isInteractive && setHover(n)} | |
| onBlur={()=> isInteractive && setHover(null)} | |
| onClick={()=> commit(n)} | |
| > | |
| <Star | |
| size={size} | |
| className={active ? 'text-yellow-500' : 'text-zinc-400'} | |
| fill={active ? 'currentColor' : 'none'} | |
| /> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /* USAGE (example) | |
| -------------------------------------------------- | |
| import { InteractiveStars } from "@/ui/shared/InteractiveStars"; | |
| function Example(){ | |
| const [rating, setRating] = React.useState(0); | |
| return ( | |
| <InteractiveStars value={rating} onChange={setRating} /> | |
| ); | |
| } | |
| // To wire into LongboxItemCard: | |
| // 1) import { InteractiveStars } from "@/ui/shared/InteractiveStars"; | |
| // 2) Replace <RatingStars .../> with: | |
| // <InteractiveStars value={myRating || Math.round(avgRating)} onChange={handleRate} size={14} /> | |
| // 3) Persist via onRate handler passed from parent (see earlier notes). | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment