feat: date and time picker (#13985)

This commit is contained in:
Wu Tianwei 2025-02-19 10:56:18 +08:00 committed by GitHub
parent 4a332ff1af
commit 19d413ac1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1234 additions and 1 deletions

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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,
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View 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
}

View 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
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

37
web/i18n/en-US/time.ts Normal file
View 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
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/fa-IR/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/fr-FR/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/hi-IN/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

View File

@ -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
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/ja-JP/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/ko-KR/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/pl-PL/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/pt-BR/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/ro-RO/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/ru-RU/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/sl-SI/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/th-TH/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/tr-TR/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/uk-UA/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

3
web/i18n/vi-VN/time.ts Normal file
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation

37
web/i18n/zh-Hans/time.ts Normal file
View 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
View File

@ -0,0 +1,3 @@
const translation = {}
export default translation