mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-14 06:55:57 +08:00
feat: date and time picker (#13985)
This commit is contained in:
parent
4a332ff1af
commit
19d413ac1e
@ -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 p-2 border-b-[0.5px] border-divider-regular'>
|
||||||
|
{daysOfWeek.map(day => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className='flex items-center justify-center text-text-tertiary system-2xs-medium'
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(DaysOfWeek)
|
@ -0,0 +1,27 @@
|
|||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
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}
|
||||||
|
/>)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar
|
@ -0,0 +1,30 @@
|
|||||||
|
import React, { type FC } from 'react'
|
||||||
|
import type { CalendarItemProps } from '../types'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const Item: FC<CalendarItemProps> = ({
|
||||||
|
day,
|
||||||
|
selectedDate,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const { date, isCurrentMonth } = day
|
||||||
|
const isSelected = selectedDate?.isSame(date, 'date')
|
||||||
|
const isToday = date.isSame(dayjs(), 'date')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onClick(date)}
|
||||||
|
className={cn(
|
||||||
|
'relative px-1 py-2 rounded-lg flex items-center justify-center system-sm-medium',
|
||||||
|
isCurrentMonth ? 'text-text-secondary' : 'text-text-quaternary hover:text-text-secondary',
|
||||||
|
isSelected ? 'text-components-button-primary-text system-sm-medium bg-components-button-primary-bg' : 'hover:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{date.date()}
|
||||||
|
{isToday && <div className='absolute bottom-1 mx-auto w-1 h-1 rounded-full bg-components-button-primary-bg' />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Item)
|
@ -0,0 +1,38 @@
|
|||||||
|
import React, { type FC, useEffect, useRef } from 'react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type OptionListItemProps = {
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
} & React.LiHTMLAttributes<HTMLLIElement>
|
||||||
|
|
||||||
|
const OptionListItem: FC<OptionListItemProps> = ({
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const listItemRef = useRef<HTMLLIElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelected)
|
||||||
|
listItemRef.current?.scrollIntoView({ behavior: 'instant' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={listItemRef}
|
||||||
|
className={cn(
|
||||||
|
'px-1.5 py-1 rounded-md flex items-center justify-center text-components-button-ghost-text system-xs-medium cursor-pointer',
|
||||||
|
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 justify-between items-center p-2 border-t-[0.5px] border-divider-regular',
|
||||||
|
!needTimePicker && 'justify-end',
|
||||||
|
)}>
|
||||||
|
{/* Time Picker */}
|
||||||
|
{needTimePicker && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex items-center rounded-md px-1.5 py-1 gap-x-[1px] border-[0.5px] border-components-button-secondary-border system-xs-medium
|
||||||
|
bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] text-components-button-secondary-accent-text'
|
||||||
|
onClick={handleClickTimePicker}
|
||||||
|
>
|
||||||
|
<RiTimeLine className='w-3.5 h-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='flex items-center justify-center px-1.5 py-1 text-components-button-secondary-accent-text system-xs-medium'
|
||||||
|
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='flex items-center mx-2 mt-2'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenYearMonthPicker}
|
||||||
|
className='flex items-center gap-x-0.5 px-2 py-1.5 rounded-lg hover:bg-state-base-hover text-text-primary system-md-semibold'
|
||||||
|
>
|
||||||
|
<span>{`${months[currentDate.month()]} ${currentDate.year()}`}</span>
|
||||||
|
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary' />
|
||||||
|
</button>
|
||||||
|
</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
|
||||||
|
onClick={onClickPrevMonth}
|
||||||
|
className='p-1.5 hover:bg-state-base-hover rounded-lg'
|
||||||
|
>
|
||||||
|
<RiArrowUpSLine className='w-[18px] h-[18px] text-text-secondary' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Header)
|
@ -0,0 +1,279 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import type { DatePickerProps, Period } from '../types'
|
||||||
|
import { ViewType } from '../types'
|
||||||
|
import { cloneTime, getDaysInMonth, getHourIn12Hour } from '../utils'
|
||||||
|
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,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
placeholder,
|
||||||
|
needTimePicker = true,
|
||||||
|
renderTrigger,
|
||||||
|
}: DatePickerProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [view, setView] = useState(ViewType.date)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState(value || dayjs())
|
||||||
|
const [selectedDate, setSelectedDate] = useState(value)
|
||||||
|
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState((value || dayjs()).month())
|
||||||
|
const [selectedYear, setSelectedYear] = useState((value || dayjs()).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)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setView(ViewType.date)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
|
const newDate = dayjs()
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedDate(undefined)
|
||||||
|
setCurrentDate(prev => prev || newDate)
|
||||||
|
setSelectedMonth(prev => prev || newDate.month())
|
||||||
|
setSelectedYear(prev => prev || newDate.year())
|
||||||
|
if (!isOpen)
|
||||||
|
onClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
|
return getDaysInMonth(currentDate)
|
||||||
|
}, [currentDate])
|
||||||
|
|
||||||
|
const handleClickNextMonth = useCallback(() => {
|
||||||
|
setCurrentDate(currentDate.clone().add(1, 'month'))
|
||||||
|
}, [currentDate])
|
||||||
|
|
||||||
|
const handleClickPrevMonth = useCallback(() => {
|
||||||
|
setCurrentDate(currentDate.clone().subtract(1, 'month'))
|
||||||
|
}, [currentDate])
|
||||||
|
|
||||||
|
const handleDateSelect = useCallback((day: Dayjs) => {
|
||||||
|
const newDate = cloneTime(day, selectedDate || dayjs())
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
setSelectedDate(newDate)
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
const handleSelectCurrentDate = () => {
|
||||||
|
const newDate = dayjs()
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
setSelectedDate(newDate)
|
||||||
|
onChange(newDate)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDate = () => {
|
||||||
|
onChange(selectedDate)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickTimePicker = () => {
|
||||||
|
if (view === ViewType.date) {
|
||||||
|
setView(ViewType.time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view === ViewType.time)
|
||||||
|
setView(ViewType.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
||||||
|
const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
|
||||||
|
setSelectedDate((prev) => {
|
||||||
|
return prev ? cloneTime(prev, newTime) : newTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectHour = useCallback((hour: string) => {
|
||||||
|
const selectedTime = selectedDate || dayjs()
|
||||||
|
handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
const handleSelectMinute = useCallback((minute: string) => {
|
||||||
|
const selectedTime = selectedDate || dayjs()
|
||||||
|
handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
const handleSelectPeriod = useCallback((period: Period) => {
|
||||||
|
const selectedTime = selectedDate || dayjs()
|
||||||
|
handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
return prev ? prev.clone().month(selectedMonth).year(selectedYear) : dayjs().month(selectedMonth).year(selectedYear)
|
||||||
|
})
|
||||||
|
setView(ViewType.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
|
||||||
|
const displayValue = value?.format(timeFormat) || ''
|
||||||
|
const displayTime = (selectedDate || dayjs().startOf('day')).format('hh:mm A')
|
||||||
|
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalToFollowElem
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
placement='bottom-end'
|
||||||
|
>
|
||||||
|
<PortalToFollowElemTrigger>
|
||||||
|
{renderTrigger ? (renderTrigger({
|
||||||
|
value,
|
||||||
|
selectedDate,
|
||||||
|
isOpen,
|
||||||
|
handleClear,
|
||||||
|
handleClickTrigger,
|
||||||
|
})) : (
|
||||||
|
<div
|
||||||
|
className='w-[252px] flex items-center gap-x-0.5 rounded-lg px-2 py-1 bg-components-input-bg-normal cursor-pointer group hover:bg-state-base-hover-alt'
|
||||||
|
onClick={handleClickTrigger}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className='flex-1 p-1 bg-transparent text-components-input-text-filled placeholder:text-components-input-text-placeholder truncate system-xs-regular
|
||||||
|
outline-none appearance-none cursor-pointer'
|
||||||
|
readOnly
|
||||||
|
value={isOpen ? '' : displayValue}
|
||||||
|
placeholder={placeholderDate}
|
||||||
|
/>
|
||||||
|
<RiCalendarLine className={cn(
|
||||||
|
'shrink-0 w-4 h-4 text-text-quaternary',
|
||||||
|
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||||
|
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
|
||||||
|
)} />
|
||||||
|
<RiCloseCircleFill
|
||||||
|
className={cn(
|
||||||
|
'hidden shrink-0 w-4 h-4 text-text-quaternary',
|
||||||
|
(displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
onClick={handleClear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
<PortalToFollowElemContent>
|
||||||
|
<div className='w-[252px] mt-1 bg-components-panel-bg rounded-xl shadow-lg shadow-shadow-shadow-5 border-[0.5px] border-components-panel-border'>
|
||||||
|
{/* Header */}
|
||||||
|
{view === ViewType.date ? (
|
||||||
|
<DatePickerHeader
|
||||||
|
handleOpenYearMonthPicker={handleOpenYearMonthPicker}
|
||||||
|
currentDate={currentDate}
|
||||||
|
onClickNextMonth={handleClickNextMonth}
|
||||||
|
onClickPrevMonth={handleClickPrevMonth}
|
||||||
|
/>
|
||||||
|
) : view === ViewType.yearMonth ? (
|
||||||
|
<YearAndMonthPickerHeader
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
onClick={handleCloseYearMonthPicker}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TimePickerHeader />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{
|
||||||
|
view === ViewType.date ? (
|
||||||
|
<Calendar
|
||||||
|
days={days}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onDateClick={handleDateSelect}
|
||||||
|
/>
|
||||||
|
) : view === ViewType.yearMonth ? (
|
||||||
|
<YearAndMonthPickerOptions
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
handleMonthSelect={handleMonthSelect}
|
||||||
|
handleYearSelect={handleYearSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TimePickerOptions
|
||||||
|
selectedTime={selectedDate}
|
||||||
|
handleSelectHour={handleSelectHour}
|
||||||
|
handleSelectMinute={handleSelectMinute}
|
||||||
|
handleSelectPeriod={handleSelectPeriod}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{
|
||||||
|
[ViewType.date, ViewType.time].includes(view) ? (
|
||||||
|
<DatePickerFooter
|
||||||
|
needTimePicker={needTimePicker}
|
||||||
|
displayTime={displayTime}
|
||||||
|
view={view}
|
||||||
|
handleClickTimePicker={handleClickTimePicker}
|
||||||
|
handleSelectCurrentDate={handleSelectCurrentDate}
|
||||||
|
handleConfirmDate={handleConfirmDate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<YearAndMonthPickerFooter
|
||||||
|
handleYearMonthCancel={handleYearMonthCancel}
|
||||||
|
handleYearMonthConfirm={handleYearMonthConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatePicker
|
49
web/app/components/base/date-and-time-picker/hooks.ts
Normal file
49
web/app/components/base/date-and-time-picker/hooks.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import dayjs from '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,37 @@
|
|||||||
|
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 justify-end items-center p-2 border-t-[0.5px] border-divider-regular'>
|
||||||
|
<div className='flex items-center gap-x-1'>
|
||||||
|
{/* Now */}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex items-center justify-center px-1.5 py-1 text-components-button-secondary-accent-text system-xs-medium'
|
||||||
|
onClick={handleSelectCurrentTime}
|
||||||
|
>
|
||||||
|
<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={handleConfirm.bind(null)}
|
||||||
|
>
|
||||||
|
{t('time.operation.ok')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Footer)
|
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col border-b-[0.5px] border-divider-regular'>
|
||||||
|
<div className='flex items-center px-2 py-1.5 text-text-primary system-md-semibold'>
|
||||||
|
{t('time.title.pickTime')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Header)
|
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { Period, TimePickerProps } from '../types'
|
||||||
|
import { cloneTime, getHourIn12Hour } from '../utils'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const TimePicker = ({
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
renderTrigger,
|
||||||
|
}: TimePickerProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selectedTime, setSelectedTime] = useState(value)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedTime(undefined)
|
||||||
|
if (!isOpen)
|
||||||
|
onClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
||||||
|
const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
|
||||||
|
setSelectedTime((prev) => {
|
||||||
|
return prev ? cloneTime(prev, newTime) : newTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectHour = useCallback((hour: string) => {
|
||||||
|
const time = selectedTime || dayjs().startOf('day')
|
||||||
|
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
||||||
|
}, [selectedTime])
|
||||||
|
|
||||||
|
const handleSelectMinute = useCallback((minute: string) => {
|
||||||
|
const time = selectedTime || dayjs().startOf('day')
|
||||||
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
||||||
|
}, [selectedTime])
|
||||||
|
|
||||||
|
const handleSelectPeriod = useCallback((period: Period) => {
|
||||||
|
const time = selectedTime || dayjs().startOf('day')
|
||||||
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
||||||
|
}, [selectedTime])
|
||||||
|
|
||||||
|
const handleSelectCurrentTime = useCallback(() => {
|
||||||
|
const newDate = dayjs()
|
||||||
|
setSelectedTime(newDate)
|
||||||
|
onChange(newDate)
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
onChange(selectedTime)
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [onChange, selectedTime])
|
||||||
|
|
||||||
|
const timeFormat = 'hh:mm A'
|
||||||
|
const displayValue = value?.format(timeFormat) || ''
|
||||||
|
const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalToFollowElem
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
placement='bottom-end'
|
||||||
|
>
|
||||||
|
<PortalToFollowElemTrigger>
|
||||||
|
{renderTrigger ? (renderTrigger()) : (
|
||||||
|
<div
|
||||||
|
className='w-[252px] flex items-center gap-x-0.5 rounded-lg px-2 py-1 bg-components-input-bg-normal cursor-pointer group hover:bg-state-base-hover-alt'
|
||||||
|
onClick={handleClickTrigger}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className='flex-1 p-1 bg-transparent text-components-input-text-filled placeholder:text-components-input-text-placeholder truncate system-xs-regular
|
||||||
|
outline-none appearance-none cursor-pointer'
|
||||||
|
readOnly
|
||||||
|
value={isOpen ? '' : displayValue}
|
||||||
|
placeholder={placeholderDate}
|
||||||
|
/>
|
||||||
|
<RiTimeLine className={cn(
|
||||||
|
'shrink-0 w-4 h-4 text-text-quaternary',
|
||||||
|
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||||
|
(displayValue || (isOpen && selectedTime)) && 'group-hover:hidden',
|
||||||
|
)} />
|
||||||
|
<RiCloseCircleFill
|
||||||
|
className={cn(
|
||||||
|
'hidden shrink-0 w-4 h-4 text-text-quaternary',
|
||||||
|
(displayValue || (isOpen && selectedTime)) && 'group-hover:inline-block hover:text-text-secondary',
|
||||||
|
)}
|
||||||
|
onClick={handleClear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
<PortalToFollowElemContent>
|
||||||
|
<div className='w-[252px] mt-1 bg-components-panel-bg rounded-xl shadow-lg shadow-shadow-shadow-5 border-[0.5px] border-components-panel-border'>
|
||||||
|
{/* Header */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Time Options */}
|
||||||
|
<Options
|
||||||
|
selectedTime={selectedTime}
|
||||||
|
handleSelectHour={handleSelectHour}
|
||||||
|
handleSelectMinute={handleSelectMinute}
|
||||||
|
handleSelectPeriod={handleSelectPeriod}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer
|
||||||
|
handleSelectCurrentTime={handleSelectCurrentTime}
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimePicker
|
@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
handleSelectHour,
|
||||||
|
handleSelectMinute,
|
||||||
|
handleSelectPeriod,
|
||||||
|
}) => {
|
||||||
|
const { hourOptions, minuteOptions, periodOptions } = useTimeOptions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='grid grid-cols-3 gap-x-1 p-2'>
|
||||||
|
{/* Hour */}
|
||||||
|
<ul className='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar 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='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar pb-[184px]'>
|
||||||
|
{
|
||||||
|
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='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar pb-[184px]'>
|
||||||
|
{
|
||||||
|
periodOptions.map((period) => {
|
||||||
|
const isSelected = selectedTime?.format('A') === period
|
||||||
|
return (
|
||||||
|
<OptionListItem
|
||||||
|
key={period}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClick={handleSelectPeriod.bind(null, period)}
|
||||||
|
>
|
||||||
|
{period}
|
||||||
|
</OptionListItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Options)
|
101
web/app/components/base/date-and-time-picker/types.ts
Normal file
101
web/app/components/base/date-and-time-picker/types.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
|
||||||
|
export enum ViewType {
|
||||||
|
date = 'date',
|
||||||
|
yearMonth = 'yearMonth',
|
||||||
|
time = 'time',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Period {
|
||||||
|
AM = 'AM',
|
||||||
|
PM = 'PM',
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
placeholder?: string
|
||||||
|
needTimePicker?: boolean
|
||||||
|
onChange: (date: Dayjs | undefined) => void
|
||||||
|
onClear: () => void
|
||||||
|
renderTrigger?: (props: TriggerProps) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TimePickerProps = {
|
||||||
|
value: Dayjs | undefined
|
||||||
|
placeholder?: string
|
||||||
|
onChange: (date: Dayjs | undefined) => void
|
||||||
|
onClear: () => void
|
||||||
|
renderTrigger?: () => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarItemProps = {
|
||||||
|
day: Day
|
||||||
|
selectedDate: Dayjs | undefined
|
||||||
|
onClick: (date: Dayjs) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeOptionsProps = {
|
||||||
|
selectedTime: Dayjs | undefined
|
||||||
|
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
|
||||||
|
}
|
64
web/app/components/base/date-and-time-picker/utils.ts
Normal file
64
web/app/components/base/date-and-time-picker/utils.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
import type { Day } from './types'
|
||||||
|
|
||||||
|
const monthMaps: Record<string, Day[]> = {}
|
||||||
|
|
||||||
|
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 getHourIn12Hour = (date: Dayjs) => {
|
||||||
|
const hour = date.hour()
|
||||||
|
return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour
|
||||||
|
}
|
@ -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 p-2 pb-1 border-b-[0.5px] border-divider-regular'>
|
||||||
|
{/* Year and Month */}
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className='flex items-center gap-x-0.5 px-2 py-1.5 rounded-lg hover:bg-state-base-hover text-text-primary system-md-semibold'
|
||||||
|
>
|
||||||
|
<span>{`${months[selectedMonth]} ${selectedYear}`}</span>
|
||||||
|
<RiArrowUpSLine className='w-4 h-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='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar 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='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar 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)
|
@ -684,4 +684,17 @@ button:focus-within {
|
|||||||
@import "../components/base/action-button/index.css";
|
@import "../components/base/action-button/index.css";
|
||||||
@import "../components/base/modal/index.css";
|
@import "../components/base/modal/index.css";
|
||||||
|
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
}
|
3
web/i18n/de-DE/time.ts
Normal file
3
web/i18n/de-DE/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
37
web/i18n/en-US/time.ts
Normal file
37
web/i18n/en-US/time.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const translation = {
|
||||||
|
daysInWeek: {
|
||||||
|
Sun: 'Sun',
|
||||||
|
Mon: 'Mon',
|
||||||
|
Tue: 'Tue',
|
||||||
|
Wed: 'Wed',
|
||||||
|
Thu: 'Thu',
|
||||||
|
Fri: 'Fri',
|
||||||
|
Sat: 'Sat',
|
||||||
|
},
|
||||||
|
months: {
|
||||||
|
January: 'January',
|
||||||
|
February: 'February',
|
||||||
|
March: 'March',
|
||||||
|
April: 'April',
|
||||||
|
May: 'May',
|
||||||
|
June: 'June',
|
||||||
|
July: 'July',
|
||||||
|
August: 'August',
|
||||||
|
September: 'September',
|
||||||
|
October: 'October',
|
||||||
|
November: 'November',
|
||||||
|
December: 'December',
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
now: 'Now',
|
||||||
|
ok: 'OK',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
pickDate: 'Pick Date',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
pickTime: 'Pick Time',
|
||||||
|
},
|
||||||
|
defaultPlaceholder: 'Pick a time...',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/es-ES/time.ts
Normal file
3
web/i18n/es-ES/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/fa-IR/time.ts
Normal file
3
web/i18n/fa-IR/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/fr-FR/time.ts
Normal file
3
web/i18n/fr-FR/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/hi-IN/time.ts
Normal file
3
web/i18n/hi-IN/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
@ -30,6 +30,7 @@ const loadLangResources = (lang: string) => ({
|
|||||||
runLog: require(`./${lang}/run-log`).default,
|
runLog: require(`./${lang}/run-log`).default,
|
||||||
plugin: require(`./${lang}/plugin`).default,
|
plugin: require(`./${lang}/plugin`).default,
|
||||||
pluginTags: require(`./${lang}/plugin-tags`).default,
|
pluginTags: require(`./${lang}/plugin-tags`).default,
|
||||||
|
time: require(`./${lang}/time`).default,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
3
web/i18n/it-IT/time.ts
Normal file
3
web/i18n/it-IT/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/ja-JP/time.ts
Normal file
3
web/i18n/ja-JP/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/ko-KR/time.ts
Normal file
3
web/i18n/ko-KR/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/pl-PL/time.ts
Normal file
3
web/i18n/pl-PL/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/pt-BR/time.ts
Normal file
3
web/i18n/pt-BR/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/ro-RO/time.ts
Normal file
3
web/i18n/ro-RO/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/ru-RU/time.ts
Normal file
3
web/i18n/ru-RU/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/sl-SI/time.ts
Normal file
3
web/i18n/sl-SI/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/th-TH/time.ts
Normal file
3
web/i18n/th-TH/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/tr-TR/time.ts
Normal file
3
web/i18n/tr-TR/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/uk-UA/time.ts
Normal file
3
web/i18n/uk-UA/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/vi-VN/time.ts
Normal file
3
web/i18n/vi-VN/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
37
web/i18n/zh-Hans/time.ts
Normal file
37
web/i18n/zh-Hans/time.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const translation = {
|
||||||
|
daysInWeek: {
|
||||||
|
Sun: '日',
|
||||||
|
Mon: '一',
|
||||||
|
Tue: '二',
|
||||||
|
Wed: '三',
|
||||||
|
Thu: '四',
|
||||||
|
Fri: '五',
|
||||||
|
Sat: '六',
|
||||||
|
},
|
||||||
|
months: {
|
||||||
|
January: '一月',
|
||||||
|
February: '二月',
|
||||||
|
March: '三月',
|
||||||
|
April: '四月',
|
||||||
|
May: '五月',
|
||||||
|
June: '六月',
|
||||||
|
July: '七月',
|
||||||
|
August: '八月',
|
||||||
|
September: '九月',
|
||||||
|
October: '十月',
|
||||||
|
November: '十一月',
|
||||||
|
December: '十二月',
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
now: '此刻',
|
||||||
|
ok: '确定',
|
||||||
|
cancel: '取消',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
pickTime: '选择时间',
|
||||||
|
},
|
||||||
|
pickDate: '选择日期',
|
||||||
|
defaultPlaceholder: '请选择时间...',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default translation
|
3
web/i18n/zh-Hant/time.ts
Normal file
3
web/i18n/zh-Hant/time.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const translation = {}
|
||||||
|
|
||||||
|
export default translation
|
Loading…
x
Reference in New Issue
Block a user