|
1 | | -import { useState } from 'react'; |
2 | | - |
3 | | -import EmptyRating from '../../assets/Icons/rating/emptyRating.svg?react'; |
4 | | -import HalfRating from '../../assets/Icons/rating/halfRating.svg?react'; |
5 | | -import FillRating from '../../assets/Icons/rating/fillRating.svg?react'; |
| 1 | +import React, { useState, useRef, useCallback } from 'react'; |
| 2 | +import EmptyRatingIcon from '../../assets/Icons/rating/emptyRating.svg?react'; |
| 3 | +import HalfRatingIcon from '../../assets/Icons/rating/halfRating.svg?react'; |
| 4 | +import FillRatingIcon from '../../assets/Icons/rating/fillRating.svg?react'; |
| 5 | +import { useIsMobile } from '../../hooks/useIsMobile'; |
6 | 6 |
|
7 | 7 | interface RatingProps { |
8 | 8 | maxRatingValue?: number; |
9 | 9 | currentRating: number; |
10 | 10 | setCurrentRating: (rating: number) => void; |
| 11 | + starSize?: number; |
| 12 | + readOnly?: boolean; |
| 13 | + handleSubmit?: () => void; |
11 | 14 | } |
12 | 15 |
|
13 | | -const getStar = (index: number, ratingValue: number) => { |
14 | | - const fullStars = Math.floor(ratingValue); |
15 | | - const hasHalfStar = ratingValue % 1 !== 0 && index === fullStars; |
16 | | - |
17 | | - if (index < fullStars) { |
18 | | - return <FillRating width={48} height={48} />; |
19 | | - } |
20 | | - if (hasHalfStar) { |
21 | | - return <HalfRating width={48} height={48} />; |
| 16 | +const StarDisplay = React.memo( |
| 17 | + ({ type, size }: { type: 'fill' | 'half' | 'empty'; size: number }) => { |
| 18 | + if (type === 'fill') return <FillRatingIcon width={size} height={size} />; |
| 19 | + if (type === 'half') return <HalfRatingIcon width={size} height={size} />; |
| 20 | + return <EmptyRatingIcon width={size} height={size} />; |
22 | 21 | } |
23 | | - return <EmptyRating width={48} height={48} />; |
24 | | -}; |
| 22 | +); |
25 | 23 |
|
26 | | -const Rating = ({ maxRatingValue = 5, setCurrentRating, currentRating }: RatingProps) => { |
| 24 | +const Rating = ({ |
| 25 | + maxRatingValue = 5, |
| 26 | + currentRating, |
| 27 | + setCurrentRating, |
| 28 | + starSize = 48, |
| 29 | + readOnly = false, |
| 30 | + handleSubmit = () => {}, |
| 31 | +}: RatingProps) => { |
27 | 32 | const [hoverRating, setHoverRating] = useState<number | null>(null); |
| 33 | + const [isDragging, setIsDragging] = useState(false); |
| 34 | + const ratingContainerRef = useRef<HTMLDivElement>(null); |
| 35 | + const isMobile = useIsMobile(); |
| 36 | + |
| 37 | + const getRatingToShow = readOnly |
| 38 | + ? currentRating |
| 39 | + : hoverRating !== null |
| 40 | + ? hoverRating |
| 41 | + : currentRating; |
| 42 | + |
| 43 | + const calculateRatingFromX = useCallback( |
| 44 | + (clientX: number): number => { |
| 45 | + if (!ratingContainerRef.current) return 0; |
| 46 | + |
| 47 | + const rect = ratingContainerRef.current.getBoundingClientRect(); |
| 48 | + |
| 49 | + const x = clientX - rect.left; |
| 50 | + |
| 51 | + let newRating = (x / rect.width) * maxRatingValue; |
| 52 | + |
| 53 | + newRating = Math.round(newRating * 2) / 2; |
| 54 | + |
| 55 | + newRating = Math.max(0, Math.min(newRating, maxRatingValue)); |
| 56 | + |
| 57 | + return newRating; |
| 58 | + }, |
| 59 | + [maxRatingValue] |
| 60 | + ); |
| 61 | + |
| 62 | + const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { |
| 63 | + if (readOnly) return; |
| 64 | + |
| 65 | + if (isMobile) { |
| 66 | + setIsDragging(true); |
| 67 | + |
| 68 | + event.currentTarget.setPointerCapture(event.pointerId); |
| 69 | + |
| 70 | + const newRating = calculateRatingFromX(event.clientX); |
| 71 | + setHoverRating(newRating); |
| 72 | + setCurrentRating(newRating); |
| 73 | + } |
| 74 | + }; |
28 | 75 |
|
29 | | - const handleMouseMove = (index: number, event: React.MouseEvent<HTMLSpanElement>) => { |
30 | | - const starElement = event.currentTarget; |
31 | | - const rect = starElement.getBoundingClientRect(); |
32 | | - const mouseX = event.clientX - rect.left; |
33 | | - const starWidth = rect.width; |
| 76 | + const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { |
| 77 | + if (readOnly) return; |
34 | 78 |
|
35 | | - if (mouseX <= starWidth / 2) { |
36 | | - setHoverRating(index + 0.5); |
| 79 | + if (isMobile) { |
| 80 | + if (isDragging) { |
| 81 | + const newRating = calculateRatingFromX(event.clientX); |
| 82 | + setHoverRating(newRating); |
| 83 | + setCurrentRating(newRating); |
| 84 | + } |
37 | 85 | } else { |
38 | | - setHoverRating(index + 1); |
| 86 | + const newRating = calculateRatingFromX(event.clientX); |
| 87 | + setHoverRating(newRating); |
39 | 88 | } |
40 | 89 | }; |
41 | 90 |
|
42 | | - const handleMouseLeave = () => { |
43 | | - setHoverRating(null); |
| 91 | + const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => { |
| 92 | + if (readOnly) return; |
| 93 | + |
| 94 | + if (isMobile && isDragging) { |
| 95 | + event.currentTarget.releasePointerCapture(event.pointerId); |
| 96 | + setIsDragging(false); |
| 97 | + } |
| 98 | + |
| 99 | + if (isMobile) { |
| 100 | + handleSubmit(); |
| 101 | + } |
44 | 102 | }; |
45 | 103 |
|
46 | | - const handleClick = (index: number) => { |
47 | | - const ratingToSet = hoverRating !== null ? hoverRating : index + 0.5; |
| 104 | + const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { |
| 105 | + if (readOnly || isMobile) return; |
| 106 | + |
| 107 | + const ratingToSet = hoverRating !== null ? hoverRating : calculateRatingFromX(event.clientX); |
48 | 108 | setCurrentRating(ratingToSet); |
49 | 109 | }; |
50 | 110 |
|
| 111 | + const handleMouseLeave = () => { |
| 112 | + if (readOnly || isMobile || isDragging) return; |
| 113 | + setHoverRating(null); |
| 114 | + }; |
| 115 | + |
| 116 | + const starsToRender = []; |
| 117 | + for (let i = 0; i < maxRatingValue; i++) { |
| 118 | + const starPoint = i + 1; |
| 119 | + let starType: 'fill' | 'half' | 'empty' = 'empty'; |
| 120 | + |
| 121 | + if (getRatingToShow >= starPoint) { |
| 122 | + starType = 'fill'; |
| 123 | + } else if (getRatingToShow >= starPoint - 0.5) { |
| 124 | + starType = 'half'; |
| 125 | + } |
| 126 | + starsToRender.push(<StarDisplay key={i} type={starType} size={starSize} />); |
| 127 | + } |
| 128 | + |
51 | 129 | return ( |
52 | | - <div className="inline-flex items-center" onMouseLeave={handleMouseLeave}> |
53 | | - {[...Array(maxRatingValue)].map((_, index) => { |
54 | | - const ratingValue = hoverRating !== null ? hoverRating : currentRating; |
55 | | - return ( |
56 | | - <span |
57 | | - className="cursor-pointer" |
58 | | - key={index} |
59 | | - onMouseMove={(e) => handleMouseMove(index, e)} |
60 | | - onClick={() => handleClick(index)} |
61 | | - > |
62 | | - {getStar(index, ratingValue)} |
63 | | - </span> |
64 | | - ); |
65 | | - })} |
| 130 | + <div |
| 131 | + ref={ratingContainerRef} |
| 132 | + className={`inline-flex max-w-[240px] cursor-pointer items-center`} |
| 133 | + style={{ touchAction: isMobile && !readOnly ? 'none' : 'auto' }} |
| 134 | + onPointerDown={handlePointerDown} |
| 135 | + onPointerMove={handlePointerMove} |
| 136 | + onPointerUp={handlePointerUp} |
| 137 | + onClick={handleClick} |
| 138 | + onMouseLeave={handleMouseLeave} |
| 139 | + role="slider" |
| 140 | + aria-valuenow={currentRating} |
| 141 | + aria-valuemin={0} |
| 142 | + aria-valuemax={maxRatingValue} |
| 143 | + aria-readonly={readOnly} |
| 144 | + aria-label={`Rating: ${currentRating} out of ${maxRatingValue} stars`} |
| 145 | + tabIndex={readOnly ? -1 : 0} |
| 146 | + > |
| 147 | + {starsToRender} |
66 | 148 | </div> |
67 | 149 | ); |
68 | 150 | }; |
|
0 commit comments