react-ratings-star
Version: 
A fully customizable, interactive, and accessible rating component for React.
270 lines (255 loc) • 12.1 kB
JavaScript
function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); }
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
function _arrayWithHoles(r) { if (Array.isArray(r)) return r; }
import PropTypes from "prop-types";
import React, { useRef, useState, useId } from "react";
// The primary Rating component, packed with useful features.
var Rating = function Rating(_ref) {
  var _ref2, _Number;
  var _ref$value = _ref.value,
    value = _ref$value === void 0 ? 0 : _ref$value,
    _ref$max = _ref.max,
    max = _ref$max === void 0 ? 5 : _ref$max,
    _ref$onRatingChange = _ref.onRatingChange,
    onRatingChange = _ref$onRatingChange === void 0 ? function () {} : _ref$onRatingChange,
    _ref$readOnly = _ref.readOnly,
    readOnly = _ref$readOnly === void 0 ? false : _ref$readOnly,
    _ref$size = _ref.size,
    size = _ref$size === void 0 ? 24 : _ref$size,
    _ref$fullColor = _ref.fullColor,
    fullColor = _ref$fullColor === void 0 ? "#FFD700" : _ref$fullColor,
    _ref$emptyColor = _ref.emptyColor,
    emptyColor = _ref$emptyColor === void 0 ? "#E0E0E0" : _ref$emptyColor,
    _ref$customIcon = _ref.customIcon,
    customIcon = _ref$customIcon === void 0 ? null : _ref$customIcon,
    _ref$tooltips = _ref.tooltips,
    tooltips = _ref$tooltips === void 0 ? [] : _ref$tooltips,
    _ref$className = _ref.className,
    className = _ref$className === void 0 ? "" : _ref$className,
    _ref$style = _ref.style,
    style = _ref$style === void 0 ? {} : _ref$style,
    _ref$rounding = _ref.rounding,
    rounding = _ref$rounding === void 0 ? "ceil" : _ref$rounding;
  var _useState = useState(null),
    _useState2 = _slicedToArray(_useState, 2),
    hoverValue = _useState2[0],
    setHoverValue = _useState2[1];
  var _useState3 = useState(false),
    _useState4 = _slicedToArray(_useState3, 2),
    isTouching = _useState4[0],
    setIsTouching = _useState4[1];
  var ratingContainerRef = useRef(null);
  var uniqueId = useId();
  // Calculate rating based on mouse/touch position
  var calculateRatingFromClientX = function calculateRatingFromClientX(clientX) {
    if (!ratingContainerRef.current) return 0;
    var _ratingContainerRef$c = ratingContainerRef.current.getBoundingClientRect(),
      width = _ratingContainerRef$c.width,
      left = _ratingContainerRef$c.left;
    var percent = (clientX - left) / width;
    percent = Math.max(0, Math.min(1, percent));
    var raw = percent * max;
    var rating;
    if (rounding === "nearest") {
      rating = Math.round(raw * 2) / 2;
    } else if (rounding === "floor") {
      rating = Math.floor(raw * 2) / 2;
    } else {
      rating = Math.ceil(raw * 2) / 2;
    }
    return Math.max(0, Math.min(max, +rating.toFixed(2)));
  };
  // --- Event handlers ---
  //* Mouse handlers for the smooth hover effect
  var handleMouseMove = function handleMouseMove(e) {
    if (readOnly || isTouching) return;
    setHoverValue(calculateRatingFromClientX(e.clientX));
  };
  var handleMouseLeave = function handleMouseLeave() {
    if (readOnly || isTouching) return;
    setHoverValue(null);
  };
  var handleClick = function handleClick(e) {
    if (readOnly) return;
    e.stopPropagation();
    onRatingChange(calculateRatingFromClientX(e.clientX));
  };
  //* Handlers for a smooth mobile touch experience.
  var handleTouchStart = function handleTouchStart(e) {
    var _e$touches;
    if (readOnly) return;
    setIsTouching(true);
    if ((_e$touches = e.touches) !== null && _e$touches !== void 0 && _e$touches[0]) {
      setHoverValue(calculateRatingFromClientX(e.touches[0].clientX));
    }
  };
  var handleTouchMove = function handleTouchMove(e) {
    var _e$touches2;
    if (readOnly) return;
    if ((_e$touches2 = e.touches) !== null && _e$touches2 !== void 0 && _e$touches2[0]) {
      setHoverValue(calculateRatingFromClientX(e.touches[0].clientX));
    }
  };
  var handleTouchEnd = function handleTouchEnd(e) {
    if (readOnly) return;
    setIsTouching(false);
    if (hoverValue !== null) onRatingChange(hoverValue);
    setHoverValue(null);
  };
  //* Handlers for keyboard navigation for accessibility.
  var handleKeyDown = function handleKeyDown(e) {
    if (readOnly) return;
    var newValue = value;
    var step = 0.5;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        newValue = Math.min(max, +(value + step).toFixed(2));
        break;
      case "ArrowLeft":
      case "ArrowDown":
        newValue = Math.max(0, +(value - step).toFixed(2));
        break;
      case "Home":
        newValue = 0;
        break;
      case "End":
        newValue = max;
        break;
      case "PageUp":
        newValue = Math.min(max, +(value + 1).toFixed(2));
        break;
      case "PageDown":
        newValue = Math.max(0, +(value - 1).toFixed(2));
        break;
      case "Enter":
      case " ":
        break;
      default:
        return;
    }
    e.preventDefault();
    if (newValue !== value) onRatingChange(newValue);
  };
  //* --- Value & Tooltip Calculation ---
  var displayValue = (_ref2 = hoverValue !== null && hoverValue !== void 0 ? hoverValue : Number(value)) !== null && _ref2 !== void 0 ? _ref2 : 0;
  var formattedValue = displayValue % 1 === 0 ? displayValue : displayValue.toFixed(1);
  var numericTooltip = "".concat(formattedValue, " out of ").concat(max);
  var descriptiveIndex = Math.ceil(displayValue) - 1;
  var descriptiveText = tooltips[descriptiveIndex];
  var finalTooltip = descriptiveText ? "".concat(formattedValue, " - ").concat(descriptiveText) : numericTooltip;
  // For screen readers
  var ariaValueText = finalTooltip;
  // Default star path (used if no custom icon is provided)
  var DefaultStar = function DefaultStar() {
    return /*#__PURE__*/React.createElement("path", {
      d: "M12 .587l3.668 7.429 8.207 1.192-5.938 5.787 1.401 8.17L12 18.897l-7.338 3.856 1.401-8.17L.125 9.208l8.207-1.192L12 .587z"
    });
  };
  var IconComponent = customIcon;
  return /*#__PURE__*/React.createElement("div", {
    ref: ratingContainerRef,
    onMouseMove: handleMouseMove,
    onMouseLeave: handleMouseLeave,
    onClick: handleClick,
    onTouchStart: handleTouchStart,
    onTouchMove: handleTouchMove,
    onTouchEnd: handleTouchEnd,
    onKeyDown: handleKeyDown,
    role: "slider",
    "aria-valuenow": (_Number = Number(value)) !== null && _Number !== void 0 ? _Number : 0,
    "aria-valuemin": 0,
    "aria-valuemax": max,
    "aria-valuetext": ariaValueText,
    "aria-label": "Rating",
    "aria-readonly": readOnly,
    tabIndex: readOnly ? -1 : 0,
    style: _objectSpread({
      display: "inline-flex",
      alignItems: "center",
      cursor: readOnly ? "default" : "pointer",
      userSelect: "none"
    }, style),
    className: className,
    title: finalTooltip
  }, Array.from({
    length: max
  }, function (_, i) {
    var iconValue = i + 1;
    var effectiveDisplayValue = Math.min(displayValue, max);
    var fillPercentage;
    if (effectiveDisplayValue >= iconValue) {
      fillPercentage = 100;
    } else if (effectiveDisplayValue > i) {
      fillPercentage = (effectiveDisplayValue - i) * 100;
    } else {
      fillPercentage = 0;
    }
    // FIX: Use the stable uniqueId to create flicker-free SVG IDs.
    var gradientId = "grad-".concat(uniqueId, "-").concat(iconValue);
    var maskId = "mask-".concat(uniqueId, "-").concat(iconValue);
    return /*#__PURE__*/React.createElement("div", {
      key: iconValue,
      style: {
        width: size,
        height: size
      },
      "aria-hidden": "true"
    }, /*#__PURE__*/React.createElement("svg", {
      height: size,
      width: size,
      viewBox: "0 0 24 24"
    }, /*#__PURE__*/React.createElement("defs", null, /*#__PURE__*/React.createElement("linearGradient", {
      id: gradientId
    }, /*#__PURE__*/React.createElement("stop", {
      offset: "0%",
      stopColor: fullColor
    }), /*#__PURE__*/React.createElement("stop", {
      offset: "".concat(fillPercentage, "%"),
      stopColor: fullColor
    }), /*#__PURE__*/React.createElement("stop", {
      offset: "".concat(fillPercentage, "%"),
      stopColor: emptyColor
    }), /*#__PURE__*/React.createElement("stop", {
      offset: "100%",
      stopColor: emptyColor
    })), /*#__PURE__*/React.createElement("mask", {
      id: maskId
    }, /*#__PURE__*/React.createElement("g", {
      fill: "white"
    }, IconComponent ? /*#__PURE__*/React.createElement(IconComponent, {
      size: size
    }) : /*#__PURE__*/React.createElement(DefaultStar, null)))), /*#__PURE__*/React.createElement("rect", {
      x: "0",
      y: "0",
      width: "100%",
      height: "100%",
      fill: "url(#".concat(gradientId, ")"),
      mask: "url(#".concat(maskId, ")")
    })));
  }));
};
Rating.propTypes = {
  value: PropTypes.number.isRequired,
  max: PropTypes.number,
  onRatingChange: PropTypes.func,
  readOnly: PropTypes.bool,
  size: PropTypes.number,
  fullColor: PropTypes.string,
  emptyColor: PropTypes.string,
  customIcon: PropTypes.elementType,
  tooltips: PropTypes.arrayOf(PropTypes.string),
  className: PropTypes.string,
  style: PropTypes.object,
  rounding: PropTypes.oneOf(["ceil", "floor", "nearest"])
};
export default Rating;