UNPKG

20.1 kBJavaScriptView Raw
1'use client';
2
3import * as React from 'react';
4import PropTypes from 'prop-types';
5import clsx from 'clsx';
6import clamp from '@mui/utils/clamp';
7import visuallyHidden from '@mui/utils/visuallyHidden';
8import chainPropTypes from '@mui/utils/chainPropTypes';
9import composeClasses from '@mui/utils/composeClasses';
10import { useRtl } from '@mui/system/RtlProvider';
11import isFocusVisible from '@mui/utils/isFocusVisible';
12import { capitalize, useForkRef, useControlled, unstable_useId as useId } from "../utils/index.js";
13import Star from "../internal/svg-icons/Star.js";
14import StarBorder from "../internal/svg-icons/StarBorder.js";
15import { styled } from "../zero-styled/index.js";
16import memoTheme from "../utils/memoTheme.js";
17import { useDefaultProps } from "../DefaultPropsProvider/index.js";
18import slotShouldForwardProp from "../styles/slotShouldForwardProp.js";
19import ratingClasses, { getRatingUtilityClass } from "./ratingClasses.js";
20import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21function getDecimalPrecision(num) {
22 const decimalPart = num.toString().split('.')[1];
23 return decimalPart ? decimalPart.length : 0;
24}
25function roundValueToPrecision(value, precision) {
26 if (value == null) {
27 return value;
28 }
29 const nearest = Math.round(value / precision) * precision;
30 return Number(nearest.toFixed(getDecimalPrecision(precision)));
31}
32const useUtilityClasses = ownerState => {
33 const {
34 classes,
35 size,
36 readOnly,
37 disabled,
38 emptyValueFocused,
39 focusVisible
40 } = ownerState;
41 const slots = {
42 root: ['root', `size${capitalize(size)}`, disabled && 'disabled', focusVisible && 'focusVisible', readOnly && 'readOnly'],
43 label: ['label', 'pristine'],
44 labelEmptyValue: [emptyValueFocused && 'labelEmptyValueActive'],
45 icon: ['icon'],
46 iconEmpty: ['iconEmpty'],
47 iconFilled: ['iconFilled'],
48 iconHover: ['iconHover'],
49 iconFocus: ['iconFocus'],
50 iconActive: ['iconActive'],
51 decimal: ['decimal'],
52 visuallyHidden: ['visuallyHidden']
53 };
54 return composeClasses(slots, getRatingUtilityClass, classes);
55};
56const RatingRoot = styled('span', {
57 name: 'MuiRating',
58 slot: 'Root',
59 overridesResolver: (props, styles) => {
60 const {
61 ownerState
62 } = props;
63 return [{
64 [`& .${ratingClasses.visuallyHidden}`]: styles.visuallyHidden
65 }, styles.root, styles[`size${capitalize(ownerState.size)}`], ownerState.readOnly && styles.readOnly];
66 }
67})(memoTheme(({
68 theme
69}) => ({
70 display: 'inline-flex',
71 // Required to position the pristine input absolutely
72 position: 'relative',
73 fontSize: theme.typography.pxToRem(24),
74 color: '#faaf00',
75 cursor: 'pointer',
76 textAlign: 'left',
77 width: 'min-content',
78 WebkitTapHighlightColor: 'transparent',
79 [`&.${ratingClasses.disabled}`]: {
80 opacity: (theme.vars || theme).palette.action.disabledOpacity,
81 pointerEvents: 'none'
82 },
83 [`&.${ratingClasses.focusVisible} .${ratingClasses.iconActive}`]: {
84 outline: '1px solid #999'
85 },
86 [`& .${ratingClasses.visuallyHidden}`]: visuallyHidden,
87 variants: [{
88 props: {
89 size: 'small'
90 },
91 style: {
92 fontSize: theme.typography.pxToRem(18)
93 }
94 }, {
95 props: {
96 size: 'large'
97 },
98 style: {
99 fontSize: theme.typography.pxToRem(30)
100 }
101 }, {
102 // TODO v6: use the .Mui-readOnly global state class
103 props: ({
104 ownerState
105 }) => ownerState.readOnly,
106 style: {
107 pointerEvents: 'none'
108 }
109 }]
110})));
111const RatingLabel = styled('label', {
112 name: 'MuiRating',
113 slot: 'Label',
114 overridesResolver: ({
115 ownerState
116 }, styles) => [styles.label, ownerState.emptyValueFocused && styles.labelEmptyValueActive]
117})({
118 cursor: 'inherit',
119 variants: [{
120 props: ({
121 ownerState
122 }) => ownerState.emptyValueFocused,
123 style: {
124 top: 0,
125 bottom: 0,
126 position: 'absolute',
127 outline: '1px solid #999',
128 width: '100%'
129 }
130 }]
131});
132const RatingIcon = styled('span', {
133 name: 'MuiRating',
134 slot: 'Icon',
135 overridesResolver: (props, styles) => {
136 const {
137 ownerState
138 } = props;
139 return [styles.icon, ownerState.iconEmpty && styles.iconEmpty, ownerState.iconFilled && styles.iconFilled, ownerState.iconHover && styles.iconHover, ownerState.iconFocus && styles.iconFocus, ownerState.iconActive && styles.iconActive];
140 }
141})(memoTheme(({
142 theme
143}) => ({
144 // Fit wrapper to actual icon size.
145 display: 'flex',
146 transition: theme.transitions.create('transform', {
147 duration: theme.transitions.duration.shortest
148 }),
149 // Fix mouseLeave issue.
150 // https://github.com/facebook/react/issues/4492
151 pointerEvents: 'none',
152 variants: [{
153 props: ({
154 ownerState
155 }) => ownerState.iconActive,
156 style: {
157 transform: 'scale(1.2)'
158 }
159 }, {
160 props: ({
161 ownerState
162 }) => ownerState.iconEmpty,
163 style: {
164 color: (theme.vars || theme).palette.action.disabled
165 }
166 }]
167})));
168const RatingDecimal = styled('span', {
169 name: 'MuiRating',
170 slot: 'Decimal',
171 shouldForwardProp: prop => slotShouldForwardProp(prop) && prop !== 'iconActive',
172 overridesResolver: (props, styles) => {
173 const {
174 iconActive
175 } = props;
176 return [styles.decimal, iconActive && styles.iconActive];
177 }
178})({
179 position: 'relative',
180 variants: [{
181 props: ({
182 iconActive
183 }) => iconActive,
184 style: {
185 transform: 'scale(1.2)'
186 }
187 }]
188});
189function IconContainer(props) {
190 const {
191 value,
192 ...other
193 } = props;
194 return /*#__PURE__*/_jsx("span", {
195 ...other
196 });
197}
198process.env.NODE_ENV !== "production" ? IconContainer.propTypes = {
199 value: PropTypes.number.isRequired
200} : void 0;
201function RatingItem(props) {
202 const {
203 classes,
204 disabled,
205 emptyIcon,
206 focus,
207 getLabelText,
208 highlightSelectedOnly,
209 hover,
210 icon,
211 IconContainerComponent,
212 isActive,
213 itemValue,
214 labelProps,
215 name,
216 onBlur,
217 onChange,
218 onClick,
219 onFocus,
220 readOnly,
221 ownerState,
222 ratingValue,
223 ratingValueRounded
224 } = props;
225 const isFilled = highlightSelectedOnly ? itemValue === ratingValue : itemValue <= ratingValue;
226 const isHovered = itemValue <= hover;
227 const isFocused = itemValue <= focus;
228 const isChecked = itemValue === ratingValueRounded;
229
230 // "name" ensures unique IDs across different Rating components in React 17,
231 // preventing one component from affecting another. React 18's useId already handles this.
232 // Update to const id = useId(); when React 17 support is dropped.
233 // More details: https://github.com/mui/material-ui/issues/40997
234 const id = `${name}-${useId()}`;
235 const container = /*#__PURE__*/_jsx(RatingIcon, {
236 as: IconContainerComponent,
237 value: itemValue,
238 className: clsx(classes.icon, isFilled ? classes.iconFilled : classes.iconEmpty, isHovered && classes.iconHover, isFocused && classes.iconFocus, isActive && classes.iconActive),
239 ownerState: {
240 ...ownerState,
241 iconEmpty: !isFilled,
242 iconFilled: isFilled,
243 iconHover: isHovered,
244 iconFocus: isFocused,
245 iconActive: isActive
246 },
247 children: emptyIcon && !isFilled ? emptyIcon : icon
248 });
249 if (readOnly) {
250 return /*#__PURE__*/_jsx("span", {
251 ...labelProps,
252 children: container
253 });
254 }
255 return /*#__PURE__*/_jsxs(React.Fragment, {
256 children: [/*#__PURE__*/_jsxs(RatingLabel, {
257 ownerState: {
258 ...ownerState,
259 emptyValueFocused: undefined
260 },
261 htmlFor: id,
262 ...labelProps,
263 children: [container, /*#__PURE__*/_jsx("span", {
264 className: classes.visuallyHidden,
265 children: getLabelText(itemValue)
266 })]
267 }), /*#__PURE__*/_jsx("input", {
268 className: classes.visuallyHidden,
269 onFocus: onFocus,
270 onBlur: onBlur,
271 onChange: onChange,
272 onClick: onClick,
273 disabled: disabled,
274 value: itemValue,
275 id: id,
276 type: "radio",
277 name: name,
278 checked: isChecked
279 })]
280 });
281}
282process.env.NODE_ENV !== "production" ? RatingItem.propTypes = {
283 classes: PropTypes.object.isRequired,
284 disabled: PropTypes.bool.isRequired,
285 emptyIcon: PropTypes.node,
286 focus: PropTypes.number.isRequired,
287 getLabelText: PropTypes.func.isRequired,
288 highlightSelectedOnly: PropTypes.bool.isRequired,
289 hover: PropTypes.number.isRequired,
290 icon: PropTypes.node,
291 IconContainerComponent: PropTypes.elementType.isRequired,
292 isActive: PropTypes.bool.isRequired,
293 itemValue: PropTypes.number.isRequired,
294 labelProps: PropTypes.object,
295 name: PropTypes.string,
296 onBlur: PropTypes.func.isRequired,
297 onChange: PropTypes.func.isRequired,
298 onClick: PropTypes.func.isRequired,
299 onFocus: PropTypes.func.isRequired,
300 ownerState: PropTypes.object.isRequired,
301 ratingValue: PropTypes.number,
302 ratingValueRounded: PropTypes.number,
303 readOnly: PropTypes.bool.isRequired
304} : void 0;
305const defaultIcon = /*#__PURE__*/_jsx(Star, {
306 fontSize: "inherit"
307});
308const defaultEmptyIcon = /*#__PURE__*/_jsx(StarBorder, {
309 fontSize: "inherit"
310});
311function defaultLabelText(value) {
312 return `${value || '0'} Star${value !== 1 ? 's' : ''}`;
313}
314const Rating = /*#__PURE__*/React.forwardRef(function Rating(inProps, ref) {
315 const props = useDefaultProps({
316 name: 'MuiRating',
317 props: inProps
318 });
319 const {
320 className,
321 defaultValue = null,
322 disabled = false,
323 emptyIcon = defaultEmptyIcon,
324 emptyLabelText = 'Empty',
325 getLabelText = defaultLabelText,
326 highlightSelectedOnly = false,
327 icon = defaultIcon,
328 IconContainerComponent = IconContainer,
329 max = 5,
330 name: nameProp,
331 onChange,
332 onChangeActive,
333 onMouseLeave,
334 onMouseMove,
335 precision = 1,
336 readOnly = false,
337 size = 'medium',
338 value: valueProp,
339 ...other
340 } = props;
341 const name = useId(nameProp);
342 const [valueDerived, setValueState] = useControlled({
343 controlled: valueProp,
344 default: defaultValue,
345 name: 'Rating'
346 });
347 const valueRounded = roundValueToPrecision(valueDerived, precision);
348 const isRtl = useRtl();
349 const [{
350 hover,
351 focus
352 }, setState] = React.useState({
353 hover: -1,
354 focus: -1
355 });
356 let value = valueRounded;
357 if (hover !== -1) {
358 value = hover;
359 }
360 if (focus !== -1) {
361 value = focus;
362 }
363 const [focusVisible, setFocusVisible] = React.useState(false);
364 const rootRef = React.useRef();
365 const handleRef = useForkRef(rootRef, ref);
366 const handleMouseMove = event => {
367 if (onMouseMove) {
368 onMouseMove(event);
369 }
370 const rootNode = rootRef.current;
371 const {
372 right,
373 left,
374 width: containerWidth
375 } = rootNode.getBoundingClientRect();
376 let percent;
377 if (isRtl) {
378 percent = (right - event.clientX) / containerWidth;
379 } else {
380 percent = (event.clientX - left) / containerWidth;
381 }
382 let newHover = roundValueToPrecision(max * percent + precision / 2, precision);
383 newHover = clamp(newHover, precision, max);
384 setState(prev => prev.hover === newHover && prev.focus === newHover ? prev : {
385 hover: newHover,
386 focus: newHover
387 });
388 setFocusVisible(false);
389 if (onChangeActive && hover !== newHover) {
390 onChangeActive(event, newHover);
391 }
392 };
393 const handleMouseLeave = event => {
394 if (onMouseLeave) {
395 onMouseLeave(event);
396 }
397 const newHover = -1;
398 setState({
399 hover: newHover,
400 focus: newHover
401 });
402 if (onChangeActive && hover !== newHover) {
403 onChangeActive(event, newHover);
404 }
405 };
406 const handleChange = event => {
407 let newValue = event.target.value === '' ? null : parseFloat(event.target.value);
408
409 // Give mouse priority over keyboard
410 // Fix https://github.com/mui/material-ui/issues/22827
411 if (hover !== -1) {
412 newValue = hover;
413 }
414 setValueState(newValue);
415 if (onChange) {
416 onChange(event, newValue);
417 }
418 };
419 const handleClear = event => {
420 // Ignore keyboard events
421 // https://github.com/facebook/react/issues/7407
422 if (event.clientX === 0 && event.clientY === 0) {
423 return;
424 }
425 setState({
426 hover: -1,
427 focus: -1
428 });
429 setValueState(null);
430 if (onChange && parseFloat(event.target.value) === valueRounded) {
431 onChange(event, null);
432 }
433 };
434 const handleFocus = event => {
435 if (isFocusVisible(event.target)) {
436 setFocusVisible(true);
437 }
438 const newFocus = parseFloat(event.target.value);
439 setState(prev => ({
440 hover: prev.hover,
441 focus: newFocus
442 }));
443 };
444 const handleBlur = event => {
445 if (hover !== -1) {
446 return;
447 }
448 if (!isFocusVisible(event.target)) {
449 setFocusVisible(false);
450 }
451 const newFocus = -1;
452 setState(prev => ({
453 hover: prev.hover,
454 focus: newFocus
455 }));
456 };
457 const [emptyValueFocused, setEmptyValueFocused] = React.useState(false);
458 const ownerState = {
459 ...props,
460 defaultValue,
461 disabled,
462 emptyIcon,
463 emptyLabelText,
464 emptyValueFocused,
465 focusVisible,
466 getLabelText,
467 icon,
468 IconContainerComponent,
469 max,
470 precision,
471 readOnly,
472 size
473 };
474 const classes = useUtilityClasses(ownerState);
475 return /*#__PURE__*/_jsxs(RatingRoot, {
476 ref: handleRef,
477 onMouseMove: handleMouseMove,
478 onMouseLeave: handleMouseLeave,
479 className: clsx(classes.root, className, readOnly && 'MuiRating-readOnly'),
480 ownerState: ownerState,
481 role: readOnly ? 'img' : null,
482 "aria-label": readOnly ? getLabelText(value) : null,
483 ...other,
484 children: [Array.from(new Array(max)).map((_, index) => {
485 const itemValue = index + 1;
486 const ratingItemProps = {
487 classes,
488 disabled,
489 emptyIcon,
490 focus,
491 getLabelText,
492 highlightSelectedOnly,
493 hover,
494 icon,
495 IconContainerComponent,
496 name,
497 onBlur: handleBlur,
498 onChange: handleChange,
499 onClick: handleClear,
500 onFocus: handleFocus,
501 ratingValue: value,
502 ratingValueRounded: valueRounded,
503 readOnly,
504 ownerState
505 };
506 const isActive = itemValue === Math.ceil(value) && (hover !== -1 || focus !== -1);
507 if (precision < 1) {
508 const items = Array.from(new Array(1 / precision));
509 return /*#__PURE__*/_jsx(RatingDecimal, {
510 className: clsx(classes.decimal, isActive && classes.iconActive),
511 ownerState: ownerState,
512 iconActive: isActive,
513 children: items.map(($, indexDecimal) => {
514 const itemDecimalValue = roundValueToPrecision(itemValue - 1 + (indexDecimal + 1) * precision, precision);
515 return /*#__PURE__*/_jsx(RatingItem, {
516 ...ratingItemProps,
517 // The icon is already displayed as active
518 isActive: false,
519 itemValue: itemDecimalValue,
520 labelProps: {
521 style: items.length - 1 === indexDecimal ? {} : {
522 width: itemDecimalValue === value ? `${(indexDecimal + 1) * precision * 100}%` : '0%',
523 overflow: 'hidden',
524 position: 'absolute'
525 }
526 }
527 }, itemDecimalValue);
528 })
529 }, itemValue);
530 }
531 return /*#__PURE__*/_jsx(RatingItem, {
532 ...ratingItemProps,
533 isActive: isActive,
534 itemValue: itemValue
535 }, itemValue);
536 }), !readOnly && !disabled && /*#__PURE__*/_jsxs(RatingLabel, {
537 className: clsx(classes.label, classes.labelEmptyValue),
538 ownerState: ownerState,
539 children: [/*#__PURE__*/_jsx("input", {
540 className: classes.visuallyHidden,
541 value: "",
542 id: `${name}-empty`,
543 type: "radio",
544 name: name,
545 checked: valueRounded == null,
546 onFocus: () => setEmptyValueFocused(true),
547 onBlur: () => setEmptyValueFocused(false),
548 onChange: handleChange
549 }), /*#__PURE__*/_jsx("span", {
550 className: classes.visuallyHidden,
551 children: emptyLabelText
552 })]
553 })]
554 });
555});
556process.env.NODE_ENV !== "production" ? Rating.propTypes /* remove-proptypes */ = {
557 // ┌────────────────────────────── Warning ──────────────────────────────┐
558 // │ These PropTypes are generated from the TypeScript type definitions. │
559 // │ To update them, edit the d.ts file and run `pnpm proptypes`. │
560 // └─────────────────────────────────────────────────────────────────────┘
561 /**
562 * Override or extend the styles applied to the component.
563 */
564 classes: PropTypes.object,
565 /**
566 * @ignore
567 */
568 className: PropTypes.string,
569 /**
570 * The default value. Use when the component is not controlled.
571 * @default null
572 */
573 defaultValue: PropTypes.number,
574 /**
575 * If `true`, the component is disabled.
576 * @default false
577 */
578 disabled: PropTypes.bool,
579 /**
580 * The icon to display when empty.
581 * @default <StarBorder fontSize="inherit" />
582 */
583 emptyIcon: PropTypes.node,
584 /**
585 * The label read when the rating input is empty.
586 * @default 'Empty'
587 */
588 emptyLabelText: PropTypes.node,
589 /**
590 * Accepts a function which returns a string value that provides a user-friendly name for the current value of the rating.
591 * This is important for screen reader users.
592 *
593 * For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
594 * @param {number} value The rating label's value to format.
595 * @returns {string}
596 * @default function defaultLabelText(value) {
597 * return `${value || '0'} Star${value !== 1 ? 's' : ''}`;
598 * }
599 */
600 getLabelText: PropTypes.func,
601 /**
602 * If `true`, only the selected icon will be highlighted.
603 * @default false
604 */
605 highlightSelectedOnly: PropTypes.bool,
606 /**
607 * The icon to display.
608 * @default <Star fontSize="inherit" />
609 */
610 icon: PropTypes.node,
611 /**
612 * The component containing the icon.
613 * @default function IconContainer(props) {
614 * const { value, ...other } = props;
615 * return <span {...other} />;
616 * }
617 */
618 IconContainerComponent: PropTypes.elementType,
619 /**
620 * Maximum rating.
621 * @default 5
622 */
623 max: PropTypes.number,
624 /**
625 * The name attribute of the radio `input` elements.
626 * This input `name` should be unique within the page.
627 * Being unique within a form is insufficient since the `name` is used to generate IDs.
628 */
629 name: PropTypes.string,
630 /**
631 * Callback fired when the value changes.
632 * @param {React.SyntheticEvent} event The event source of the callback.
633 * @param {number|null} value The new value.
634 */
635 onChange: PropTypes.func,
636 /**
637 * Callback function that is fired when the hover state changes.
638 * @param {React.SyntheticEvent} event The event source of the callback.
639 * @param {number} value The new value.
640 */
641 onChangeActive: PropTypes.func,
642 /**
643 * @ignore
644 */
645 onMouseLeave: PropTypes.func,
646 /**
647 * @ignore
648 */
649 onMouseMove: PropTypes.func,
650 /**
651 * The minimum increment value change allowed.
652 * @default 1
653 */
654 precision: chainPropTypes(PropTypes.number, props => {
655 if (props.precision < 0.1) {
656 return new Error(['MUI: The prop `precision` should be above 0.1.', 'A value below this limit has an imperceptible impact.'].join('\n'));
657 }
658 return null;
659 }),
660 /**
661 * Removes all hover effects and pointer events.
662 * @default false
663 */
664 readOnly: PropTypes.bool,
665 /**
666 * The size of the component.
667 * @default 'medium'
668 */
669 size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([PropTypes.oneOf(['small', 'medium', 'large']), PropTypes.string]),
670 /**
671 * The system prop that allows defining system overrides as well as additional CSS styles.
672 */
673 sx: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), PropTypes.func, PropTypes.object]),
674 /**
675 * The rating value.
676 */
677 value: PropTypes.number
678} : void 0;
679export default Rating;
\No newline at end of file