feat: refactor date-and-time-picker to use custom dayjs utility and add timezone support (#15101)

This commit is contained in:
Wu Tianwei 2025-03-06 16:24:03 +08:00 committed by GitHub
parent 6101733232
commit 5be8fbab56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 102 additions and 38 deletions

View File

@ -1,7 +1,7 @@
import React, { type FC } from 'react' import React, { type FC } from 'react'
import type { CalendarItemProps } from '../types' import type { CalendarItemProps } from '../types'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import dayjs from 'dayjs' import dayjs from '../utils/dayjs'
const Item: FC<CalendarItemProps> = ({ const Item: FC<CalendarItemProps> = ({
day, day,

View File

@ -22,18 +22,18 @@ const Header: FC<DatePickerHeaderProps> = ({
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary' /> <RiArrowDownSLine className='w-4 h-4 text-text-tertiary' />
</button> </button>
</div> </div>
<button
onClick={onClickNextMonth}
className='p-1.5 hover:bg-state-base-hover rounded-lg'
>
<RiArrowDownSLine className='w-[18px] h-[18px] text-text-secondary' />
</button>
<button <button
onClick={onClickPrevMonth} onClick={onClickPrevMonth}
className='p-1.5 hover:bg-state-base-hover rounded-lg' className='p-1.5 hover:bg-state-base-hover rounded-lg'
> >
<RiArrowUpSLine className='w-[18px] h-[18px] text-text-secondary' /> <RiArrowUpSLine className='w-[18px] h-[18px] text-text-secondary' />
</button> </button>
<button
onClick={onClickNextMonth}
className='p-1.5 hover:bg-state-base-hover rounded-lg'
>
<RiArrowDownSLine className='w-[18px] h-[18px] text-text-secondary' />
</button>
</div> </div>
) )
} }

View File

@ -1,10 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dayjs, { type Dayjs } from 'dayjs'
import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react' import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import type { DatePickerProps, Period } from '../types' import type { DatePickerProps, Period } from '../types'
import { ViewType } from '../types' import { ViewType } from '../types'
import { cloneTime, getDaysInMonth, getHourIn12Hour } from '../utils' import type { Dayjs } from 'dayjs'
import dayjs, {
clearMonthMapCache,
cloneTime,
getDateWithTimezone,
getDaysInMonth,
getHourIn12Hour,
} from '../utils/dayjs'
import { import {
PortalToFollowElem, PortalToFollowElem,
PortalToFollowElemContent, PortalToFollowElemContent,
@ -22,6 +28,7 @@ import { useTranslation } from 'react-i18next'
const DatePicker = ({ const DatePicker = ({
value, value,
timezone,
onChange, onChange,
onClear, onClear,
placeholder, placeholder,
@ -32,12 +39,15 @@ const DatePicker = ({
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState(ViewType.date) const [view, setView] = useState(ViewType.date)
const containerRef = useRef<HTMLDivElement>(null) 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(value || dayjs()) const [currentDate, setCurrentDate] = useState(inputValue || defaultValue)
const [selectedDate, setSelectedDate] = useState(value) const [selectedDate, setSelectedDate] = useState(inputValue)
const [selectedMonth, setSelectedMonth] = useState((value || dayjs()).month()) const [selectedMonth, setSelectedMonth] = useState((inputValue || defaultValue).month())
const [selectedYear, setSelectedYear] = useState((value || dayjs()).year()) const [selectedYear, setSelectedYear] = useState((inputValue || defaultValue).year())
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -50,6 +60,25 @@ const DatePicker = ({
return () => document.removeEventListener('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) => { const handleClickTrigger = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (isOpen) { if (isOpen) {
@ -58,15 +87,15 @@ const DatePicker = ({
} }
setView(ViewType.date) setView(ViewType.date)
setIsOpen(true) setIsOpen(true)
if (value) {
setCurrentDate(value)
setSelectedDate(value)
}
} }
const handleClear = (e: React.MouseEvent) => { const handleClear = (e: React.MouseEvent) => {
const newDate = dayjs()
e.stopPropagation() e.stopPropagation()
setSelectedDate(undefined) setSelectedDate(undefined)
setCurrentDate(prev => prev || newDate)
setSelectedMonth(prev => prev || newDate.month())
setSelectedYear(prev => prev || newDate.year())
if (!isOpen) if (!isOpen)
onClear() onClear()
} }
@ -84,13 +113,13 @@ const DatePicker = ({
}, [currentDate]) }, [currentDate])
const handleDateSelect = useCallback((day: Dayjs) => { const handleDateSelect = useCallback((day: Dayjs) => {
const newDate = cloneTime(day, selectedDate || dayjs()) const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone }))
setCurrentDate(newDate) setCurrentDate(newDate)
setSelectedDate(newDate) setSelectedDate(newDate)
}, [selectedDate]) }, [selectedDate, timezone])
const handleSelectCurrentDate = () => { const handleSelectCurrentDate = () => {
const newDate = dayjs() const newDate = getDateWithTimezone({ timezone })
setCurrentDate(newDate) setCurrentDate(newDate)
setSelectedDate(newDate) setSelectedDate(newDate)
onChange(newDate) onChange(newDate)
@ -119,19 +148,19 @@ const DatePicker = ({
} }
const handleSelectHour = useCallback((hour: string) => { const handleSelectHour = useCallback((hour: string) => {
const selectedTime = selectedDate || dayjs() const selectedTime = selectedDate || getDateWithTimezone({ timezone })
handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period) handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
}, [selectedDate]) }, [selectedDate, timezone])
const handleSelectMinute = useCallback((minute: string) => { const handleSelectMinute = useCallback((minute: string) => {
const selectedTime = selectedDate || dayjs() const selectedTime = selectedDate || getDateWithTimezone({ timezone })
handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period) handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
}, [selectedDate]) }, [selectedDate, timezone])
const handleSelectPeriod = useCallback((period: Period) => { const handleSelectPeriod = useCallback((period: Period) => {
const selectedTime = selectedDate || dayjs() const selectedTime = selectedDate || getDateWithTimezone({ timezone })
handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period) handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
}, [selectedDate]) }, [selectedDate, timezone])
const handleOpenYearMonthPicker = () => { const handleOpenYearMonthPicker = () => {
setSelectedMonth(currentDate.month()) setSelectedMonth(currentDate.month())
@ -156,15 +185,13 @@ const DatePicker = ({
}, []) }, [])
const handleYearMonthConfirm = () => { const handleYearMonthConfirm = () => {
setCurrentDate((prev) => { setCurrentDate(prev => prev.clone().month(selectedMonth).year(selectedYear))
return prev ? prev.clone().month(selectedMonth).year(selectedYear) : dayjs().month(selectedMonth).year(selectedYear)
})
setView(ViewType.date) setView(ViewType.date)
} }
const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY' const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
const displayValue = value?.format(timeFormat) || '' const displayValue = value?.format(timeFormat) || ''
const displayTime = (selectedDate || dayjs().startOf('day')).format('hh:mm A') const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder')) const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
return ( return (

View File

@ -1,4 +1,4 @@
import dayjs from 'dayjs' import dayjs from './utils/dayjs'
import { Period } from './types' import { Period } from './types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import type { Period, TimePickerProps } from '../types' import type { Period, TimePickerProps } from '../types'
import { cloneTime, getHourIn12Hour } from '../utils' import dayjs, { cloneTime, getDateWithTimezone, getHourIn12Hour } from '../utils/dayjs'
import { import {
PortalToFollowElem, PortalToFollowElem,
PortalToFollowElemContent, PortalToFollowElemContent,
@ -16,6 +15,7 @@ import cn from '@/utils/classnames'
const TimePicker = ({ const TimePicker = ({
value, value,
timezone,
placeholder, placeholder,
onChange, onChange,
onClear, onClear,
@ -24,7 +24,8 @@ const TimePicker = ({
const { t } = useTranslation() const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [selectedTime, setSelectedTime] = useState(value) const isInitial = useRef(true)
const [selectedTime, setSelectedTime] = useState(value ? getDateWithTimezone({ timezone, date: value }) : undefined)
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -35,6 +36,22 @@ const TimePicker = ({
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, []) }, [])
useEffect(() => {
if (isInitial.current) {
isInitial.current = false
return
}
if (value) {
const newValue = getDateWithTimezone({ date: value, timezone })
setSelectedTime(newValue)
onChange(newValue)
}
else {
setSelectedTime(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timezone])
const handleClickTrigger = (e: React.MouseEvent) => { const handleClickTrigger = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (isOpen) { if (isOpen) {
@ -42,6 +59,8 @@ const TimePicker = ({
return return
} }
setIsOpen(true) setIsOpen(true)
if (value)
setSelectedTime(value)
} }
const handleClear = (e: React.MouseEvent) => { const handleClear = (e: React.MouseEvent) => {
@ -74,11 +93,11 @@ const TimePicker = ({
}, [selectedTime]) }, [selectedTime])
const handleSelectCurrentTime = useCallback(() => { const handleSelectCurrentTime = useCallback(() => {
const newDate = dayjs() const newDate = getDateWithTimezone({ timezone })
setSelectedTime(newDate) setSelectedTime(newDate)
onChange(newDate) onChange(newDate)
setIsOpen(false) setIsOpen(false)
}, [onChange]) }, [onChange, timezone])
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
onChange(selectedTime) onChange(selectedTime)

View File

@ -21,6 +21,7 @@ type TriggerProps = {
export type DatePickerProps = { export type DatePickerProps = {
value: Dayjs | undefined value: Dayjs | undefined
timezone?: string
placeholder?: string placeholder?: string
needTimePicker?: boolean needTimePicker?: boolean
onChange: (date: Dayjs | undefined) => void onChange: (date: Dayjs | undefined) => void
@ -46,6 +47,7 @@ export type DatePickerFooterProps = {
export type TimePickerProps = { export type TimePickerProps = {
value: Dayjs | undefined value: Dayjs | undefined
timezone?: string
placeholder?: string placeholder?: string
onChange: (date: Dayjs | undefined) => void onChange: (date: Dayjs | undefined) => void
onClear: () => void onClear: () => void

View File

@ -1,5 +1,12 @@
import type { Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import type { Day } from './types' import type { Day } from '../types'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export default dayjs
const monthMaps: Record<string, Day[]> = {} const monthMaps: Record<string, Day[]> = {}
@ -58,7 +65,16 @@ export const getDaysInMonth = (currentDate: Dayjs) => {
return days return days
} }
export const clearMonthMapCache = () => {
for (const key in monthMaps)
delete monthMaps[key]
}
export const getHourIn12Hour = (date: Dayjs) => { export const getHourIn12Hour = (date: Dayjs) => {
const hour = date.hour() const hour = date.hour()
return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour
} }
export const getDateWithTimezone = (props: { date?: Dayjs, timezone?: string }) => {
return props.date ? dayjs.tz(props.date, props.timezone) : dayjs().tz(props.timezone)
}