dify/web/app/components/base/date-and-time-picker/date-picker/index.tsx
Stephen Zhou eb71e47f3b
chore: migrate linting to vp lint with ESLint fallback (#38834)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-13 06:41:53 +00:00

351 lines
12 KiB
TypeScript

import type { Dayjs } from 'dayjs'
import type { DatePickerProps, Period } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Calendar from '../calendar'
import TimePickerHeader from '../time-picker/header'
import TimePickerOptions from '../time-picker/options'
import { ViewType } from '../types'
import dayjs, {
clearMonthMapCache,
cloneTime,
getDateWithTimezone,
getDaysInMonth,
getHourIn12Hour,
} from '../utils/dayjs'
import YearAndMonthPickerFooter from '../year-and-month-picker/footer'
import YearAndMonthPickerHeader from '../year-and-month-picker/header'
import YearAndMonthPickerOptions from '../year-and-month-picker/options'
import DatePickerFooter from './footer'
import DatePickerHeader from './header'
const DatePicker = ({
value,
timezone,
onChange,
onClear,
placeholder,
needTimePicker = true,
renderTrigger,
triggerWrapClassName,
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState(ViewType.date)
const isInitialRef = 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(() => {
if (isInitialRef.current) {
isInitialRef.current = false
return
}
clearMonthMapCache()
if (normalizedValue) {
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
// oxlint-disable-next-line eslint-react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate(newValue)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate(newValue)
onChange(newValue)
} else {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
setCurrentDate((prev) => getDateWithTimezone({ date: prev, timezone }))
// oxlint-disable-next-line eslint-react/set-state-in-effect -- timezone changes intentionally resync the selected value.
setSelectedDate((prev) => (prev ? getDateWithTimezone({ date: prev, timezone }) : undefined))
}
// oxlint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
}, [timezone])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setIsOpen(nextOpen)
setView(ViewType.date)
if (nextOpen && normalizedValue) {
setCurrentDate(normalizedValue)
setSelectedDate(normalizedValue)
}
},
[normalizedValue],
)
const handleClickTrigger = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleOpenChange(!isOpen)
}
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(($) => $['dateFormats.displayWithTime'], { ns: 'time' })
: t(($) => $['dateFormats.display'], { ns: 'time' })
const displayValue = normalizedValue?.format(timeFormat) || ''
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
const placeholderDate =
isOpen && selectedDate
? selectedDate.format(timeFormat)
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
nativeButton={false}
className={triggerWrapClassName}
render={
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}
data-testid="date-picker-trigger"
>
<input
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder"
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
<span
className={cn(
'i-ri-calendar-line size-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
)}
/>
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className={cn(
'hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
(displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block',
)}
onClick={handleClear}
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
</div>
)
}
/>
<PopoverContent
placement="bottom-end"
sideOffset={0}
popupClassName="border-none bg-transparent shadow-none"
>
<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>
</PopoverContent>
</Popover>
)
}
export default DatePicker