배경
캘린더의 일정등록 기능을 구현중에 시간을 선택하는 기능이 포함되어있는데 디자이너분이 만들어주신 와이어프레임에는 아래 사진과 같이 시간을 스크롤해서 선택하는 형태의 기능이었다.
react-time-picker, Flatpickr, antd, mui/x-date-pickers 등 여러 라이브러리가 있었는데 제공하는 기본 디자인으로는 와이어프레임과 조금 다르게 시간을 선택하는 ui 였어서 비슷한걸 찾아보다가 keen-slider 를 찾게되었다.
해당 라이브러리가 기본 ui 적으로는 가장 원하는 느낌이었어서 설치를 해서 적용을 해보았지만, 내가 지정한 시간이 정확하게 출력되지않았고(아마 내가 잘못 사용하고 있었을것) 비슷한 느낌으로 하려면 스타일도 조금 변경을 해줘야하는데 어떤 값을 바꿔줘야하는지도 조금 어려웠다.
시간만 선택하는 간단한 기능만 필요하기 때문에 구글링을 통해서 onScroll 으로 스크롤한 Index 의 값을 가져오는 식으로 라이브러리 없이 직접 구현하기로 결정했다.
와이어 프레임 시안 | keen-slider |
완성모습
구현과정
① 일정 등록(부모 컴포넌트)
나는 우선 시작시간, 종료시간을 눌렀을때 열리는 모달창에서 날짜를 선택할 것이기 때문에 SelectTime 이라는 모달을 만들었고 해당 컴포넌트 안에 관련 코드를 집어넣었다.
// CreateEventForm.tsx
"use client"
// 일정 관련 상태
const [eventStart, setEventStart] = useState(""); // 시작 시간
const [eventEnd, setEventEnd] = useState(""); // 종료 시간
// 모달 관련 상태
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열림/닫힘
const [activeInput, setActiveInput] = useState<"start" | "end" | null>(null); // 현재 선택 중인 입력란
// 시간 선택 핸들러
const handleTimeSelect = (time: string) => {
// activeInput 상태(start/end)에 따라 시작/종료 시간 업데이트
if (activeInput === "start") {
setEventStart(time);
} else if (activeInput === "end") {
setEventEnd(time);
}
// 모달 관련 상태 초기화
setIsModalOpen(false);
setActiveInput(null);
};
return (
⠇
일정등록 관련 코드들
⠇
{/* 모달 컴포넌트 */}
{isModalOpen && (
<SelectTime
onTimeSelect={handleTimeSelect}
onClose={() => {
setIsModalOpen(false);
setActiveInput(null);
}}
/>
)}
);
};
export default CreateEventForm;
② 모달 컴포넌트
"use client";
import { useState } from "react";
import TimePicker from "./TimePicker";
const buttonClass = "p-2.5 flex-1 rounded-xl bg-[#8D8D8D]";
interface SelecTimeProps {
onTimeSelect: (time: string) => void;
onClose: () => void;
}
const SelectTime = ({ onTimeSelect, onClose }: SelecTimeProps) => {
const [selectedHour, setSelectedHour] = useState("12");
const [selectedMinute, setSelectedMinute] = useState("00");
// 시간,분 데이터 만들기(인덱스 활용)
// 인덱스를 문자열로 변환하고 문자열 길이 2자리로 만들기(앞에 "0"채움)
const hours = [...Array(24)].map((_, i) => i.toString().padStart(2, "0"));
const minutes = [...Array(60)].map((_, i) => i.toString().padStart(2, "0"));
// 시간,분 스크롤 이벤트 핸들러
const handleHourScroll = (e: React.UIEvent<HTMLDivElement>) => {
const container = e.currentTarget;
const index = Math.round(container.scrollTop / 40); // 40px 단위로 스크롤 위치 계산
setSelectedHour(hours[index] || "00"); // 해당 인덱스의 값(시간)으로 상태 업데이트
};
const handleMinuteScroll = (e: React.UIEvent<HTMLDivElement>) => {
const container = e.currentTarget;
const index = Math.round(container.scrollTop / 40);
setSelectedMinute(minutes[index] || "00");
};
// 확인버튼 + 모달닫기
const handleConfirm = () => {
onTimeSelect(`${selectedHour}:${selectedMinute}`); // 선택된 시간을 "12:30" 형식으로 전달
onClose();
};
// "13:30" → "오후 1시 30분" 형태로 변환하는 함수 (모달 상단 표시용)
const convertTimeFormat = (timeStr: string): string => {
const [h, m] = timeStr.split(":").map(Number);
return `${h >= 12 ? "오후" : "오전"} ${h % 12 || 12}시 ${m}분`;
};
return (
// 모달 배경(클릭 시 닫힘)
<div
onClick={onClose}
className="fixed inset-0 flex items-center justify-center w-full h-full bg-black/70 z-50"
>
<div
className="w-full m-6 h-[258px] bg-white rounded-2xl flex items-center justify-center p-6"
onClick={(e) => e.stopPropagation()}
>
<section className="w-full">
<p className="text-center text-xl font-medium border-b-2 pb-2">
{convertTimeFormat(`${selectedHour}:${selectedMinute}`)}
</p>
<div className="flex justify-center items-center">
{/* 시간 선택 */}
<TimePicker
time={hours}
handleTimeScroll={handleHourScroll}
selectedTime={selectedHour}
/>
<span className="text-xl font-bold mx-4">:</span>
{/* 분 선택 */}
<TimePicker
time={minutes}
handleTimeScroll={handleMinuteScroll}
selectedTime={selectedMinute}
/>
</div>
<div className="flex justify-center items-center gap-2 text-white mt-4">
<button onClick={onClose} className={buttonClass}>
취소
</button>
<button onClick={handleConfirm} className={buttonClass}>
확인
</button>
</div>
</section>
</div>
</div>
);
};
export default SelectTime;
③ TimePicker
CSS 속성들을 추가해서 타임피커처럼 보이게 만들기
interface TimePickerProps {
time: string[];
handleTimeScroll: (e: React.UIEvent<HTMLDivElement>) => void;
selectedTime: string;
}
const TimePicker = ({
time,
handleTimeScroll,
selectedTime,
}: TimePickerProps) => {
return (
<div className="h-[140px] relative w-16">
{/* 선택 박스 */}
<div className="absolute pointer-events-none top-[50px] right-[14px] h-10 w-10 border-y-2" />
{/* 스크롤 박스 */}
<div
className="h-full overflow-auto scrollbar-hide snap-y snap-mandatory overscroll-contain py-[60px]"
onScroll={handleTimeScroll}
>
{/* 시간/분 아이템들 */}
{time.map((t) => (
<div
key={t}
className={`h-[40px] flex items-center justify-center snap-center
${
selectedTime === t
? "text-black font-medium mt:border-solid"
: "text-gray-400"
}`}
>
{t}
</div>
))}
</div>
</div>
);
};
export default TimePicker;
주요 css 포인트
1. 스크롤 관련
- overflow-auto: 내용이 넘칠 경우 스크롤 가능
- scrollbar-hide: 스크롤바 시각적으로 숨기기
- snap-y snap-mandatory: 세로 방향으로 각 숫자에 딱딱 멈추게
- overscroll-contain: 스크롤을 시작과 끝에서 딱 멈추게
2. 선택 영역 표시
- absolute와 border-y-2로 선택 영역 위,아래 선으로 표시 (위치는 찔끔찔끔 봐가면서 top/right 야매로 조정함)
- pointer-events-none로 테두리가 있어도 시간 선택이 자연스럽게
- top-[50px]로 선택 영역 위치 지정
3. 시간 표시
- text-black: 선택된 시간 블랙으로 표시
- text-gray-400: 선택되지 않은 시간 흐리게 표시
- h-[40px]: 시간 별 각각의 칸이 모두 같은 높이를 가지게
- snap-center: 스크롤을 멈추면 선택된 시간이 가운데에 위치하도록
4. 레이아웃
- py-[60px]: 첫 번째 시간(00)과 마지막 시간(23)도 중앙에 올 수 있게 스크롤 영역의 위아래에 60px 패딩 추가
🥺 느낀점
기능 구현을 시작하기 전 단순하게 생각했을땐 아 그냥 비슷한 라이브러리 사용해서 하면 되겠구나~~ 생각을 했었다.
그러면서 라이브러리 검색도 많이 해봤는데 오늘 써보려고 했던 라이브러리는 일단 내가 미숙하기도 하고 공식문서들을 봐도 쉽게 익숙해지지 않아서 많이 헤매고 어려움을 겪었다. 그래서 직접구현으로 눈을돌려 다른사람들은 어떻게 구현했는지 많이 찾아보고 조합을 해보았다.
그러던 중에 다른사람들은 어떻게 이렇게 구현할 생각을 하지? 하는 코드들도 많이 본 것 같다.
단순히 라이브러리를 사용하는 것보다 직접 구현을 해보니 CSS의 새로 알게된 속성들도 많았고, 상태관리와 이벤트 핸들링을 포함한 컴포넌트의 동작원리를 좀 더 잘 이해할 수 있게 되었다.
'TIL' 카테고리의 다른 글
캘린더 일정관리 - 시작시간 / 종료시간 유효성 (0) | 2024.10.31 |
---|---|
모의면접 ② (1) | 2024.10.30 |
월간 달력 출력하기 (FullCalendar / Shadcn) (0) | 2024.10.28 |
console.error() 와 throw error 의 차이 (1) | 2024.10.27 |
Tanstack Query - querykey (0) | 2024.10.25 |