TimeRangePicker
두 시간의 간격을 입력받는 구성 요소입니다.
Example
It can only be viewed in a mobile.
Steps
Prerequisite
Install Package
npm install dayjs lucide-react
Copy Code
components/TimeRangePicker/index.tsx
'use client'
import { useEffect, useId, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import dayjs from 'dayjs'
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
import { useOnClickOutside } from 'hooks'
import { XCircleIcon } from 'lucide-react'
import { createPortal } from 'react-dom'
import { cn, twoDigitsNumber } from 'utils'
import { Button } from '../..'
dayjs.extend(isSameOrBefore)
export interface Props {
startTime: string
endTime: string
onChange: (startTime: string, endTime: string) => void
error?: ReactNode
}
function TimeRangePicker({ startTime, endTime, onChange, error }: Props) {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [tab, setTab] = useState<'start' | 'end'>('start')
const [startHour, setStartHour] = useState<string>(
startTime ? startTime.split(':')[0] : '00'
)
const [startMinute, setStartMinute] = useState<string>(
startTime ? startTime.split(':')[1] : '00'
)
const [endHour, setEndHour] = useState<string>(
endTime ? endTime.split(':')[0] : '00'
)
const [endMinute, setEndMinute] = useState<string>(
endTime ? endTime.split(':')[1] : '00'
)
const ref = useRef<HTMLDivElement>(null)
const hourRef = useRef<HTMLDivElement>(null)
const minuteRef = useRef<HTMLDivElement>(null)
const targetRef = useRef<HTMLDivElement>(null)
const id = useId()
const onApply = () => {
if (
dayjs(`2022-02-08T${endHour}:${endMinute}:00`).isSameOrBefore(
`2022-02-08T${startHour}:${startMinute}`
)
) {
alert('시작 시간이 종료 시간보다 뒤에 있거나 같습니다.')
return
}
onChange(`${startHour}:${startMinute}`, `${endHour}:${endMinute}`)
setIsOpen(false)
}
useEffect(() => {
if (!isOpen) return
if (hourRef.current)
hourRef.current.scrollTop =
40 * Number(tab === 'start' ? startHour : endHour)
if (minuteRef.current)
minuteRef.current.scrollTop =
40 * Number(tab === 'start' ? startMinute : endMinute)
}, [isOpen, tab, hourRef, endHour, endMinute, startHour, startMinute])
useOnClickOutside(targetRef, () => setIsOpen(false), id)
return (
<div className="inline-block">
<div
ref={ref}
id={id}
className={cn(
'flex items-center justify-between gap-2 rounded border border-neutral-200 bg-white px-2 py-1 dark:bg-neutral-800',
error
? 'border-red-500'
: 'hover:border-neutral-500 dark:border-neutral-700'
)}
>
<button
onClick={() => {
setTab('start')
setIsOpen(true)
}}
className={cn(
'rounded px-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-700',
startTime ? 'text-black' : 'text-neutral-400 dark:text-neutral-100'
)}
>
{startTime || '00:00'}
</button>
<span className="cursor-default text-neutral-400">~</span>
<button
onClick={() => {
setTab('end')
setIsOpen(true)
}}
className={cn(
'rounded px-2 py-1 hover:bg-neutral-100 dark:hover:bg-neutral-700',
endTime ? 'text-black' : 'text-neutral-400 dark:text-neutral-100'
)}
>
{endTime || '00:00'}
</button>
</div>
<div className="mt-1 text-xs text-red-500">{error}</div>
{isOpen && (
<>
{createPortal(
<div
role="presentation"
style={{
left: ref.current?.getBoundingClientRect().left || 0,
top:
window.scrollY +
(ref.current?.getBoundingClientRect().top || 0) +
(ref.current?.clientHeight || 0)
}}
className="absolute z-[9999]"
>
<div
ref={targetRef}
className="z-[9999] space-y-4 rounded bg-white p-4 drop-shadow-xl dark:bg-neutral-800"
>
{/* Tab */}
<div className="flex rounded-lg bg-neutral-200 p-1 text-sm dark:bg-neutral-700">
<div
className={cn(
'flex w-36 items-center justify-between gap-8 rounded-lg px-3 py-2',
tab === 'start'
? 'bg-white dark:bg-neutral-800'
: 'cursor-pointer'
)}
onClick={() => {
if (tab === 'end') setTab('start')
}}
>
<div
className={cn({
'font-bold': tab === 'start'
})}
>
<div>시작</div>
<div
className={cn({
'text-blue-500': tab === 'start'
})}
>
{startHour}:{startMinute}
</div>
</div>
<button
onClick={() => {
setStartHour('00')
setStartMinute('00')
}}
>
<XCircleIcon
className={cn(
tab === 'start'
? 'h-6 w-6 cursor-pointer text-neutral-200 hover:text-neutral-400 dark:text-neutral-600'
: 'invisible'
)}
/>
</button>
</div>
<div
className={cn(
'flex w-36 items-center justify-between gap-8 rounded-lg px-3 py-2',
tab === 'end'
? 'bg-white dark:bg-neutral-800'
: 'cursor-pointer'
)}
onClick={() => {
if (tab === 'start') setTab('end')
}}
>
<div
className={cn({
'font-bold': tab === 'end'
})}
>
<div>종료</div>
<div
className={cn({
'text-blue-500': tab === 'end'
})}
>
{endHour}:{endMinute}
</div>
</div>
<button
onClick={() => {
setEndHour('00')
setEndMinute('00')
}}
>
<XCircleIcon
className={cn(
tab === 'end'
? 'h-6 w-6 cursor-pointer text-neutral-200 hover:text-neutral-400 dark:text-neutral-600'
: 'invisible'
)}
/>
</button>
</div>
</div>
{/* Picker */}
<div className="relative flex justify-center text-neutral-500">
<div
ref={hourRef}
className="scrollbar-hide z-10 h-[200px] overflow-auto overscroll-none py-20"
>
{Array.from({ length: 24 }, (_, i) =>
twoDigitsNumber(i)
).map((hour) => (
<div
key={hour}
onClick={() => {
const currentMinuteTop =
minuteRef.current?.scrollTop || 0
if (tab === 'start') {
setStartHour(hour)
} else {
setEndHour(hour)
}
if (hourRef.current)
hourRef.current.scrollTop = 40 * Number(hour)
if (minuteRef.current)
minuteRef.current.scrollTop = currentMinuteTop
}}
className={cn(
'flex h-10 w-10 cursor-pointer items-center justify-center',
{
'text-blue-500':
(tab === 'start' && startHour === hour) ||
(tab === 'end' && endHour === hour)
}
)}
>
{hour}
</div>
))}
</div>
<div
ref={minuteRef}
className="scrollbar-hide z-10 h-[200px] overflow-auto overscroll-none py-20"
>
{Array.from({ length: 6 }, (_, i) =>
twoDigitsNumber(10 * i)
).map((minute, index) => (
<div
key={minute}
onClick={() => {
const currentHourTop = hourRef.current?.scrollTop || 0
if (tab === 'start') {
setStartMinute(minute)
} else {
setEndMinute(minute)
}
if (minuteRef.current)
minuteRef.current.scrollTop = 40 * Number(index)
if (hourRef.current)
hourRef.current.scrollTop = currentHourTop
}}
className={cn(
'flex h-10 w-10 cursor-pointer items-center justify-center',
{
'text-blue-500':
(tab === 'start' && startMinute === minute) ||
(tab === 'end' && endMinute === minute)
}
)}
>
{minute}
</div>
))}
</div>
<div className="absolute top-20 h-10 w-full rounded-lg bg-neutral-100 dark:bg-neutral-900" />
</div>
{/* Button */}
<div className="flex justify-center">
<Button.v1
shape="contained"
theme="primary"
size="sm"
onClick={onApply}
>
적용
</Button.v1>
</div>
</div>
</div>,
document.body
)}
</>
)}
</div>
)
}
export default TimeRangePicker
Usage
<TimeRangePicker
startTime=""
endTime=""
onChange={(startTime, endTime) => setState({ startTime, endTime })}
error={<p>Error!</p>}
/>
Props
Name | Type | Default |
---|---|---|
startTime | string | |
endTime | string | |
onChange | (startTime: string, endTime: string) => void | |
error | ReactNode |