mirror of
https://github.com/langgenius/dify.git
synced 2026-08-03 03:26:35 +08:00
294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
import type { Dayjs } from 'dayjs'
|
|
import type { TimePickerProps } 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, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import TimezoneLabel from '@/app/components/base/timezone-label'
|
|
import { Period } from '../types'
|
|
import dayjs, { getDateWithTimezone, getHourIn12Hour, isDayjsObject, toDayjs } from '../utils/dayjs'
|
|
import Footer from './footer'
|
|
import Header from './header'
|
|
import Options from './options'
|
|
|
|
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 isInitialRef = useRef(true)
|
|
|
|
// Initialize selectedTime
|
|
const [selectedTime, setSelectedTime] = useState(() => {
|
|
return toDayjs(value, { timezone })
|
|
})
|
|
|
|
// Track previous values to avoid unnecessary updates
|
|
const prevValueRef = useRef(value)
|
|
const prevTimezoneRef = useRef(timezone)
|
|
|
|
useEffect(() => {
|
|
if (isInitialRef.current) {
|
|
isInitialRef.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
|
|
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
|
setSelectedTime(dayjsValue)
|
|
|
|
if (timezoneChanged && !valueChanged) onChange(dayjsValue)
|
|
return
|
|
}
|
|
|
|
// oxlint-disable-next-line eslint-react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
|
setSelectedTime((prev) => {
|
|
if (!isDayjsObject(prev)) return undefined
|
|
return timezone ? getDateWithTimezone({ date: prev, timezone }) : prev
|
|
})
|
|
}, [timezone, value, onChange])
|
|
|
|
const syncSelectedTimeFromValue = useCallback(() => {
|
|
if (!value) return
|
|
|
|
const dayjsValue = toDayjs(value, { timezone })
|
|
const needsUpdate =
|
|
dayjsValue &&
|
|
(!selectedTime || !isDayjsObject(selectedTime) || !dayjsValue.isSame(selectedTime, 'minute'))
|
|
if (needsUpdate) setSelectedTime(dayjsValue)
|
|
}, [selectedTime, timezone, value])
|
|
|
|
const handleOpenChange = useCallback(
|
|
(nextOpen: boolean) => {
|
|
setIsOpen(nextOpen)
|
|
if (nextOpen) syncSelectedTimeFromValue()
|
|
},
|
|
[syncSelectedTimeFromValue],
|
|
)
|
|
|
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
handleOpenChange(!isOpen)
|
|
}
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setSelectedTime(undefined)
|
|
if (!isOpen) onClear()
|
|
}
|
|
|
|
const handleTimeSelect = useCallback(
|
|
(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)
|
|
})
|
|
},
|
|
[timezone],
|
|
)
|
|
|
|
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, handleTimeSelect],
|
|
)
|
|
|
|
const handleSelectMinute = useCallback(
|
|
(minute: string) => {
|
|
const time = getSafeTimeObject()
|
|
handleTimeSelect(
|
|
getHourIn12Hour(time).toString().padStart(2, '0'),
|
|
minute,
|
|
time.format('A') as Period,
|
|
)
|
|
},
|
|
[getSafeTimeObject, handleTimeSelect],
|
|
)
|
|
|
|
const handleSelectPeriod = useCallback(
|
|
(period: Period) => {
|
|
const time = getSafeTimeObject()
|
|
handleTimeSelect(
|
|
getHourIn12Hour(time).toString().padStart(2, '0'),
|
|
time.minute().toString().padStart(2, '0'),
|
|
period,
|
|
)
|
|
},
|
|
[getSafeTimeObject, handleTimeSelect],
|
|
)
|
|
|
|
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(($) => $.defaultPlaceholder, { ns: 'time' })
|
|
|
|
const inputElem = (
|
|
<input
|
|
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden select-none placeholder:text-components-input-text-placeholder"
|
|
readOnly
|
|
value={isOpen ? '' : displayValue}
|
|
placeholder={placeholderDate}
|
|
/>
|
|
)
|
|
return (
|
|
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger
|
|
nativeButton={false}
|
|
className={triggerFullWidth ? 'flex! w-full' : undefined}
|
|
render={
|
|
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}
|
|
data-testid="time-picker-trigger"
|
|
>
|
|
{inputElem}
|
|
{showTimezone && timezone && (
|
|
<TimezoneLabel
|
|
timezone={timezone}
|
|
inline
|
|
className="shrink-0 text-xs select-none"
|
|
/>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
'i-ri-time-line size-4 shrink-0 text-text-quaternary',
|
|
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
|
(displayValue || (isOpen && selectedTime)) &&
|
|
!notClearable &&
|
|
'group-hover:hidden',
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
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 && selectedTime)) &&
|
|
!notClearable &&
|
|
'group-hover:inline-block',
|
|
)}
|
|
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
|
onClick={handleClear}
|
|
>
|
|
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
<PopoverContent
|
|
placement={placement}
|
|
sideOffset={0}
|
|
className={popupClassName}
|
|
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 */}
|
|
<Header title={title} />
|
|
|
|
{/* Time Options */}
|
|
<Options
|
|
selectedTime={selectedTime}
|
|
minuteFilter={minuteFilter}
|
|
handleSelectHour={handleSelectHour}
|
|
handleSelectMinute={handleSelectMinute}
|
|
handleSelectPeriod={handleSelectPeriod}
|
|
/>
|
|
|
|
{/* Footer */}
|
|
<Footer handleSelectCurrentTime={handleSelectCurrentTime} handleConfirm={handleConfirm} />
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export default TimePicker
|