Rating Stars
v0.0.0A read-only star rating. Fractional values are drawn with a clipped fill rather than rounded to a half, so 4.3 and 4.7 look different. The stars are decorative — the value is announced once through an `aria-label`.
registry/acme/atoms/rating-stars · depends on @behivetech/get-class-name
rating-stars.tsx
1import { type HTMLAttributes } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./rating-stars.module.scss";45export type RatingStarsSize = "sm" | "md" | "lg";67export interface RatingStarsProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {8 /** The rating to show, from 0 to `max`; fractions render as partially filled stars */9 value: number;10 /** Number of stars; defaults to 5 */11 max?: number;12 /** Optional review count shown after the stars, e.g. "(128)" */13 count?: number;14 /** Controls the star size; defaults to md */15 size?: RatingStarsSize;16 /** Additional class names to merge with the component root element */17 className?: string;18}1920/**21 * A read-only star rating.22 *23 * Fractional values are drawn with a clipped fill rather than rounded to a24 * half, so 4.3 and 4.7 look different. The stars are decorative; the value is25 * announced once through the root's `aria-label`.26 */27export const RatingStars = ({ value, max = 5, count, size = "md", className, ...rest }: RatingStarsProps) => {28 const [rootClass, getChildClass] = getClassName({29 className,30 rootClass: "ACME__RatingStars",31 modifiers: { [size]: true },32 styles,33 });3435 const clamped = Math.min(Math.max(value, 0), max);36 const label = `${clamped.toFixed(1).replace(/\.0$/, "")} out of ${max} stars${count !== undefined ? `, ${count} reviews` : ""}`;3738 return (39 <span {...rest} className={rootClass} role="img" aria-label={label}>40 <span className={getChildClass("stars")} aria-hidden>41 {Array.from({ length: max }, (_, index) => {42 const fill = Math.min(Math.max(clamped - index, 0), 1);43 return (44 <span45 key={index}46 className={getChildClass("star")}47 // The one value CSS cannot know: how much of THIS star is filled.48 style={{ "--acme-star-fill": `${fill * 100}%` } as React.CSSProperties}49 >50 ★51 </span>52 );53 })}54 </span>55 {count !== undefined && <span className={getChildClass("count")}>({count.toLocaleString()})</span>}56 </span>57 );58};
rating-stars.module.scss
1.ACME__RatingStars {2 display: inline-flex;3 align-items: center;4 gap: 0.4em;5 font-family: var(--md-sys-typescale-body-font);6 color: var(--md-sys-color-on-surface-variant);78 &--sm {9 font-size: 0.875rem;10 }1112 &--md {13 font-size: 1.125rem;14 }1516 &--lg {17 font-size: 1.5rem;18 }1920 &__stars {21 display: inline-flex;22 gap: 0.1em;23 line-height: 1;24 }2526 &__star {27 // The outline star, with the filled star painted over the left28 // `--acme-star-fill` percent of it. One glyph, two colors, no images.29 position: relative;30 color: var(--md-sys-color-outline-variant);3132 &::before {33 content: "★";34 position: absolute;35 inset: 0;36 width: var(--acme-star-fill, 0%);37 overflow: hidden;38 color: var(--md-sys-color-tertiary);39 }40 }4142 &__count {43 font-size: 0.8em;44 }45}
index.ts
1export { RatingStars } from './rating-stars.js';2export type { RatingStarsProps } from './rating-stars.js';
rating-stars.composition.tsx
1import { RatingStars } from "./rating-stars.js";23export const BasicRatingStars = () => <RatingStars value={4} />;45export const FractionalRatingStars = () => <RatingStars value={4.3} count={128} />;67export const TenStarRating = () => <RatingStars value={7.5} max={10} />;89export const RatingStarsSizes = () => (10 <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>11 <RatingStars size="sm" value={3.5} count={12} />12 <RatingStars size="md" value={3.5} count={12} />13 <RatingStars size="lg" value={3.5} count={12} />14 </div>15);
rating-stars.spec.tsx
1import { render, screen } from "@testing-library/react";2import { RatingStars } from "./rating-stars.js";34describe("RatingStars", () => {5 it("announces the rating once, as text, rather than star by star", () => {6 render(<RatingStars value={4.3} count={128} />);7 expect(screen.getByRole("img", { name: "4.3 out of 5 stars, 128 reviews" })).toBeTruthy();8 });910 // Each star carries its fill on a style variable; that's the stable handle,11 // since CSS-module class names are hashed under test.12 const starsOf = (container: HTMLElement) =>13 container.querySelectorAll<HTMLElement>('[style*="--acme-star-fill"]');1415 it("renders one star per point of max", () => {16 const { container } = render(<RatingStars value={7.5} max={10} />);17 expect(starsOf(container)).toHaveLength(10);18 });1920 it("fills a fractional star by its fraction", () => {21 const { container } = render(<RatingStars value={2.25} />);22 const stars = starsOf(container);23 expect(stars[1]?.style.getPropertyValue("--acme-star-fill")).toBe("100%");24 expect(stars[2]?.style.getPropertyValue("--acme-star-fill")).toBe("25%");25 expect(stars[3]?.style.getPropertyValue("--acme-star-fill")).toBe("0%");26 });2728 it("clamps out-of-range values", () => {29 render(<RatingStars value={9} />);30 expect(screen.getByRole("img", { name: "5 out of 5 stars" })).toBeTruthy();31 });32});