UNPKG

14.9 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
3import * as React from 'react';
4import PropTypes from 'prop-types';
5import clsx from 'clsx';
6import { chainPropTypes } from '@material-ui/utils';
7import { useTheme, withStyles } from '@material-ui/core/styles';
8import { capitalize, useForkRef, useIsFocusVisible, useControlled, unstable_useId as useId } from '@material-ui/core/utils';
9import Star from '../internal/svg-icons/Star';
10
11function clamp(value, min, max) {
12 if (value < min) {
13 return min;
14 }
15
16 if (value > max) {
17 return max;
18 }
19
20 return value;
21}
22
23function getDecimalPrecision(num) {
24 const decimalPart = num.toString().split('.')[1];
25 return decimalPart ? decimalPart.length : 0;
26}
27
28function roundValueToPrecision(value, precision) {
29 if (value == null) {
30 return value;
31 }
32
33 const nearest = Math.round(value / precision) * precision;
34 return Number(nearest.toFixed(getDecimalPrecision(precision)));
35}
36
37export const styles = theme => ({
38 /* Styles applied to the root element. */
39 root: {
40 display: 'inline-flex',
41 position: 'relative',
42 fontSize: theme.typography.pxToRem(24),
43 color: '#ffb400',
44 cursor: 'pointer',
45 textAlign: 'left',
46 WebkitTapHighlightColor: 'transparent',
47 '&$disabled': {
48 opacity: 0.5,
49 pointerEvents: 'none'
50 },
51 '&$focusVisible $iconActive': {
52 outline: '1px solid #999'
53 }
54 },
55
56 /* Styles applied to the root element if `size="small"`. */
57 sizeSmall: {
58 fontSize: theme.typography.pxToRem(18)
59 },
60
61 /* Styles applied to the root element if `size="large"`. */
62 sizeLarge: {
63 fontSize: theme.typography.pxToRem(30)
64 },
65
66 /* Styles applied to the root element if `readOnly={true}`. */
67 readOnly: {
68 pointerEvents: 'none'
69 },
70
71 /* Pseudo-class applied to the root element if `disabled={true}`. */
72 disabled: {},
73
74 /* Pseudo-class applied to the root element if keyboard focused. */
75 focusVisible: {},
76
77 /* Visually hide an element. */
78 visuallyhidden: {
79 border: 0,
80 clip: 'rect(0 0 0 0)',
81 height: 1,
82 margin: -1,
83 color: '#000',
84 overflow: 'hidden',
85 padding: 0,
86 position: 'absolute',
87 top: 20,
88 width: 1
89 },
90
91 /* Styles applied to the pristine label. */
92 pristine: {
93 'input:focus + &': {
94 top: 0,
95 bottom: 0,
96 position: 'absolute',
97 outline: '1px solid #999',
98 width: '100%'
99 }
100 },
101
102 /* Styles applied to the label elements. */
103 label: {
104 cursor: 'inherit'
105 },
106
107 /* Styles applied to the icon wrapping elements. */
108 icon: {
109 display: 'flex',
110 transition: theme.transitions.create('transform', {
111 duration: theme.transitions.duration.shortest
112 }),
113 // Fix mouseLeave issue.
114 // https://github.com/facebook/react/issues/4492
115 pointerEvents: 'none'
116 },
117
118 /* Styles applied to the icon wrapping elements when empty. */
119 iconEmpty: {
120 color: theme.palette.action.disabled
121 },
122
123 /* Styles applied to the icon wrapping elements when filled. */
124 iconFilled: {},
125
126 /* Styles applied to the icon wrapping elements when hover. */
127 iconHover: {},
128
129 /* Styles applied to the icon wrapping elements when focus. */
130 iconFocus: {},
131
132 /* Styles applied to the icon wrapping elements when active. */
133 iconActive: {
134 transform: 'scale(1.2)'
135 },
136
137 /* Styles applied to the icon wrapping elements when decimals are necessary. */
138 decimal: {
139 position: 'relative'
140 }
141});
142
143function IconContainer(props) {
144 const other = _objectWithoutPropertiesLoose(props, ["value"]);
145
146 return /*#__PURE__*/React.createElement("span", other);
147}
148
149process.env.NODE_ENV !== "production" ? IconContainer.propTypes = {
150 value: PropTypes.number.isRequired
151} : void 0;
152const defaultIcon = /*#__PURE__*/React.createElement(Star, {
153 fontSize: "inherit"
154});
155
156function defaultLabelText(value) {
157 return `${value} Star${value !== 1 ? 's' : ''}`;
158}
159
160const Rating = /*#__PURE__*/React.forwardRef(function Rating(props, ref) {
161 const {
162 classes,
163 className,
164 defaultValue = null,
165 disabled = false,
166 emptyIcon,
167 emptyLabelText = 'Empty',
168 getLabelText = defaultLabelText,
169 icon = defaultIcon,
170 IconContainerComponent = IconContainer,
171 max = 5,
172 name: nameProp,
173 onChange,
174 onChangeActive,
175 onMouseLeave,
176 onMouseMove,
177 precision = 1,
178 readOnly = false,
179 size = 'medium',
180 value: valueProp
181 } = props,
182 other = _objectWithoutPropertiesLoose(props, ["classes", "className", "defaultValue", "disabled", "emptyIcon", "emptyLabelText", "getLabelText", "icon", "IconContainerComponent", "max", "name", "onChange", "onChangeActive", "onMouseLeave", "onMouseMove", "precision", "readOnly", "size", "value"]);
183
184 const name = useId(nameProp);
185 const [valueDerived, setValueState] = useControlled({
186 controlled: valueProp,
187 default: defaultValue,
188 name: 'Rating'
189 });
190 const valueRounded = roundValueToPrecision(valueDerived, precision);
191 const theme = useTheme();
192 const [{
193 hover,
194 focus
195 }, setState] = React.useState({
196 hover: -1,
197 focus: -1
198 });
199 let value = valueRounded;
200
201 if (hover !== -1) {
202 value = hover;
203 }
204
205 if (focus !== -1) {
206 value = focus;
207 }
208
209 const {
210 isFocusVisible,
211 onBlurVisible,
212 ref: focusVisibleRef
213 } = useIsFocusVisible();
214 const [focusVisible, setFocusVisible] = React.useState(false);
215 const rootRef = React.useRef();
216 const handleFocusRef = useForkRef(focusVisibleRef, rootRef);
217 const handleRef = useForkRef(handleFocusRef, ref);
218
219 const handleMouseMove = event => {
220 if (onMouseMove) {
221 onMouseMove(event);
222 }
223
224 const rootNode = rootRef.current;
225 const {
226 right,
227 left
228 } = rootNode.getBoundingClientRect();
229 const {
230 width
231 } = rootNode.firstChild.getBoundingClientRect();
232 let percent;
233
234 if (theme.direction === 'rtl') {
235 percent = (right - event.clientX) / (width * max);
236 } else {
237 percent = (event.clientX - left) / (width * max);
238 }
239
240 let newHover = roundValueToPrecision(max * percent + precision / 2, precision);
241 newHover = clamp(newHover, precision, max);
242 setState(prev => prev.hover === newHover && prev.focus === newHover ? prev : {
243 hover: newHover,
244 focus: newHover
245 });
246 setFocusVisible(false);
247
248 if (onChangeActive && hover !== newHover) {
249 onChangeActive(event, newHover);
250 }
251 };
252
253 const handleMouseLeave = event => {
254 if (onMouseLeave) {
255 onMouseLeave(event);
256 }
257
258 const newHover = -1;
259 setState({
260 hover: newHover,
261 focus: newHover
262 });
263
264 if (onChangeActive && hover !== newHover) {
265 onChangeActive(event, newHover);
266 }
267 };
268
269 const handleChange = event => {
270 const newValue = parseFloat(event.target.value);
271 setValueState(newValue);
272
273 if (onChange) {
274 onChange(event, newValue);
275 }
276 };
277
278 const handleClear = event => {
279 // Ignore keyboard events
280 // https://github.com/facebook/react/issues/7407
281 if (event.clientX === 0 && event.clientY === 0) {
282 return;
283 }
284
285 setState({
286 hover: -1,
287 focus: -1
288 });
289 setValueState(null);
290
291 if (onChange && parseFloat(event.target.value) === valueRounded) {
292 onChange(event, null);
293 }
294 };
295
296 const handleFocus = event => {
297 if (isFocusVisible(event)) {
298 setFocusVisible(true);
299 }
300
301 const newFocus = parseFloat(event.target.value);
302 setState(prev => ({
303 hover: prev.hover,
304 focus: newFocus
305 }));
306
307 if (onChangeActive && focus !== newFocus) {
308 onChangeActive(event, newFocus);
309 }
310 };
311
312 const handleBlur = event => {
313 if (hover !== -1) {
314 return;
315 }
316
317 if (focusVisible !== false) {
318 setFocusVisible(false);
319 onBlurVisible();
320 }
321
322 const newFocus = -1;
323 setState(prev => ({
324 hover: prev.hover,
325 focus: newFocus
326 }));
327
328 if (onChangeActive && focus !== newFocus) {
329 onChangeActive(event, newFocus);
330 }
331 };
332
333 const item = (state, labelProps) => {
334 const id = `${name}-${String(state.value).replace('.', '-')}`;
335 const container = /*#__PURE__*/React.createElement(IconContainerComponent, {
336 value: state.value,
337 className: clsx(classes.icon, state.filled ? classes.iconFilled : classes.iconEmpty, state.hover && classes.iconHover, state.focus && classes.iconFocus, state.active && classes.iconActive)
338 }, emptyIcon && !state.filled ? emptyIcon : icon);
339
340 if (readOnly) {
341 return /*#__PURE__*/React.createElement("span", _extends({
342 key: state.value
343 }, labelProps), container);
344 }
345
346 return /*#__PURE__*/React.createElement(React.Fragment, {
347 key: state.value
348 }, /*#__PURE__*/React.createElement("label", _extends({
349 className: classes.label,
350 htmlFor: id
351 }, labelProps), container, /*#__PURE__*/React.createElement("span", {
352 className: classes.visuallyhidden
353 }, getLabelText(state.value))), /*#__PURE__*/React.createElement("input", {
354 onFocus: handleFocus,
355 onBlur: handleBlur,
356 onChange: handleChange,
357 onClick: handleClear,
358 disabled: disabled,
359 value: state.value,
360 id: id,
361 type: "radio",
362 name: name,
363 checked: state.checked,
364 className: classes.visuallyhidden
365 }));
366 };
367
368 return /*#__PURE__*/React.createElement("span", _extends({
369 ref: handleRef,
370 onMouseMove: handleMouseMove,
371 onMouseLeave: handleMouseLeave,
372 className: clsx(classes.root, className, size !== 'medium' && classes[`size${capitalize(size)}`], disabled && classes.disabled, focusVisible && classes.focusVisible, readOnly && classes.readOnly),
373 role: readOnly ? 'img' : null,
374 "aria-label": readOnly ? getLabelText(value) : null
375 }, other), Array.from(new Array(max)).map((_, index) => {
376 const itemValue = index + 1;
377
378 if (precision < 1) {
379 const items = Array.from(new Array(1 / precision));
380 return /*#__PURE__*/React.createElement("span", {
381 key: itemValue,
382 className: clsx(classes.decimal, itemValue === Math.ceil(value) && (hover !== -1 || focus !== -1) && classes.iconActive)
383 }, items.map(($, indexDecimal) => {
384 const itemDecimalValue = roundValueToPrecision(itemValue - 1 + (indexDecimal + 1) * precision, precision);
385 return item({
386 value: itemDecimalValue,
387 filled: itemDecimalValue <= value,
388 hover: itemDecimalValue <= hover,
389 focus: itemDecimalValue <= focus,
390 checked: itemDecimalValue === valueRounded
391 }, {
392 style: items.length - 1 === indexDecimal ? {} : {
393 width: itemDecimalValue === value ? `${(indexDecimal + 1) * precision * 100}%` : '0%',
394 overflow: 'hidden',
395 zIndex: 1,
396 position: 'absolute'
397 }
398 });
399 }));
400 }
401
402 return item({
403 value: itemValue,
404 active: itemValue === value && (hover !== -1 || focus !== -1),
405 filled: itemValue <= value,
406 hover: itemValue <= hover,
407 focus: itemValue <= focus,
408 checked: itemValue === valueRounded
409 });
410 }), !readOnly && !disabled && valueRounded == null && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("input", {
411 value: "",
412 id: `${name}-empty`,
413 type: "radio",
414 name: name,
415 defaultChecked: true,
416 className: classes.visuallyhidden
417 }), /*#__PURE__*/React.createElement("label", {
418 className: classes.pristine,
419 htmlFor: `${name}-empty`
420 }, /*#__PURE__*/React.createElement("span", {
421 className: classes.visuallyhidden
422 }, emptyLabelText))));
423});
424process.env.NODE_ENV !== "production" ? Rating.propTypes = {
425 // ----------------------------- Warning --------------------------------
426 // | These PropTypes are generated from the TypeScript type definitions |
427 // | To update them edit the d.ts file and run "yarn proptypes" |
428 // ----------------------------------------------------------------------
429
430 /**
431 * Override or extend the styles applied to the component.
432 * See [CSS API](#css) below for more details.
433 */
434 classes: PropTypes.object,
435
436 /**
437 * @ignore
438 */
439 className: PropTypes.string,
440
441 /**
442 * The default value. Use when the component is not controlled.
443 */
444 defaultValue: PropTypes.number,
445
446 /**
447 * If `true`, the rating will be disabled.
448 */
449 disabled: PropTypes.bool,
450
451 /**
452 * The icon to display when empty.
453 */
454 emptyIcon: PropTypes.node,
455
456 /**
457 * The label read when the rating input is empty.
458 */
459 emptyLabelText: PropTypes.node,
460
461 /**
462 * Accepts a function which returns a string value that provides a user-friendly name for the current value of the rating.
463 *
464 * For localization purposes, you can use the provided [translations](/guides/localization/).
465 *
466 * @param {number} value The rating label's value to format.
467 * @returns {string}
468 */
469 getLabelText: PropTypes.func,
470
471 /**
472 * The icon to display.
473 */
474 icon: PropTypes.node,
475
476 /**
477 * The component containing the icon.
478 */
479 IconContainerComponent: PropTypes.elementType,
480
481 /**
482 * Maximum rating.
483 */
484 max: PropTypes.number,
485
486 /**
487 * The name attribute of the radio `input` elements.
488 * If `readOnly` is false, the prop is required,
489 * this input name`should be unique within the parent form.
490 */
491 name: chainPropTypes(PropTypes.string, props => {
492 if (!props.readOnly && !props.name) {
493 return new Error(['Material-UI: The prop `name` is required (when `readOnly` is false).', 'Additionally, the input name should be unique within the parent form.'].join('\n'));
494 }
495
496 return null;
497 }),
498
499 /**
500 * Callback fired when the value changes.
501 *
502 * @param {object} event The event source of the callback.
503 * @param {number} value The new value.
504 */
505 onChange: PropTypes.func,
506
507 /**
508 * Callback function that is fired when the hover state changes.
509 *
510 * @param {object} event The event source of the callback.
511 * @param {number} value The new value.
512 */
513 onChangeActive: PropTypes.func,
514
515 /**
516 * @ignore
517 */
518 onMouseLeave: PropTypes.func,
519
520 /**
521 * @ignore
522 */
523 onMouseMove: PropTypes.func,
524
525 /**
526 * The minimum increment value change allowed.
527 */
528 precision: chainPropTypes(PropTypes.number, props => {
529 if (props.precision < 0.1) {
530 return new Error(['Material-UI: The prop `precision` should be above 0.1.', 'A value below this limit has an imperceptible impact.'].join('\n'));
531 }
532
533 return null;
534 }),
535
536 /**
537 * Removes all hover effects and pointer events.
538 */
539 readOnly: PropTypes.bool,
540
541 /**
542 * The size of the rating.
543 */
544 size: PropTypes.oneOf(['large', 'medium', 'small']),
545
546 /**
547 * The rating value.
548 */
549 value: PropTypes.number
550} : void 0;
551export default withStyles(styles, {
552 name: 'MuiRating'
553})(Rating);
\No newline at end of file