All files / src/shared/StarRating GiveStarRating.tsx

87.5% Statements 14/16
63.63% Branches 7/11
66.66% Functions 2/3
86.66% Lines 13/15

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108          24x 24x                     24x             211x 211x 211x 211x       211x   211x                           1055x 1055x     1055x                                                       1055x                                                        
import { Box } from "@mui/material";
import { StarIcon } from "@phosphor-icons/react";
import { useAppTheme } from "@theme/v2/Provider";
import { GiveStarRatingProps } from "./GiveStarRating.types";
 
const MAX_STARS = 5;
const STAR_GAP = 4;
 
/**
 * The star row used by every review surface — the side panel's summary and
 * cards, and the Reviews tab's summary, filter and rows.
 *
 * Fractional values matter: the summary shows an average, so 4.3 has to draw as
 * four stars and a third of a fifth rather than snapping to a whole one. Each
 * star is an outline glyph with a filled copy clipped to its own share of the
 * value on top, which keeps the two glyphs pixel-aligned at any size.
 */
const GiveStarRating = ({
  value,
  size = 16,
  onChange,
  ariaLabel,
  sx,
}: GiveStarRatingProps) => {
  const { palette } = useAppTheme();
  const filled = palette.primitive?.blue?.["70"];
  const empty = palette.primitive?.neutral?.["40"];
  const isInteractive = Boolean(onChange);
 
  // Clamp before drawing: a value outside 0-5 would otherwise produce a star
  // with a negative or >100% clip, and the API is not the only caller.
  const safeValue = Math.min(Math.max(value, 0), MAX_STARS);
 
  return (
    <Box
      role={isInteractive ? "radiogroup" : "img"}
      aria-label={ariaLabel ?? `${safeValue} out of ${MAX_STARS} stars`}
      sx={{
        display: "inline-flex",
        alignItems: "center",
        gap: `${STAR_GAP}px`,
        ...sx,
      }}
    >
      {Array.from({ length: MAX_STARS }, (_, index) => {
        // How much of THIS star is filled: 1 for a whole star below the value,
        // 0 above it, and the remainder for the one the value falls inside.
        const fillRatio = Math.min(Math.max(safeValue - index, 0), 1);
        const starValue = index + 1;
 
        const star = (
          <Box
            key={starValue}
            sx={{
              position: "relative",
              width: size,
              height: size,
              lineHeight: 0,
              flexShrink: 0,
            }}
          >
            <StarIcon size={size} weight="regular" color={empty} />
            {fillRatio > 0 && (
              <Box
                aria-hidden
                sx={{
                  position: "absolute",
                  inset: 0,
                  overflow: "hidden",
                  width: `${fillRatio * 100}%`,
                  lineHeight: 0,
                }}
              >
                <StarIcon size={size} weight="fill" color={filled} />
              </Box>
            )}
          </Box>
        );
 
        Eif (!isInteractive) return star;
 
        return (
          <Box
            key={starValue}
            component="button"
            type="button"
            role="radio"
            aria-checked={safeValue === starValue}
            aria-label={`${starValue} ${starValue === 1 ? "star" : "stars"}`}
            onClick={() => onChange?.(starValue)}
            sx={{
              padding: 0,
              border: "none",
              background: "none",
              cursor: "pointer",
              lineHeight: 0,
            }}
          >
            {star}
          </Box>
        );
      })}
    </Box>
  );
};
 
export default GiveStarRating;