123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
- import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
- import cn from '@/utils/classnames'
- import type { DatePickerProps, Period } from '../types'
- import { ViewType } from '../types'
- import type { Dayjs } from 'dayjs'
- import dayjs, {
- clearMonthMapCache,
- cloneTime,
- getDateWithTimezone,
- getDaysInMonth,
- getHourIn12Hour,
- } from '../utils/dayjs'
- import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
- } from '@/app/components/base/portal-to-follow-elem'
- import DatePickerHeader from './header'
- import Calendar from '../calendar'
- import DatePickerFooter from './footer'
- import YearAndMonthPickerHeader from '../year-and-month-picker/header'
- import YearAndMonthPickerOptions from '../year-and-month-picker/options'
- import YearAndMonthPickerFooter from '../year-and-month-picker/footer'
- import TimePickerHeader from '../time-picker/header'
- import TimePickerOptions from '../time-picker/options'
- import { useTranslation } from 'react-i18next'
- const DatePicker = ({
- value,
- timezone,
- onChange,
- onClear,
- placeholder,
- needTimePicker = true,
- renderTrigger,
- }: DatePickerProps) => {
- const { t } = useTranslation()
- const [isOpen, setIsOpen] = useState(false)
- const [view, setView] = useState(ViewType.date)
- const containerRef = useRef<HTMLDivElement>(null)
- const isInitial = useRef(true)
- const inputValue = useRef(value ? value.tz(timezone) : undefined).current
- const defaultValue = useRef(getDateWithTimezone({ timezone })).current
- const [currentDate, setCurrentDate] = useState(inputValue || defaultValue)
- const [selectedDate, setSelectedDate] = useState(inputValue)
- const [selectedMonth, setSelectedMonth] = useState((inputValue || defaultValue).month())
- const [selectedYear, setSelectedYear] = useState((inputValue || defaultValue).year())
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
- setIsOpen(false)
- setView(ViewType.date)
- }
- }
- document.addEventListener('mousedown', handleClickOutside)
- return () => document.removeEventListener('mousedown', handleClickOutside)
- }, [])
- useEffect(() => {
- if (isInitial.current) {
- isInitial.current = false
- return
- }
- clearMonthMapCache()
- if (value) {
- const newValue = getDateWithTimezone({ date: value, timezone })
- setCurrentDate(newValue)
- setSelectedDate(newValue)
- onChange(newValue)
- }
- else {
- setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
- setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [timezone])
- const handleClickTrigger = (e: React.MouseEvent) => {
- e.stopPropagation()
- if (isOpen) {
- setIsOpen(false)
- return
- }
- setView(ViewType.date)
- setIsOpen(true)
- if (value) {
- setCurrentDate(value)
- setSelectedDate(value)
- }
- }
- const handleClear = (e: React.MouseEvent) => {
- e.stopPropagation()
- setSelectedDate(undefined)
- if (!isOpen)
- onClear()
- }
- const days = useMemo(() => {
- return getDaysInMonth(currentDate)
- }, [currentDate])
- const handleClickNextMonth = useCallback(() => {
- setCurrentDate(currentDate.clone().add(1, 'month'))
- }, [currentDate])
- const handleClickPrevMonth = useCallback(() => {
- setCurrentDate(currentDate.clone().subtract(1, 'month'))
- }, [currentDate])
- const handleDateSelect = useCallback((day: Dayjs) => {
- const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone }))
- setCurrentDate(newDate)
- setSelectedDate(newDate)
- }, [selectedDate, timezone])
- const handleSelectCurrentDate = () => {
- const newDate = getDateWithTimezone({ timezone })
- setCurrentDate(newDate)
- setSelectedDate(newDate)
- onChange(newDate)
- setIsOpen(false)
- }
- const handleConfirmDate = () => {
- onChange(selectedDate)
- setIsOpen(false)
- }
- const handleClickTimePicker = () => {
- if (view === ViewType.date) {
- setView(ViewType.time)
- return
- }
- if (view === ViewType.time)
- setView(ViewType.date)
- }
- const handleTimeSelect = (hour: string, minute: string, period: Period) => {
- const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
- setSelectedDate((prev) => {
- return prev ? cloneTime(prev, newTime) : newTime
- })
- }
- const handleSelectHour = useCallback((hour: string) => {
- const selectedTime = selectedDate || getDateWithTimezone({ timezone })
- handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
- }, [selectedDate, timezone])
- const handleSelectMinute = useCallback((minute: string) => {
- const selectedTime = selectedDate || getDateWithTimezone({ timezone })
- handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
- }, [selectedDate, timezone])
- const handleSelectPeriod = useCallback((period: Period) => {
- const selectedTime = selectedDate || getDateWithTimezone({ timezone })
- handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
- }, [selectedDate, timezone])
- const handleOpenYearMonthPicker = () => {
- setSelectedMonth(currentDate.month())
- setSelectedYear(currentDate.year())
- setView(ViewType.yearMonth)
- }
- const handleCloseYearMonthPicker = useCallback(() => {
- setView(ViewType.date)
- }, [])
- const handleMonthSelect = useCallback((month: number) => {
- setSelectedMonth(month)
- }, [])
- const handleYearSelect = useCallback((year: number) => {
- setSelectedYear(year)
- }, [])
- const handleYearMonthCancel = useCallback(() => {
- setView(ViewType.date)
- }, [])
- const handleYearMonthConfirm = () => {
- setCurrentDate(prev => prev.clone().month(selectedMonth).year(selectedYear))
- setView(ViewType.date)
- }
- const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
- const displayValue = value?.format(timeFormat) || ''
- const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
- const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
- return (
- <PortalToFollowElem
- open={isOpen}
- onOpenChange={setIsOpen}
- placement='bottom-end'
- >
- <PortalToFollowElemTrigger>
- {renderTrigger ? (renderTrigger({
- value,
- selectedDate,
- isOpen,
- handleClear,
- handleClickTrigger,
- })) : (
- <div
- className='w-[252px] flex items-center gap-x-0.5 rounded-lg px-2 py-1 bg-components-input-bg-normal cursor-pointer group hover:bg-state-base-hover-alt'
- onClick={handleClickTrigger}
- >
- <input
- className='flex-1 p-1 bg-transparent text-components-input-text-filled placeholder:text-components-input-text-placeholder truncate system-xs-regular
- outline-none appearance-none cursor-pointer'
- readOnly
- value={isOpen ? '' : displayValue}
- placeholder={placeholderDate}
- />
- <RiCalendarLine className={cn(
- 'shrink-0 w-4 h-4 text-text-quaternary',
- isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
- (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
- )} />
- <RiCloseCircleFill
- className={cn(
- 'hidden shrink-0 w-4 h-4 text-text-quaternary',
- (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary',
- )}
- onClick={handleClear}
- />
- </div>
- )}
- </PortalToFollowElemTrigger>
- <PortalToFollowElemContent className='z-50'>
- <div className='w-[252px] mt-1 bg-components-panel-bg rounded-xl shadow-lg shadow-shadow-shadow-5 border-[0.5px] border-components-panel-border'>
- {/* Header */}
- {view === ViewType.date ? (
- <DatePickerHeader
- handleOpenYearMonthPicker={handleOpenYearMonthPicker}
- currentDate={currentDate}
- onClickNextMonth={handleClickNextMonth}
- onClickPrevMonth={handleClickPrevMonth}
- />
- ) : view === ViewType.yearMonth ? (
- <YearAndMonthPickerHeader
- selectedYear={selectedYear}
- selectedMonth={selectedMonth}
- onClick={handleCloseYearMonthPicker}
- />
- ) : (
- <TimePickerHeader />
- )}
- {/* Content */}
- {
- view === ViewType.date ? (
- <Calendar
- days={days}
- selectedDate={selectedDate}
- onDateClick={handleDateSelect}
- />
- ) : view === ViewType.yearMonth ? (
- <YearAndMonthPickerOptions
- selectedMonth={selectedMonth}
- selectedYear={selectedYear}
- handleMonthSelect={handleMonthSelect}
- handleYearSelect={handleYearSelect}
- />
- ) : (
- <TimePickerOptions
- selectedTime={selectedDate}
- handleSelectHour={handleSelectHour}
- handleSelectMinute={handleSelectMinute}
- handleSelectPeriod={handleSelectPeriod}
- />
- )
- }
- {/* Footer */}
- {
- [ViewType.date, ViewType.time].includes(view) ? (
- <DatePickerFooter
- needTimePicker={needTimePicker}
- displayTime={displayTime}
- view={view}
- handleClickTimePicker={handleClickTimePicker}
- handleSelectCurrentDate={handleSelectCurrentDate}
- handleConfirmDate={handleConfirmDate}
- />
- ) : (
- <YearAndMonthPickerFooter
- handleYearMonthCancel={handleYearMonthCancel}
- handleYearMonthConfirm={handleYearMonthConfirm}
- />
- )
- }
- </div>
- </PortalToFollowElemContent>
- </PortalToFollowElem>
- )
- }
- export default DatePicker
|