From 19d413ac1ea075104e2c0ac93560c7b010c29330 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 19 Feb 2025 10:56:18 +0800 Subject: [PATCH] feat: date and time picker (#13985) --- .../calendar/days-of-week.tsx | 21 ++ .../date-and-time-picker/calendar/index.tsx | 27 ++ .../date-and-time-picker/calendar/item.tsx | 30 ++ .../common/option-list-item.tsx | 38 +++ .../date-picker/footer.tsx | 59 ++++ .../date-picker/header.tsx | 41 +++ .../date-picker/index.tsx | 279 ++++++++++++++++++ .../base/date-and-time-picker/hooks.ts | 49 +++ .../time-picker/footer.tsx | 37 +++ .../time-picker/header.tsx | 16 + .../time-picker/index.tsx | 151 ++++++++++ .../time-picker/options.tsx | 71 +++++ .../base/date-and-time-picker/types.ts | 101 +++++++ .../base/date-and-time-picker/utils.ts | 64 ++++ .../year-and-month-picker/footer.tsx | 25 ++ .../year-and-month-picker/header.tsx | 27 ++ .../year-and-month-picker/options.tsx | 55 ++++ web/app/styles/globals.css | 15 +- web/i18n/de-DE/time.ts | 3 + web/i18n/en-US/time.ts | 37 +++ web/i18n/es-ES/time.ts | 3 + web/i18n/fa-IR/time.ts | 3 + web/i18n/fr-FR/time.ts | 3 + web/i18n/hi-IN/time.ts | 3 + web/i18n/i18next-config.ts | 1 + web/i18n/it-IT/time.ts | 3 + web/i18n/ja-JP/time.ts | 3 + web/i18n/ko-KR/time.ts | 3 + web/i18n/pl-PL/time.ts | 3 + web/i18n/pt-BR/time.ts | 3 + web/i18n/ro-RO/time.ts | 3 + web/i18n/ru-RU/time.ts | 3 + web/i18n/sl-SI/time.ts | 3 + web/i18n/th-TH/time.ts | 3 + web/i18n/tr-TR/time.ts | 3 + web/i18n/uk-UA/time.ts | 3 + web/i18n/vi-VN/time.ts | 3 + web/i18n/zh-Hans/time.ts | 37 +++ web/i18n/zh-Hant/time.ts | 3 + 39 files changed, 1234 insertions(+), 1 deletion(-) create mode 100644 web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx create mode 100644 web/app/components/base/date-and-time-picker/calendar/index.tsx create mode 100644 web/app/components/base/date-and-time-picker/calendar/item.tsx create mode 100644 web/app/components/base/date-and-time-picker/common/option-list-item.tsx create mode 100644 web/app/components/base/date-and-time-picker/date-picker/footer.tsx create mode 100644 web/app/components/base/date-and-time-picker/date-picker/header.tsx create mode 100644 web/app/components/base/date-and-time-picker/date-picker/index.tsx create mode 100644 web/app/components/base/date-and-time-picker/hooks.ts create mode 100644 web/app/components/base/date-and-time-picker/time-picker/footer.tsx create mode 100644 web/app/components/base/date-and-time-picker/time-picker/header.tsx create mode 100644 web/app/components/base/date-and-time-picker/time-picker/index.tsx create mode 100644 web/app/components/base/date-and-time-picker/time-picker/options.tsx create mode 100644 web/app/components/base/date-and-time-picker/types.ts create mode 100644 web/app/components/base/date-and-time-picker/utils.ts create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx create mode 100644 web/i18n/de-DE/time.ts create mode 100644 web/i18n/en-US/time.ts create mode 100644 web/i18n/es-ES/time.ts create mode 100644 web/i18n/fa-IR/time.ts create mode 100644 web/i18n/fr-FR/time.ts create mode 100644 web/i18n/hi-IN/time.ts create mode 100644 web/i18n/it-IT/time.ts create mode 100644 web/i18n/ja-JP/time.ts create mode 100644 web/i18n/ko-KR/time.ts create mode 100644 web/i18n/pl-PL/time.ts create mode 100644 web/i18n/pt-BR/time.ts create mode 100644 web/i18n/ro-RO/time.ts create mode 100644 web/i18n/ru-RU/time.ts create mode 100644 web/i18n/sl-SI/time.ts create mode 100644 web/i18n/th-TH/time.ts create mode 100644 web/i18n/tr-TR/time.ts create mode 100644 web/i18n/uk-UA/time.ts create mode 100644 web/i18n/vi-VN/time.ts create mode 100644 web/i18n/zh-Hans/time.ts create mode 100644 web/i18n/zh-Hant/time.ts diff --git a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx new file mode 100644 index 0000000000..9de122d254 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { useDaysOfWeek } from '../hooks' + +export const DaysOfWeek = () => { + const daysOfWeek = useDaysOfWeek() + + return ( +
+ {daysOfWeek.map(day => ( +
+ {day} +
+ ))} +
+ ) +} + +export default React.memo(DaysOfWeek) diff --git a/web/app/components/base/date-and-time-picker/calendar/index.tsx b/web/app/components/base/date-and-time-picker/calendar/index.tsx new file mode 100644 index 0000000000..00612fcb37 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/calendar/index.tsx @@ -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 = ({ + days, + selectedDate, + onDateClick, + wrapperClassName, +}) => { + return
+ +
+ { + days.map(day => ) + } +
+
+} + +export default Calendar diff --git a/web/app/components/base/date-and-time-picker/calendar/item.tsx b/web/app/components/base/date-and-time-picker/calendar/item.tsx new file mode 100644 index 0000000000..166cc2eb23 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/calendar/item.tsx @@ -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 = ({ + day, + selectedDate, + onClick, +}) => { + const { date, isCurrentMonth } = day + const isSelected = selectedDate?.isSame(date, 'date') + const isToday = date.isSame(dayjs(), 'date') + + return ( + + ) +} + +export default React.memo(Item) diff --git a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx new file mode 100644 index 0000000000..3e2fccce7b --- /dev/null +++ b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx @@ -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 + +const OptionListItem: FC = ({ + isSelected, + onClick, + children, +}) => { + const listItemRef = useRef(null) + + useEffect(() => { + if (isSelected) + listItemRef.current?.scrollIntoView({ behavior: 'instant' }) + }, []) + + return ( +
  • { + listItemRef.current?.scrollIntoView({ behavior: 'smooth' }) + onClick() + }} + > + {children} +
  • + ) +} + +export default React.memo(OptionListItem) diff --git a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx new file mode 100644 index 0000000000..6233ea714b --- /dev/null +++ b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx @@ -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 = ({ + needTimePicker, + displayTime, + view, + handleClickTimePicker, + handleSelectCurrentDate, + handleConfirmDate, +}) => { + const { t } = useTranslation() + + return ( +
    + {/* Time Picker */} + {needTimePicker && ( + + )} +
    + {/* Now */} + + {/* Confirm Button */} + +
    +
    + ) +} + +export default React.memo(Footer) diff --git a/web/app/components/base/date-and-time-picker/date-picker/header.tsx b/web/app/components/base/date-and-time-picker/date-picker/header.tsx new file mode 100644 index 0000000000..009e1eaec5 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/date-picker/header.tsx @@ -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 = ({ + handleOpenYearMonthPicker, + currentDate, + onClickNextMonth, + onClickPrevMonth, +}) => { + const months = useMonths() + + return ( +
    +
    + +
    + + +
    + ) +} + +export default React.memo(Header) diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx new file mode 100644 index 0000000000..06dd459ece --- /dev/null +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -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(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 ( + + + {renderTrigger ? (renderTrigger({ + value, + selectedDate, + isOpen, + handleClear, + handleClickTrigger, + })) : ( +
    + + + +
    + )} +
    + +
    + {/* Header */} + {view === ViewType.date ? ( + + ) : view === ViewType.yearMonth ? ( + + ) : ( + + )} + + {/* Content */} + { + view === ViewType.date ? ( + + ) : view === ViewType.yearMonth ? ( + + ) : ( + + ) + } + + {/* Footer */} + { + [ViewType.date, ViewType.time].includes(view) ? ( + + ) : ( + + ) + } +
    +
    +
    + ) +} + +export default DatePicker diff --git a/web/app/components/base/date-and-time-picker/hooks.ts b/web/app/components/base/date-and-time-picker/hooks.ts new file mode 100644 index 0000000000..0976234b4d --- /dev/null +++ b/web/app/components/base/date-and-time-picker/hooks.ts @@ -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, + } +} diff --git a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx new file mode 100644 index 0000000000..6209ef5c08 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx @@ -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 = ({ + handleSelectCurrentTime, + handleConfirm, +}) => { + const { t } = useTranslation() + + return ( +
    +
    + {/* Now */} + + {/* Confirm Button */} + +
    +
    + ) +} + +export default React.memo(Footer) diff --git a/web/app/components/base/date-and-time-picker/time-picker/header.tsx b/web/app/components/base/date-and-time-picker/time-picker/header.tsx new file mode 100644 index 0000000000..fc4a1fe2a0 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/time-picker/header.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +const Header = () => { + const { t } = useTranslation() + + return ( +
    +
    + {t('time.title.pickTime')} +
    +
    + ) +} + +export default React.memo(Header) diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx new file mode 100644 index 0000000000..348079e535 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -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(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 ( + + + {renderTrigger ? (renderTrigger()) : ( +
    + + + +
    + )} +
    + +
    + {/* Header */} +
    + + {/* Time Options */} + + + {/* Footer */} +
    + +
    +
    +
    + ) +} + +export default TimePicker diff --git a/web/app/components/base/date-and-time-picker/time-picker/options.tsx b/web/app/components/base/date-and-time-picker/time-picker/options.tsx new file mode 100644 index 0000000000..0e297c91b8 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/time-picker/options.tsx @@ -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 = ({ + selectedTime, + handleSelectHour, + handleSelectMinute, + handleSelectPeriod, +}) => { + const { hourOptions, minuteOptions, periodOptions } = useTimeOptions() + + return ( +
    + {/* Hour */} +
      + { + hourOptions.map((hour) => { + const isSelected = selectedTime?.format('hh') === hour + return ( + + {hour} + + ) + }) + } +
    + {/* Minute */} +
      + { + minuteOptions.map((minute) => { + const isSelected = selectedTime?.format('mm') === minute + return ( + + {minute} + + ) + }) + } +
    + {/* Period */} +
      + { + periodOptions.map((period) => { + const isSelected = selectedTime?.format('A') === period + return ( + + {period} + + ) + }) + } +
    +
    + ) +} + +export default React.memo(Options) diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts new file mode 100644 index 0000000000..03fc103739 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -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 +} diff --git a/web/app/components/base/date-and-time-picker/utils.ts b/web/app/components/base/date-and-time-picker/utils.ts new file mode 100644 index 0000000000..6d1258c8df --- /dev/null +++ b/web/app/components/base/date-and-time-picker/utils.ts @@ -0,0 +1,64 @@ +import type { Dayjs } from 'dayjs' +import type { Day } from './types' + +const monthMaps: Record = {} + +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 +} diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx new file mode 100644 index 0000000000..8e0566aefc --- /dev/null +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx @@ -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 = ({ + handleYearMonthCancel, + handleYearMonthConfirm, +}) => { + const { t } = useTranslation() + + return ( +
    + + +
    + ) +} + +export default React.memo(Footer) diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx new file mode 100644 index 0000000000..121c784b0a --- /dev/null +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx @@ -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 = ({ + selectedYear, + selectedMonth, + onClick, +}) => { + const months = useMonths() + + return ( +
    + {/* Year and Month */} + +
    + ) +} + +export default React.memo(Header) diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx new file mode 100644 index 0000000000..5864cc94e7 --- /dev/null +++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx @@ -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 = ({ + selectedMonth, + selectedYear, + handleMonthSelect, + handleYearSelect, +}) => { + const months = useMonths() + const yearOptions = useYearOptions() + + return ( +
    + {/* Month Picker */} +
      + { + months.map((month, index) => { + const isSelected = selectedMonth === index + return ( + + {month} + + ) + }) + } +
    + {/* Year Picker */} +
      + { + yearOptions.map((year) => { + const isSelected = selectedYear === year + return ( + + {year} + + ) + }) + } +
    +
    + ) +} + +export default React.memo(Options) diff --git a/web/app/styles/globals.css b/web/app/styles/globals.css index 573523fd48..93ef2ce166 100644 --- a/web/app/styles/globals.css +++ b/web/app/styles/globals.css @@ -684,4 +684,17 @@ button:focus-within { @import "../components/base/action-button/index.css"; @import "../components/base/modal/index.css"; -@tailwind utilities; \ No newline at end of file +@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; + } +} \ No newline at end of file diff --git a/web/i18n/de-DE/time.ts b/web/i18n/de-DE/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/de-DE/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/en-US/time.ts b/web/i18n/en-US/time.ts new file mode 100644 index 0000000000..40adad0231 --- /dev/null +++ b/web/i18n/en-US/time.ts @@ -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 diff --git a/web/i18n/es-ES/time.ts b/web/i18n/es-ES/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/es-ES/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/fa-IR/time.ts b/web/i18n/fa-IR/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/fa-IR/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/fr-FR/time.ts b/web/i18n/fr-FR/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/fr-FR/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/hi-IN/time.ts b/web/i18n/hi-IN/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/hi-IN/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/i18next-config.ts b/web/i18n/i18next-config.ts index bbba4c7c35..7215b7818e 100644 --- a/web/i18n/i18next-config.ts +++ b/web/i18n/i18next-config.ts @@ -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, }, }) diff --git a/web/i18n/it-IT/time.ts b/web/i18n/it-IT/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/it-IT/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/ja-JP/time.ts b/web/i18n/ja-JP/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/ja-JP/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/ko-KR/time.ts b/web/i18n/ko-KR/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/ko-KR/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/pl-PL/time.ts b/web/i18n/pl-PL/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/pl-PL/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/pt-BR/time.ts b/web/i18n/pt-BR/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/pt-BR/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/ro-RO/time.ts b/web/i18n/ro-RO/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/ro-RO/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/ru-RU/time.ts b/web/i18n/ru-RU/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/ru-RU/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/sl-SI/time.ts b/web/i18n/sl-SI/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/sl-SI/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/th-TH/time.ts b/web/i18n/th-TH/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/th-TH/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/tr-TR/time.ts b/web/i18n/tr-TR/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/tr-TR/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/uk-UA/time.ts b/web/i18n/uk-UA/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/uk-UA/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/vi-VN/time.ts b/web/i18n/vi-VN/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/vi-VN/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation diff --git a/web/i18n/zh-Hans/time.ts b/web/i18n/zh-Hans/time.ts new file mode 100644 index 0000000000..8a223d9dd1 --- /dev/null +++ b/web/i18n/zh-Hans/time.ts @@ -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 diff --git a/web/i18n/zh-Hant/time.ts b/web/i18n/zh-Hant/time.ts new file mode 100644 index 0000000000..e2410dd34b --- /dev/null +++ b/web/i18n/zh-Hant/time.ts @@ -0,0 +1,3 @@ +const translation = {} + +export default translation