dify
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { useDaysOfWeek } from '../hooks'
|
||||
|
||||
export const DaysOfWeek = () => {
|
||||
const daysOfWeek = useDaysOfWeek()
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-7 gap-x-0.5 border-b-[0.5px] border-divider-regular p-2'>
|
||||
{daysOfWeek.map(day => (
|
||||
<div
|
||||
key={day}
|
||||
className='system-2xs-medium flex items-center justify-center text-text-tertiary'
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DaysOfWeek)
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CalendarProps } from '../types'
|
||||
import { DaysOfWeek } from './days-of-week'
|
||||
import CalendarItem from './item'
|
||||
|
||||
const Calendar: FC<CalendarProps> = ({
|
||||
days,
|
||||
selectedDate,
|
||||
onDateClick,
|
||||
wrapperClassName,
|
||||
getIsDateDisabled,
|
||||
}) => {
|
||||
return <div className={wrapperClassName}>
|
||||
<DaysOfWeek />
|
||||
<div className='grid grid-cols-7 gap-0.5 p-2'>
|
||||
{
|
||||
days.map(day => <CalendarItem
|
||||
key={day.date.format('YYYY-MM-DD')}
|
||||
day={day}
|
||||
selectedDate={selectedDate}
|
||||
onClick={onDateClick}
|
||||
isDisabled={getIsDateDisabled ? getIsDateDisabled(day.date) : false}
|
||||
/>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Calendar
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { type FC } from 'react'
|
||||
import type { CalendarItemProps } from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import dayjs from '../utils/dayjs'
|
||||
|
||||
const Item: FC<CalendarItemProps> = ({
|
||||
day,
|
||||
selectedDate,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}) => {
|
||||
const { date, isCurrentMonth } = day
|
||||
const isSelected = selectedDate?.isSame(date, 'date')
|
||||
const isToday = date.isSame(dayjs(), 'date')
|
||||
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={() => !isDisabled && onClick(date)}
|
||||
className={cn(
|
||||
'system-sm-medium relative flex items-center justify-center rounded-lg px-1 py-2',
|
||||
isCurrentMonth ? 'text-text-secondary' : 'text-text-quaternary hover:text-text-secondary',
|
||||
isSelected ? 'system-sm-medium bg-components-button-primary-bg text-components-button-primary-text' : 'hover:bg-state-base-hover',
|
||||
isDisabled && 'cursor-not-allowed text-text-quaternary hover:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{date.date()}
|
||||
{isToday && <div className='absolute bottom-1 mx-auto h-1 w-1 rounded-full bg-components-button-primary-bg' />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { type FC, useEffect, useRef } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OptionListItemProps = {
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
noAutoScroll?: boolean
|
||||
} & React.LiHTMLAttributes<HTMLLIElement>
|
||||
|
||||
const OptionListItem: FC<OptionListItemProps> = ({
|
||||
isSelected,
|
||||
onClick,
|
||||
noAutoScroll,
|
||||
children,
|
||||
}) => {
|
||||
const listItemRef = useRef<HTMLLIElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && !noAutoScroll)
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'instant' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
className={cn(
|
||||
'system-xs-medium flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 text-components-button-ghost-text',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionListItem)
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { type FC } from 'react'
|
||||
import Button from '../../button'
|
||||
import { type DatePickerFooterProps, ViewType } from '../types'
|
||||
import { RiTimeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Footer: FC<DatePickerFooterProps> = ({
|
||||
needTimePicker,
|
||||
displayTime,
|
||||
view,
|
||||
handleClickTimePicker,
|
||||
handleSelectCurrentDate,
|
||||
handleConfirmDate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between border-t-[0.5px] border-divider-regular p-2',
|
||||
!needTimePicker && 'justify-end',
|
||||
)}>
|
||||
{/* Time Picker */}
|
||||
{needTimePicker && (
|
||||
<button
|
||||
type='button'
|
||||
className='system-xs-medium flex items-center gap-x-[1px] rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5
|
||||
py-1 text-components-button-secondary-accent-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]'
|
||||
onClick={handleClickTimePicker}
|
||||
>
|
||||
<RiTimeLine className='h-3.5 w-3.5' />
|
||||
{view === ViewType.date && <span>{displayTime}</span>}
|
||||
{view === ViewType.time && <span>{t('time.operation.pickDate')}</span>}
|
||||
</button>
|
||||
)}
|
||||
<div className='flex items-center gap-x-1'>
|
||||
{/* Now */}
|
||||
<button
|
||||
type='button'
|
||||
className='system-xs-medium flex items-center justify-center px-1.5 py-1 text-components-button-secondary-accent-text'
|
||||
onClick={handleSelectCurrentDate}
|
||||
>
|
||||
<span className='px-[3px]'>{t('time.operation.now')}</span>
|
||||
</button>
|
||||
{/* Confirm Button */}
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
className='w-16 px-1.5 py-1'
|
||||
onClick={handleConfirmDate}
|
||||
>
|
||||
{t('time.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Footer)
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import type { DatePickerHeaderProps } from '../types'
|
||||
import { useMonths } from '../hooks'
|
||||
|
||||
const Header: FC<DatePickerHeaderProps> = ({
|
||||
handleOpenYearMonthPicker,
|
||||
currentDate,
|
||||
onClickNextMonth,
|
||||
onClickPrevMonth,
|
||||
}) => {
|
||||
const months = useMonths()
|
||||
|
||||
return (
|
||||
<div className='mx-2 mt-2 flex items-center'>
|
||||
<div className='flex-1'>
|
||||
<button type="button"
|
||||
onClick={handleOpenYearMonthPicker}
|
||||
className='system-md-semibold flex items-center gap-x-0.5 rounded-lg px-2 py-1.5 text-text-primary hover:bg-state-base-hover'
|
||||
>
|
||||
<span>{`${months[currentDate.month()]} ${currentDate.year()}`}</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onClickPrevMonth}
|
||||
className='rounded-lg p-1.5 hover:bg-state-base-hover'
|
||||
>
|
||||
<RiArrowUpSLine className='h-[18px] w-[18px] text-text-secondary' />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onClickNextMonth}
|
||||
className='rounded-lg p-1.5 hover:bg-state-base-hover'
|
||||
>
|
||||
<RiArrowDownSLine className='h-[18px] w-[18px] text-text-secondary' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,324 @@
|
||||
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,
|
||||
triggerWrapClassName,
|
||||
popupZIndexClassname = 'z-[11]',
|
||||
noConfirm,
|
||||
getIsDateDisabled,
|
||||
}: DatePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [view, setView] = useState(ViewType.date)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
|
||||
// Normalize the value to ensure that all subsequent uses are Day.js objects.
|
||||
const normalizedValue = useMemo(() => {
|
||||
if (!value) return undefined
|
||||
return dayjs.isDayjs(value) ? value.tz(timezone) : dayjs(value).tz(timezone)
|
||||
}, [value, timezone])
|
||||
|
||||
const inputValue = useRef(normalizedValue).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 (normalizedValue) {
|
||||
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
|
||||
setCurrentDate(newValue)
|
||||
setSelectedDate(newValue)
|
||||
onChange(newValue)
|
||||
}
|
||||
else {
|
||||
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
|
||||
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
|
||||
}
|
||||
}, [timezone])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
setView(ViewType.date)
|
||||
setIsOpen(true)
|
||||
if (normalizedValue) {
|
||||
setCurrentDate(normalizedValue)
|
||||
setSelectedDate(normalizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
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 handleConfirmDate = useCallback((passedInSelectedDate?: Dayjs) => {
|
||||
// passedInSelectedDate may be a click event when noConfirm is false
|
||||
const nextDate = (dayjs.isDayjs(passedInSelectedDate) ? passedInSelectedDate : selectedDate)
|
||||
onChange(nextDate ? nextDate.tz(timezone) : undefined)
|
||||
setIsOpen(false)
|
||||
}, [selectedDate, onChange, timezone])
|
||||
|
||||
const handleDateSelect = useCallback((day: Dayjs) => {
|
||||
const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone }))
|
||||
setCurrentDate(newDate)
|
||||
setSelectedDate(newDate)
|
||||
if (noConfirm)
|
||||
handleConfirmDate(newDate)
|
||||
}, [selectedDate, timezone, noConfirm, handleConfirmDate])
|
||||
|
||||
const handleSelectCurrentDate = () => {
|
||||
const newDate = getDateWithTimezone({ timezone })
|
||||
setCurrentDate(newDate)
|
||||
setSelectedDate(newDate)
|
||||
onChange(newDate)
|
||||
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 ? t('time.dateFormats.displayWithTime') : t('time.dateFormats.display')
|
||||
const displayValue = normalizedValue?.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 className={triggerWrapClassName}>
|
||||
{renderTrigger ? (renderTrigger({
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
handleClickTrigger,
|
||||
})) : (
|
||||
<div
|
||||
className='group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt'
|
||||
onClick={handleClickTrigger}
|
||||
>
|
||||
<input
|
||||
className='system-xs-regular flex-1 cursor-pointer appearance-none truncate bg-transparent p-1
|
||||
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder'
|
||||
readOnly
|
||||
value={isOpen ? '' : displayValue}
|
||||
placeholder={placeholderDate}
|
||||
/>
|
||||
<RiCalendarLine className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary',
|
||||
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
|
||||
)} />
|
||||
<RiCloseCircleFill
|
||||
className={cn(
|
||||
'hidden h-4 w-4 shrink-0 text-text-quaternary',
|
||||
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
|
||||
)}
|
||||
onClick={handleClear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupZIndexClassname}>
|
||||
<div className='mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'>
|
||||
{/* 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}
|
||||
getIsDateDisabled={getIsDateDisabled}
|
||||
/>
|
||||
) : 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) && !noConfirm && (
|
||||
<DatePickerFooter
|
||||
needTimePicker={needTimePicker}
|
||||
displayTime={displayTime}
|
||||
view={view}
|
||||
handleClickTimePicker={handleClickTimePicker}
|
||||
handleSelectCurrentDate={handleSelectCurrentDate}
|
||||
handleConfirmDate={handleConfirmDate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
![ViewType.date, ViewType.time].includes(view) && (
|
||||
<YearAndMonthPickerFooter
|
||||
handleYearMonthCancel={handleYearMonthCancel}
|
||||
handleYearMonthConfirm={handleYearMonthConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatePicker
|
||||
49
dify/web/app/components/base/date-and-time-picker/hooks.ts
Normal file
49
dify/web/app/components/base/date-and-time-picker/hooks.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import dayjs from './utils/dayjs'
|
||||
import { Period } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const YEAR_RANGE = 100
|
||||
|
||||
export const useDaysOfWeek = () => {
|
||||
const { t } = useTranslation()
|
||||
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}`))
|
||||
|
||||
return daysOfWeek
|
||||
}
|
||||
|
||||
export const useMonths = () => {
|
||||
const { t } = useTranslation()
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
].map(month => t(`time.months.${month}`))
|
||||
|
||||
return months
|
||||
}
|
||||
|
||||
export const useYearOptions = () => {
|
||||
const yearOptions = Array.from({ length: 200 }, (_, i) => dayjs().year() - YEAR_RANGE / 2 + i)
|
||||
return yearOptions
|
||||
}
|
||||
|
||||
export const useTimeOptions = () => {
|
||||
const hourOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'))
|
||||
const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'))
|
||||
const periodOptions = [Period.AM, Period.PM]
|
||||
|
||||
return {
|
||||
hourOptions,
|
||||
minuteOptions,
|
||||
periodOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { fn } from 'storybook/test'
|
||||
import { useState } from 'react'
|
||||
import DatePicker from './date-picker'
|
||||
import dayjs from './utils/dayjs'
|
||||
import { getDateWithTimezone } from './utils/dayjs'
|
||||
import type { DatePickerProps } from './types'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/DateAndTimePicker',
|
||||
component: DatePicker,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
value: getDateWithTimezone({}),
|
||||
timezone: dayjs.tz.guess(),
|
||||
needTimePicker: true,
|
||||
placeholder: 'Select schedule time',
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof DatePicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const DatePickerPlayground = (props: DatePickerProps) => {
|
||||
const [value, setValue] = useState(props.value)
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-start gap-3">
|
||||
<DatePicker popupZIndexClassname="z-50"
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onClear={() => setValue(undefined)}
|
||||
/>
|
||||
<div className="w-[252px] rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary">
|
||||
Selected datetime: <span className="font-mono text-text-primary">{value ? value.format() : 'undefined'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DatePickerPlayground {...args} />,
|
||||
args: {
|
||||
...meta.args,
|
||||
needTimePicker: false,
|
||||
placeholder: 'Select due date',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [value, setValue] = useState(getDateWithTimezone({}))
|
||||
|
||||
<DatePicker
|
||||
popupZIndexClassname="z-50"
|
||||
value={value}
|
||||
timezone={dayjs.tz.guess()}
|
||||
onChange={setValue}
|
||||
onClear={() => setValue(undefined)}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DateOnly: Story = {
|
||||
render: args => (
|
||||
<DatePickerPlayground
|
||||
{...args}
|
||||
needTimePicker={false}
|
||||
placeholder="Select due date"
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
...meta.args,
|
||||
needTimePicker: false,
|
||||
placeholder: 'Select due date',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<DatePicker needTimePicker={false} placeholder="Select due date" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { type FC } from 'react'
|
||||
import type { TimePickerFooterProps } from '../types'
|
||||
import Button from '../../button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Footer: FC<TimePickerFooterProps> = ({
|
||||
handleSelectCurrentTime,
|
||||
handleConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between border-t-[0.5px] border-divider-regular p-2'>
|
||||
{/* Now Button */}
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
size='small'
|
||||
className='mr-1 flex-1'
|
||||
onClick={handleSelectCurrentTime}
|
||||
>
|
||||
{t('time.operation.now')}
|
||||
</Button>
|
||||
{/* Confirm Button */}
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
className='ml-1 flex-1'
|
||||
onClick={handleConfirm.bind(null)}
|
||||
>
|
||||
{t('time.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Footer)
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
title?: string
|
||||
}
|
||||
const Header = ({
|
||||
title,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col border-b-[0.5px] border-divider-regular'>
|
||||
<div className='system-md-semibold flex items-center px-2 py-1.5 text-text-primary'>
|
||||
{title || t('time.title.pickTime')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TimePicker from './index'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import { isDayjsObject } from '../utils/dayjs'
|
||||
import type { TimePickerProps } from '../types'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key === 'time.defaultPlaceholder') return 'Pick a time...'
|
||||
if (key === 'time.operation.now') return 'Now'
|
||||
if (key === 'time.operation.ok') return 'OK'
|
||||
if (key === 'common.operation.clear') return 'Clear'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: (e: React.MouseEvent) => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="timepicker-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('./options', () => () => <div data-testid="time-options" />)
|
||||
jest.mock('./header', () => () => <div data-testid="time-header" />)
|
||||
jest.mock('@/app/components/base/timezone-label', () => {
|
||||
return function MockTimezoneLabel({ timezone, inline, className }: { timezone: string, inline?: boolean, className?: string }) {
|
||||
return (
|
||||
<span data-testid="timezone-label" data-timezone={timezone} data-inline={inline} className={className}>
|
||||
UTC+8
|
||||
</span>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('TimePicker', () => {
|
||||
const baseProps: Pick<TimePickerProps, 'onChange' | 'onClear' | 'value'> = {
|
||||
onChange: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
value: undefined,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders formatted value for string input (Issue #26692 regression)', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="18:45"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('06:45 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('confirms cleared value when confirming without selection', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value={dayjs('2024-01-01T03:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.click(input)
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'OK' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledTimes(1)
|
||||
expect(baseProps.onChange).toHaveBeenCalledWith(undefined)
|
||||
expect(baseProps.onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('selecting current time emits timezone-aware value', () => {
|
||||
const onChange = jest.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
const nowButton = screen.getByRole('button', { name: 'Now' })
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted?.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
})
|
||||
|
||||
describe('Timezone Label Integration', () => {
|
||||
test('should not display timezone label by default', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
timezone="Asia/Shanghai"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should not display timezone label when showTimezone is false', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
timezone="Asia/Shanghai"
|
||||
showTimezone={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should display timezone label when showTimezone is true', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
timezone="Asia/Shanghai"
|
||||
showTimezone={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const timezoneLabel = screen.getByTestId('timezone-label')
|
||||
expect(timezoneLabel).toBeInTheDocument()
|
||||
expect(timezoneLabel).toHaveAttribute('data-timezone', 'Asia/Shanghai')
|
||||
})
|
||||
|
||||
test('should pass inline prop to timezone label', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
timezone="America/New_York"
|
||||
showTimezone={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const timezoneLabel = screen.getByTestId('timezone-label')
|
||||
expect(timezoneLabel).toHaveAttribute('data-inline', 'true')
|
||||
})
|
||||
|
||||
test('should not display timezone label when showTimezone is true but timezone is not provided', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
showTimezone={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('timezone-label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should apply shrink-0 and text-xs classes to timezone label', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="12:00 AM"
|
||||
timezone="Europe/London"
|
||||
showTimezone={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
const timezoneLabel = screen.getByTestId('timezone-label')
|
||||
expect(timezoneLabel).toHaveClass('shrink-0', 'text-xs')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { Period } from '../types'
|
||||
import type { TimePickerProps } from '../types'
|
||||
import dayjs, {
|
||||
getDateWithTimezone,
|
||||
getHourIn12Hour,
|
||||
isDayjsObject,
|
||||
toDayjs,
|
||||
} from '../utils/dayjs'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Footer from './footer'
|
||||
import Options from './options'
|
||||
import Header from './header'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import TimezoneLabel from '@/app/components/base/timezone-label'
|
||||
|
||||
const to24Hour = (hour12: string, period: Period) => {
|
||||
const normalized = Number.parseInt(hour12, 10) % 12
|
||||
return period === Period.PM ? normalized + 12 : normalized
|
||||
}
|
||||
|
||||
const TimePicker = ({
|
||||
value,
|
||||
timezone,
|
||||
placeholder,
|
||||
onChange,
|
||||
onClear,
|
||||
renderTrigger,
|
||||
title,
|
||||
minuteFilter,
|
||||
popupClassName,
|
||||
notClearable = false,
|
||||
triggerFullWidth = false,
|
||||
showTimezone = false,
|
||||
placement = 'bottom-start',
|
||||
}: TimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
|
||||
// Initialize selectedTime
|
||||
const [selectedTime, setSelectedTime] = useState(() => {
|
||||
return toDayjs(value, { timezone })
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node))
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Track previous values to avoid unnecessary updates
|
||||
const prevValueRef = useRef(value)
|
||||
const prevTimezoneRef = useRef(timezone)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitial.current) {
|
||||
isInitial.current = false
|
||||
// Save initial values on first render
|
||||
prevValueRef.current = value
|
||||
prevTimezoneRef.current = timezone
|
||||
return
|
||||
}
|
||||
|
||||
// Only update when timezone changes but value doesn't
|
||||
const valueChanged = prevValueRef.current !== value
|
||||
const timezoneChanged = prevTimezoneRef.current !== timezone
|
||||
|
||||
// Update reference values
|
||||
prevValueRef.current = value
|
||||
prevTimezoneRef.current = timezone
|
||||
|
||||
// Skip if neither timezone changed nor value changed
|
||||
if (!timezoneChanged && !valueChanged) return
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
const dayjsValue = toDayjs(value, { timezone })
|
||||
if (!dayjsValue) return
|
||||
|
||||
setSelectedTime(dayjsValue)
|
||||
|
||||
if (timezoneChanged && !valueChanged)
|
||||
onChange(dayjsValue)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedTime((prev) => {
|
||||
if (!isDayjsObject(prev))
|
||||
return undefined
|
||||
return timezone ? getDateWithTimezone({ date: prev, timezone }) : prev
|
||||
})
|
||||
}, [timezone, value, onChange])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
setIsOpen(true)
|
||||
|
||||
if (value) {
|
||||
const dayjsValue = toDayjs(value, { timezone })
|
||||
const needsUpdate = dayjsValue && (
|
||||
!selectedTime
|
||||
|| !isDayjsObject(selectedTime)
|
||||
|| !dayjsValue.isSame(selectedTime, 'minute')
|
||||
)
|
||||
if (needsUpdate) setSelectedTime(dayjsValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setSelectedTime(undefined)
|
||||
if (!isOpen)
|
||||
onClear()
|
||||
}
|
||||
|
||||
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
||||
const periodAdjustedHour = to24Hour(hour, period)
|
||||
const nextMinute = Number.parseInt(minute, 10)
|
||||
setSelectedTime((prev) => {
|
||||
const reference = isDayjsObject(prev)
|
||||
? prev
|
||||
: (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('minute')
|
||||
return reference
|
||||
.set('hour', periodAdjustedHour)
|
||||
.set('minute', nextMinute)
|
||||
.set('second', 0)
|
||||
.set('millisecond', 0)
|
||||
})
|
||||
}
|
||||
|
||||
const getSafeTimeObject = useCallback(() => {
|
||||
if (isDayjsObject(selectedTime))
|
||||
return selectedTime
|
||||
return (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('day')
|
||||
}, [selectedTime, timezone])
|
||||
|
||||
const handleSelectHour = useCallback((hour: string) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
||||
}, [getSafeTimeObject])
|
||||
|
||||
const handleSelectMinute = useCallback((minute: string) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
||||
}, [getSafeTimeObject])
|
||||
|
||||
const handleSelectPeriod = useCallback((period: Period) => {
|
||||
const time = getSafeTimeObject()
|
||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
||||
}, [getSafeTimeObject])
|
||||
|
||||
const handleSelectCurrentTime = useCallback(() => {
|
||||
const newDate = getDateWithTimezone({ timezone })
|
||||
setSelectedTime(newDate)
|
||||
onChange(newDate)
|
||||
setIsOpen(false)
|
||||
}, [timezone, onChange])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const valueToEmit = isDayjsObject(selectedTime) ? selectedTime : undefined
|
||||
onChange(valueToEmit)
|
||||
setIsOpen(false)
|
||||
}, [selectedTime, onChange])
|
||||
|
||||
const timeFormat = 'hh:mm A'
|
||||
|
||||
const formatTimeValue = useCallback((timeValue: string | Dayjs | undefined): string => {
|
||||
if (!timeValue) return ''
|
||||
|
||||
const dayjsValue = toDayjs(timeValue, { timezone })
|
||||
return dayjsValue?.format(timeFormat) || ''
|
||||
}, [timezone])
|
||||
|
||||
const displayValue = formatTimeValue(value)
|
||||
|
||||
const placeholderDate = isOpen && isDayjsObject(selectedTime)
|
||||
? selectedTime.format(timeFormat)
|
||||
: (placeholder || t('time.defaultPlaceholder'))
|
||||
|
||||
const inputElem = (
|
||||
<input
|
||||
className='system-xs-regular flex-1 cursor-pointer select-none appearance-none truncate bg-transparent p-1
|
||||
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder'
|
||||
readOnly
|
||||
value={isOpen ? '' : displayValue}
|
||||
placeholder={placeholderDate}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
placement={placement}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerFullWidth ? '!block w-full' : undefined}>
|
||||
{renderTrigger ? (renderTrigger({
|
||||
inputElem,
|
||||
onClick: handleClickTrigger,
|
||||
isOpen,
|
||||
})) : (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
|
||||
triggerFullWidth ? 'w-full min-w-0' : 'w-[252px]',
|
||||
)}
|
||||
onClick={handleClickTrigger}
|
||||
>
|
||||
{inputElem}
|
||||
{showTimezone && timezone && (
|
||||
<TimezoneLabel timezone={timezone} inline className='shrink-0 select-none text-xs' />
|
||||
)}
|
||||
<RiTimeLine className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary',
|
||||
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||
(displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden',
|
||||
)} />
|
||||
<RiCloseCircleFill
|
||||
className={cn(
|
||||
'hidden h-4 w-4 shrink-0 text-text-quaternary',
|
||||
(displayValue || (isOpen && selectedTime)) && !notClearable && 'hover:text-text-secondary group-hover:inline-block',
|
||||
)}
|
||||
role='button'
|
||||
aria-label={t('common.operation.clear')}
|
||||
onClick={handleClear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
|
||||
<div className='mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'>
|
||||
{/* Header */}
|
||||
<Header title={title} />
|
||||
|
||||
{/* Time Options */}
|
||||
<Options
|
||||
selectedTime={selectedTime}
|
||||
minuteFilter={minuteFilter}
|
||||
handleSelectHour={handleSelectHour}
|
||||
handleSelectMinute={handleSelectMinute}
|
||||
handleSelectPeriod={handleSelectPeriod}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer
|
||||
handleSelectCurrentTime={handleSelectCurrentTime}
|
||||
handleConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePicker
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { useTimeOptions } from '../hooks'
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
|
||||
const Options: FC<TimeOptionsProps> = ({
|
||||
selectedTime,
|
||||
minuteFilter,
|
||||
handleSelectHour,
|
||||
handleSelectMinute,
|
||||
handleSelectPeriod,
|
||||
}) => {
|
||||
const { hourOptions, minuteOptions, periodOptions } = useTimeOptions()
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-x-1 p-2'>
|
||||
{/* Hour */}
|
||||
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
|
||||
{
|
||||
hourOptions.map((hour) => {
|
||||
const isSelected = selectedTime?.format('hh') === hour
|
||||
return (
|
||||
<OptionListItem
|
||||
key={hour}
|
||||
isSelected={isSelected}
|
||||
onClick={handleSelectHour.bind(null, hour)}
|
||||
>
|
||||
{hour}
|
||||
</OptionListItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
{/* Minute */}
|
||||
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
|
||||
{
|
||||
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
|
||||
const isSelected = selectedTime?.format('mm') === minute
|
||||
return (
|
||||
<OptionListItem
|
||||
key={minute}
|
||||
isSelected={isSelected}
|
||||
onClick={handleSelectMinute.bind(null, minute)}
|
||||
>
|
||||
{minute}
|
||||
</OptionListItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
{/* Period */}
|
||||
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
|
||||
{
|
||||
periodOptions.map((period) => {
|
||||
const isSelected = selectedTime?.format('A') === period
|
||||
return (
|
||||
<OptionListItem
|
||||
key={period}
|
||||
isSelected={isSelected}
|
||||
onClick={handleSelectPeriod.bind(null, period)}
|
||||
noAutoScroll // if choose PM which would hide(scrolled) AM that may make user confused that there's no am.
|
||||
>
|
||||
{period}
|
||||
</OptionListItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Options)
|
||||
124
dify/web/app/components/base/date-and-time-picker/types.ts
Normal file
124
dify/web/app/components/base/date-and-time-picker/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
|
||||
export enum ViewType {
|
||||
date = 'date',
|
||||
yearMonth = 'yearMonth',
|
||||
time = 'time',
|
||||
}
|
||||
|
||||
export enum Period {
|
||||
AM = 'AM',
|
||||
PM = 'PM',
|
||||
}
|
||||
|
||||
export type TriggerProps = {
|
||||
value: Dayjs | undefined
|
||||
selectedDate: Dayjs | undefined
|
||||
isOpen: boolean
|
||||
handleClear: (e: React.MouseEvent) => void
|
||||
handleClickTrigger: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export type DatePickerProps = {
|
||||
value: Dayjs | undefined
|
||||
timezone?: string
|
||||
placeholder?: string
|
||||
needTimePicker?: boolean
|
||||
onChange: (date: Dayjs | undefined) => void
|
||||
onClear: () => void
|
||||
triggerWrapClassName?: string
|
||||
renderTrigger?: (props: TriggerProps) => React.ReactNode
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
popupZIndexClassname?: string
|
||||
noConfirm?: boolean
|
||||
getIsDateDisabled?: (date: Dayjs) => boolean
|
||||
}
|
||||
|
||||
export type DatePickerHeaderProps = {
|
||||
handleOpenYearMonthPicker: () => void
|
||||
currentDate: Dayjs
|
||||
onClickNextMonth: () => void
|
||||
onClickPrevMonth: () => void
|
||||
}
|
||||
|
||||
export type DatePickerFooterProps = {
|
||||
needTimePicker: boolean
|
||||
displayTime: string
|
||||
view: ViewType
|
||||
handleClickTimePicker: () => void
|
||||
handleSelectCurrentDate: () => void
|
||||
handleConfirmDate: () => void
|
||||
}
|
||||
|
||||
export type TriggerParams = {
|
||||
isOpen: boolean
|
||||
inputElem: React.ReactNode
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
export type TimePickerProps = {
|
||||
value: Dayjs | string | undefined
|
||||
timezone?: string
|
||||
placeholder?: string
|
||||
onChange: (date: Dayjs | undefined) => void
|
||||
onClear: () => void
|
||||
renderTrigger?: (props: TriggerParams) => React.ReactNode
|
||||
title?: string
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
popupClassName?: string
|
||||
notClearable?: boolean
|
||||
triggerFullWidth?: boolean
|
||||
showTimezone?: boolean
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
export type TimePickerFooterProps = {
|
||||
handleSelectCurrentTime: () => void
|
||||
handleConfirm: () => void
|
||||
}
|
||||
|
||||
export type Day = {
|
||||
date: Dayjs
|
||||
isCurrentMonth: boolean
|
||||
}
|
||||
|
||||
export type CalendarProps = {
|
||||
days: Day[]
|
||||
selectedDate: Dayjs | undefined
|
||||
onDateClick: (date: Dayjs) => void
|
||||
wrapperClassName?: string
|
||||
getIsDateDisabled?: (date: Dayjs) => boolean
|
||||
}
|
||||
|
||||
export type CalendarItemProps = {
|
||||
day: Day
|
||||
selectedDate: Dayjs | undefined
|
||||
onClick: (date: Dayjs) => void
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export type TimeOptionsProps = {
|
||||
selectedTime: Dayjs | undefined
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
handleSelectHour: (hour: string) => void
|
||||
handleSelectMinute: (minute: string) => void
|
||||
handleSelectPeriod: (period: Period) => void
|
||||
}
|
||||
|
||||
export type YearAndMonthPickerHeaderProps = {
|
||||
selectedYear: number
|
||||
selectedMonth: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export type YearAndMonthPickerOptionsProps = {
|
||||
selectedYear: number
|
||||
selectedMonth: number
|
||||
handleYearSelect: (year: number) => void
|
||||
handleMonthSelect: (month: number) => void
|
||||
}
|
||||
|
||||
export type YearAndMonthPickerFooterProps = {
|
||||
handleYearMonthCancel: () => void
|
||||
handleYearMonthConfirm: () => void
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import dayjs from './dayjs'
|
||||
import {
|
||||
convertTimezoneToOffsetStr,
|
||||
getDateWithTimezone,
|
||||
isDayjsObject,
|
||||
toDayjs,
|
||||
} from './dayjs'
|
||||
|
||||
describe('dayjs utilities', () => {
|
||||
const timezone = 'UTC'
|
||||
|
||||
test('toDayjs parses time-only strings with timezone support', () => {
|
||||
const result = toDayjs('18:45', { timezone })
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('HH:mm')).toBe('18:45')
|
||||
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone }).utcOffset())
|
||||
})
|
||||
|
||||
test('toDayjs parses 12-hour time strings', () => {
|
||||
const tz = 'America/New_York'
|
||||
const result = toDayjs('07:15 PM', { timezone: tz })
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('HH:mm')).toBe('19:15')
|
||||
expect(result?.utcOffset()).toBe(getDateWithTimezone({ timezone: tz }).utcOffset())
|
||||
})
|
||||
|
||||
test('isDayjsObject detects dayjs instances', () => {
|
||||
const date = dayjs()
|
||||
expect(isDayjsObject(date)).toBe(true)
|
||||
expect(isDayjsObject(getDateWithTimezone({ timezone }))).toBe(true)
|
||||
expect(isDayjsObject('2024-01-01')).toBe(false)
|
||||
expect(isDayjsObject({})).toBe(false)
|
||||
})
|
||||
|
||||
test('toDayjs parses datetime strings in target timezone', () => {
|
||||
const value = '2024-05-01 12:00:00'
|
||||
const tz = 'America/New_York'
|
||||
|
||||
const result = toDayjs(value, { timezone: tz })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(12)
|
||||
expect(result?.format('YYYY-MM-DD HH:mm')).toBe('2024-05-01 12:00')
|
||||
})
|
||||
|
||||
test('toDayjs parses ISO datetime strings in target timezone', () => {
|
||||
const value = '2024-05-01T14:30:00'
|
||||
const tz = 'Europe/London'
|
||||
|
||||
const result = toDayjs(value, { timezone: tz })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(14)
|
||||
expect(result?.minute()).toBe(30)
|
||||
})
|
||||
|
||||
test('toDayjs handles dates without time component', () => {
|
||||
const value = '2024-05-01'
|
||||
const tz = 'America/Los_Angeles'
|
||||
|
||||
const result = toDayjs(value, { timezone: tz })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
|
||||
expect(result?.hour()).toBe(0)
|
||||
expect(result?.minute()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertTimezoneToOffsetStr', () => {
|
||||
test('should return default UTC+0 for undefined timezone', () => {
|
||||
expect(convertTimezoneToOffsetStr(undefined)).toBe('UTC+0')
|
||||
})
|
||||
|
||||
test('should return default UTC+0 for invalid timezone', () => {
|
||||
expect(convertTimezoneToOffsetStr('Invalid/Timezone')).toBe('UTC+0')
|
||||
})
|
||||
|
||||
test('should handle whole hour positive offsets without leading zeros', () => {
|
||||
expect(convertTimezoneToOffsetStr('Asia/Shanghai')).toBe('UTC+8')
|
||||
expect(convertTimezoneToOffsetStr('Pacific/Auckland')).toBe('UTC+12')
|
||||
expect(convertTimezoneToOffsetStr('Pacific/Apia')).toBe('UTC+13')
|
||||
})
|
||||
|
||||
test('should handle whole hour negative offsets without leading zeros', () => {
|
||||
expect(convertTimezoneToOffsetStr('Pacific/Niue')).toBe('UTC-11')
|
||||
expect(convertTimezoneToOffsetStr('Pacific/Honolulu')).toBe('UTC-10')
|
||||
expect(convertTimezoneToOffsetStr('America/New_York')).toBe('UTC-5')
|
||||
})
|
||||
|
||||
test('should handle zero offset', () => {
|
||||
expect(convertTimezoneToOffsetStr('Europe/London')).toBe('UTC+0')
|
||||
expect(convertTimezoneToOffsetStr('UTC')).toBe('UTC+0')
|
||||
})
|
||||
|
||||
test('should handle half-hour offsets (30 minutes)', () => {
|
||||
// India Standard Time: UTC+5:30
|
||||
expect(convertTimezoneToOffsetStr('Asia/Kolkata')).toBe('UTC+5:30')
|
||||
// Australian Central Time: UTC+9:30
|
||||
expect(convertTimezoneToOffsetStr('Australia/Adelaide')).toBe('UTC+9:30')
|
||||
expect(convertTimezoneToOffsetStr('Australia/Darwin')).toBe('UTC+9:30')
|
||||
})
|
||||
|
||||
test('should handle 45-minute offsets', () => {
|
||||
// Chatham Time: UTC+12:45
|
||||
expect(convertTimezoneToOffsetStr('Pacific/Chatham')).toBe('UTC+12:45')
|
||||
})
|
||||
|
||||
test('should preserve leading zeros in minute part for non-zero minutes', () => {
|
||||
// Ensure +05:30 is displayed as "UTC+5:30", not "UTC+5:3"
|
||||
const result = convertTimezoneToOffsetStr('Asia/Kolkata')
|
||||
expect(result).toMatch(/UTC[+-]\d+:30/)
|
||||
expect(result).not.toMatch(/UTC[+-]\d+:3[^0]/)
|
||||
})
|
||||
})
|
||||
255
dify/web/app/components/base/date-and-time-picker/utils/dayjs.ts
Normal file
255
dify/web/app/components/base/date-and-time-picker/utils/dayjs.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import type { Day } from '../types'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import tz from '@/utils/timezone.json'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export default dayjs
|
||||
|
||||
const monthMaps: Record<string, Day[]> = {}
|
||||
const DEFAULT_OFFSET_STR = 'UTC+0'
|
||||
const TIME_ONLY_REGEX = /^(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/
|
||||
const TIME_ONLY_12H_REGEX = /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s?(AM|PM)$/i
|
||||
|
||||
const COMMON_PARSE_FORMATS = [
|
||||
'YYYY-MM-DD',
|
||||
'YYYY/MM/DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'MM-DD-YYYY',
|
||||
'MM/DD/YYYY',
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ',
|
||||
'YYYY-MM-DDTHH:mm:ssZ',
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
'YYYY-MM-DDTHH:mm',
|
||||
'YYYY-MM-DDTHH:mmZ',
|
||||
'YYYY-MM-DDTHH:mm:ss',
|
||||
'YYYY-MM-DDTHH:mm:ss.SSS',
|
||||
]
|
||||
|
||||
export const cloneTime = (targetDate: Dayjs, sourceDate: Dayjs) => {
|
||||
return targetDate.clone()
|
||||
.set('hour', sourceDate.hour())
|
||||
.set('minute', sourceDate.minute())
|
||||
}
|
||||
|
||||
export const getDaysInMonth = (currentDate: Dayjs) => {
|
||||
const key = currentDate.format('YYYY-MM')
|
||||
// return the cached days
|
||||
if (monthMaps[key])
|
||||
return monthMaps[key]
|
||||
|
||||
const daysInCurrentMonth = currentDate.daysInMonth()
|
||||
const firstDay = currentDate.startOf('month').day()
|
||||
const lastDay = currentDate.endOf('month').day()
|
||||
const lastDayInLastMonth = currentDate.clone().subtract(1, 'month').endOf('month')
|
||||
const firstDayInNextMonth = currentDate.clone().add(1, 'month').startOf('month')
|
||||
const days: Day[] = []
|
||||
const daysInOneWeek = 7
|
||||
const totalLines = 6
|
||||
|
||||
// Add cells for days before the first day of the month
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
const date = cloneTime(lastDayInLastMonth.subtract(i, 'day'), currentDate)
|
||||
days.push({
|
||||
date,
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Add days of the month
|
||||
for (let i = 1; i <= daysInCurrentMonth; i++) {
|
||||
const date = cloneTime(currentDate.startOf('month').add(i - 1, 'day'), currentDate)
|
||||
days.push({
|
||||
date,
|
||||
isCurrentMonth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Add cells for days after the last day of the month
|
||||
const totalLinesOfCurrentMonth = Math.ceil((daysInCurrentMonth - ((daysInOneWeek - firstDay) + lastDay + 1)) / 7) + 2
|
||||
const needAdditionalLine = totalLinesOfCurrentMonth < totalLines
|
||||
for (let i = 0; lastDay + i < (needAdditionalLine ? 2 * daysInOneWeek - 1 : daysInOneWeek - 1); i++) {
|
||||
const date = cloneTime(firstDayInNextMonth.add(i, 'day'), currentDate)
|
||||
days.push({
|
||||
date,
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
// cache the days
|
||||
monthMaps[key] = days
|
||||
return days
|
||||
}
|
||||
|
||||
export const clearMonthMapCache = () => {
|
||||
for (const key in monthMaps)
|
||||
delete monthMaps[key]
|
||||
}
|
||||
|
||||
export const getHourIn12Hour = (date: Dayjs) => {
|
||||
const hour = date.hour()
|
||||
return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour
|
||||
}
|
||||
|
||||
export const getDateWithTimezone = ({ date, timezone }: { date?: Dayjs, timezone?: string }) => {
|
||||
if (!timezone)
|
||||
return (date ?? dayjs()).clone()
|
||||
return date ? dayjs.tz(date, timezone) : dayjs().tz(timezone)
|
||||
}
|
||||
|
||||
export const convertTimezoneToOffsetStr = (timezone?: string) => {
|
||||
if (!timezone)
|
||||
return DEFAULT_OFFSET_STR
|
||||
const tzItem = tz.find(item => item.value === timezone)
|
||||
if (!tzItem)
|
||||
return DEFAULT_OFFSET_STR
|
||||
// Extract offset from name format like "-11:00 Niue Time" or "+05:30 India Time"
|
||||
// Name format is always "{offset}:{minutes} {timezone name}"
|
||||
const offsetMatch = tzItem.name.match(/^([+-]?\d{1,2}):(\d{2})/)
|
||||
if (!offsetMatch)
|
||||
return DEFAULT_OFFSET_STR
|
||||
// Parse hours and minutes separately
|
||||
const hours = Number.parseInt(offsetMatch[1], 10)
|
||||
const minutes = Number.parseInt(offsetMatch[2], 10)
|
||||
const sign = hours >= 0 ? '+' : ''
|
||||
// If minutes are non-zero, include them in the output (e.g., "UTC+5:30")
|
||||
// Otherwise, only show hours (e.g., "UTC+8")
|
||||
return minutes !== 0 ? `UTC${sign}${hours}:${offsetMatch[2]}` : `UTC${sign}${hours}`
|
||||
}
|
||||
|
||||
export const isDayjsObject = (value: unknown): value is Dayjs => dayjs.isDayjs(value)
|
||||
|
||||
export type ToDayjsOptions = {
|
||||
timezone?: string
|
||||
format?: string
|
||||
formats?: string[]
|
||||
}
|
||||
|
||||
const warnParseFailure = (value: string) => {
|
||||
if (process.env.NODE_ENV !== 'production')
|
||||
console.warn('[TimePicker] Failed to parse time value', value)
|
||||
}
|
||||
|
||||
const normalizeMillisecond = (value: string | undefined) => {
|
||||
if (!value) return 0
|
||||
if (value.length === 3) return Number(value)
|
||||
if (value.length > 3) return Number(value.slice(0, 3))
|
||||
return Number(value.padEnd(3, '0'))
|
||||
}
|
||||
|
||||
const applyTimezone = (date: Dayjs, timezone?: string) => {
|
||||
return timezone ? getDateWithTimezone({ date, timezone }) : date
|
||||
}
|
||||
|
||||
export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptions = {}): Dayjs | undefined => {
|
||||
if (!value)
|
||||
return undefined
|
||||
|
||||
const { timezone: tzName, format, formats } = options
|
||||
|
||||
if (isDayjsObject(value))
|
||||
return applyTimezone(value, tzName)
|
||||
|
||||
if (typeof value !== 'string')
|
||||
return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (format) {
|
||||
const parsedWithFormat = tzName
|
||||
? dayjs(trimmed, format, true).tz(tzName, true)
|
||||
: dayjs(trimmed, format, true)
|
||||
if (parsedWithFormat.isValid())
|
||||
return parsedWithFormat
|
||||
}
|
||||
|
||||
const timeMatch = TIME_ONLY_REGEX.exec(trimmed)
|
||||
if (timeMatch) {
|
||||
const base = applyTimezone(dayjs(), tzName).startOf('day')
|
||||
const rawHour = Number(timeMatch[1])
|
||||
const minute = Number(timeMatch[2])
|
||||
const second = timeMatch[3] ? Number(timeMatch[3]) : 0
|
||||
const millisecond = normalizeMillisecond(timeMatch[4])
|
||||
|
||||
return base
|
||||
.set('hour', rawHour)
|
||||
.set('minute', minute)
|
||||
.set('second', second)
|
||||
.set('millisecond', millisecond)
|
||||
}
|
||||
|
||||
const timeMatch12h = TIME_ONLY_12H_REGEX.exec(trimmed)
|
||||
if (timeMatch12h) {
|
||||
const base = applyTimezone(dayjs(), tzName).startOf('day')
|
||||
let hour = Number(timeMatch12h[1]) % 12
|
||||
const isPM = timeMatch12h[4]?.toUpperCase() === 'PM'
|
||||
if (isPM)
|
||||
hour += 12
|
||||
const minute = Number(timeMatch12h[2])
|
||||
const second = timeMatch12h[3] ? Number(timeMatch12h[3]) : 0
|
||||
|
||||
return base
|
||||
.set('hour', hour)
|
||||
.set('minute', minute)
|
||||
.set('second', second)
|
||||
.set('millisecond', 0)
|
||||
}
|
||||
|
||||
const candidateFormats = formats ?? COMMON_PARSE_FORMATS
|
||||
for (const fmt of candidateFormats) {
|
||||
const parsed = tzName
|
||||
? dayjs(trimmed, fmt, true).tz(tzName, true)
|
||||
: dayjs(trimmed, fmt, true)
|
||||
if (parsed.isValid())
|
||||
return parsed
|
||||
}
|
||||
|
||||
const fallbackParsed = tzName ? dayjs.tz(trimmed, tzName) : dayjs(trimmed)
|
||||
if (fallbackParsed.isValid())
|
||||
return fallbackParsed
|
||||
|
||||
warnParseFailure(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse date with multiple format support
|
||||
export const parseDateWithFormat = (dateString: string, format?: string): Dayjs | null => {
|
||||
if (!dateString) return null
|
||||
|
||||
// If format is specified, use it directly
|
||||
if (format) {
|
||||
const parsed = dayjs(dateString, format, true)
|
||||
return parsed.isValid() ? parsed : null
|
||||
}
|
||||
|
||||
// Try common date formats
|
||||
const formats = [
|
||||
...COMMON_PARSE_FORMATS,
|
||||
]
|
||||
|
||||
for (const fmt of formats) {
|
||||
const parsed = dayjs(dateString, fmt, true)
|
||||
if (parsed.isValid())
|
||||
return parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Format date output with localization support
|
||||
export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, _locale: string = 'en-US'): string => {
|
||||
if (!date || !date.isValid()) return ''
|
||||
|
||||
if (includeTime) {
|
||||
// Output format with time
|
||||
return date.format('YYYY-MM-DDTHH:mm:ss.SSSZ')
|
||||
}
|
||||
else {
|
||||
// Date-only output format without timezone
|
||||
return date.format('YYYY-MM-DD')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Button from '../../button'
|
||||
import type { YearAndMonthPickerFooterProps } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Footer: FC<YearAndMonthPickerFooterProps> = ({
|
||||
handleYearMonthCancel,
|
||||
handleYearMonthConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-x-1 p-2'>
|
||||
<Button size='small' onClick={handleYearMonthCancel}>
|
||||
{t('time.operation.cancel')}
|
||||
</Button>
|
||||
<Button variant='primary' size='small' onClick={handleYearMonthConfirm}>
|
||||
{t('time.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Footer)
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { type FC } from 'react'
|
||||
import type { YearAndMonthPickerHeaderProps } from '../types'
|
||||
import { useMonths } from '../hooks'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
|
||||
const Header: FC<YearAndMonthPickerHeaderProps> = ({
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
onClick,
|
||||
}) => {
|
||||
const months = useMonths()
|
||||
|
||||
return (
|
||||
<div className='flex border-b-[0.5px] border-divider-regular p-2 pb-1'>
|
||||
{/* Year and Month */}
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
className='system-md-semibold flex items-center gap-x-0.5 rounded-lg px-2 py-1.5 text-text-primary hover:bg-state-base-hover'
|
||||
>
|
||||
<span>{`${months[selectedMonth]} ${selectedYear}`}</span>
|
||||
<RiArrowUpSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Header)
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { type FC } from 'react'
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import { useMonths, useYearOptions } from '../hooks'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
|
||||
const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
selectedMonth,
|
||||
selectedYear,
|
||||
handleMonthSelect,
|
||||
handleYearSelect,
|
||||
}) => {
|
||||
const months = useMonths()
|
||||
const yearOptions = useYearOptions()
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-x-1 p-2'>
|
||||
{/* Month Picker */}
|
||||
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
|
||||
{
|
||||
months.map((month, index) => {
|
||||
const isSelected = selectedMonth === index
|
||||
return (
|
||||
<OptionListItem
|
||||
key={month}
|
||||
isSelected={isSelected}
|
||||
onClick={handleMonthSelect.bind(null, index)}
|
||||
>
|
||||
{month}
|
||||
</OptionListItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
{/* Year Picker */}
|
||||
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
|
||||
{
|
||||
yearOptions.map((year) => {
|
||||
const isSelected = selectedYear === year
|
||||
return (
|
||||
<OptionListItem
|
||||
key={year}
|
||||
isSelected={isSelected}
|
||||
onClick={handleYearSelect.bind(null, year)}
|
||||
>
|
||||
{year}
|
||||
</OptionListItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Options)
|
||||
Reference in New Issue
Block a user