react(nextjs), tailwind를 통해 별점 컴포넌트를 만들어보자.
준비물로는 5점짜리 별 5개가 이어진 이미지 2장이 필요한데, 별이 채워진 이미지와 별이 채워지지않은 이미지가 필요하다.
(하나의 별만 있다면 크기를 지정하고 반복해서 배경이미지를 repeat 시키면 될 듯하다.)
그 이유는 마우스가 호버될때,
아래 영상과 같이 마우스 커서에 따라 채워진 이미지를 보여주기 위함이다.
컴포넌트 코드
'use client';
import clsx from 'clsx';
import React, { useState } from 'react';
interface Props {
value?: number;
onChange?: (radio: number) => void;
width?: number;
height?: number;
}
export default function RatingStar(props: Props) {
const { value = 0, onChange, width = 160, height = 32 } = props;
const stepCount = 5;
const [tempPercentage, setTempPercentage] = useState(0);
const [percentage, setPercentage] = useState(() => value * (100 / stepCount));
const isReadOnly = !onChange;
const getSteppedPercentage = (value: number) => {
// 8% (매직넘버) 이하의 경우 0%
if (value < 8) return 0;
const steps = Array.from({ length: stepCount }, (_, i) => (100 / stepCount) * (i + 1));
return steps.find((step) => value <= step) || 100;
};
const handleMove = (clientX: number, rect: DOMRect) => {
const x = clientX - rect.left;
const width = rect.width;
const newPercentage = Math.min(Math.max((x / width) * 100, 0), 100);
const steppedPercentage = getSteppedPercentage(newPercentage);
setTempPercentage(steppedPercentage);
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isReadOnly) {
const rect = e.currentTarget.getBoundingClientRect();
handleMove(e.clientX, rect);
}
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!isReadOnly) {
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.touches[0]?.clientX;
if (clientX) {
handleMove(clientX, rect);
}
}
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!isReadOnly) {
setPercentage(tempPercentage);
onChange(tempPercentage / (100 / stepCount));
}
};
return (
<div className="flex grow">
<div
className="relative cursor-pointer"
style={{ width: `${width}px`, height: `${height}px` }}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={() => {
if (!isReadOnly) {
setTempPercentage(percentage);
}
}}
onTouchMove={handleTouchMove}
>
{['rating-star__base', 'rating-star__fill'].map((className) => {
const isBase = className.includes('base');
const isFill = className.includes('fill');
return (
<div
key={className}
className={clsx(
`bg-no-repeat bg-[left_center] absolute top-0 left-0`,
{
'w-full bg-[url("/static/images/img-rating-star-base.png")]': isBase,
'w-0 bg-[url("/static/images/img-rating-star-fill.png")]': isFill,
},
className,
)}
style={
isFill
? {
width: `${tempPercentage}%`,
height: `${height}px`,
backgroundSize: `${width}px auto`,
}
: {
width: `${width}px`,
height: `${height}px`,
backgroundSize: `${width}px auto`,
}
}
/>
);
})}
</div>
</div>
);
}
간단히 흐름을 말하면,
유저의 마우스 움직임에 따라 채워진 별의 엘리먼트의 width를 조정하여 (stepCount에 따른 값에 따라 width가 지정) 별이 채워지는 형식이다.
마우스 클릭이 발생하면 특정 값(percentage)에 저장되고, 이때도 마우스를 움직일 경우에 위와 동일하게 동작하고(tempPercentage) 마우스가 빠져나갈 경우 클릭시 저장된(percentage) 별의 갯수로 돌아온다.
getSteppedPercentage 함수의 8이라는 숫자는 매직넘버인데, 마우스 오버시 마우스 커서의 위치가 8이상 올라갔을때부터 별 1개가 채워지기를 희망하기 때문이다. (10으로 했을 경우 뭔가 별이 차야할것 같은데 라는 생각이 들었다.)
이때, width 값을 저장하기 위해 percentage를 state로 저장하고, steps를 따로 계산하고 있는데 반대로 ratio 값을 state로 저장하고 역으로 percentage를 계산해서 써도 될 것 같다.
그 외의 핸들러들은 마우스 움직일때 좌표를 구해서 percentage를 움직여서 tempPercentage에만 저장하여 유저가 별의 움직임을 볼수 있도록 하고, 마우스가 빠질때 tempPercentage를 리셋해서 선택된 평점이 없음을 보여준다.
만약 클릭 핸들러가 발생할 경우 tempPercentage를 저장하여 마우스가 빠졌을때 선택된 평점을 보여준다. (클릭시 percentage에 set해주고 onChange 콜백에 계산하여 ratio를 인자로 전달
사용할 때
// 선택된 값을 통해 이동시키기 때문에 따로 value 정의가 필요없을때
<RatingStar
onChange={(ratio) => {
moveToReview(ratio);
}
/>
// 선택된 값을 보여주기만 할때
<RatingStar value={4} width={115} height={18} />
// 값 제어가 필요할때
<RatingStar width={210} height={30} value={ratio} onChange={(ratio) => setRatio(ratio)} />
요런식으로 노출
물론 이 방식이 정답은 아니고 다른 방식도 있다.
코드펜에서 구경 중에 css만으로도 제작한 방식도 봤다. (https://codepen.io/cycosta/pen/QWNaXNV)
다음엔 또 다른 방식으로도 만들어봐야겠다 ㅎㅎ
'React' 카테고리의 다른 글
React의 가상 DOM, Vue의 가상 DOM과 비교 (1) | 2024.12.11 |
---|---|
React 생명 주기(LifeCycle) (0) | 2021.05.01 |
인프런 React 강의 듣고 사이트 만들기 _ Front & back 작업 3. https 적용하기 (0) | 2021.04.05 |
React. DOM엘리먼트에 텍스트 삽입하기 innerHTML말고 dangerouslySetInnerHTML를 사용하자 (0) | 2021.03.16 |
인프런 React 강의 듣고 사이트 만들기 _ Front & back 작업 2. 피셔-에이츠 셔플 (0) | 2021.03.01 |
인프런 React 강의 듣고 사이트 만들기 _ Front & back 작업 1. 심심이 채팅을 만들어보자! (simsimi API, 심심이 API) (0) | 2021.02.16 |
인프런 React 강의 듣고 사이트 만들기 _ Front 작업 10. react-slick으로 슬라이드 만들기 (AsNavFor 방식) (0) | 2021.02.13 |
인프런 React 강의 듣고 사이트 만들기 _ Front 작업 09. 사진첩을 핀터레스트 레이아웃처럼 만들기! (masonry layout) (0) | 2021.02.11 |