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

NameTypeDefault
startTimestring
endTimestring
onChange(startTime: string, endTime: string) => void
errorReactNode