feat: date and time picker (#13985)
This commit is contained in:
parent
4a332ff1af
commit
19d413ac1e
39 changed files with 1234 additions and 1 deletions
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue