| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| } | |||
| @@ -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/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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -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 | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -30,6 +30,7 @@ const loadLangResources = (lang: string) => ({ | |||
| runLog: require(`./${lang}/run-log`).default, | |||
| plugin: require(`./${lang}/plugin`).default, | |||
| pluginTags: require(`./${lang}/plugin-tags`).default, | |||
| time: require(`./${lang}/time`).default, | |||
| }, | |||
| }) | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||
| @@ -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 | |||
| @@ -0,0 +1,3 @@ | |||
| const translation = {} | |||
| export default translation | |||