Browse Source

feat: date and time picker (#13985)

Wu Tianwei 2 months ago
parent
commit
19d413ac1e
39 changed files with 1234 additions and 1 deletions
  1. 21 0
      web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
  2. 27 0
      web/app/components/base/date-and-time-picker/calendar/index.tsx
  3. 30 0
      web/app/components/base/date-and-time-picker/calendar/item.tsx
  4. 38 0
      web/app/components/base/date-and-time-picker/common/option-list-item.tsx
  5. 59 0
      web/app/components/base/date-and-time-picker/date-picker/footer.tsx
  6. 41 0
      web/app/components/base/date-and-time-picker/date-picker/header.tsx
  7. 279 0
      web/app/components/base/date-and-time-picker/date-picker/index.tsx
  8. 49 0
      web/app/components/base/date-and-time-picker/hooks.ts
  9. 37 0
      web/app/components/base/date-and-time-picker/time-picker/footer.tsx
  10. 16 0
      web/app/components/base/date-and-time-picker/time-picker/header.tsx
  11. 151 0
      web/app/components/base/date-and-time-picker/time-picker/index.tsx
  12. 71 0
      web/app/components/base/date-and-time-picker/time-picker/options.tsx
  13. 101 0
      web/app/components/base/date-and-time-picker/types.ts
  14. 64 0
      web/app/components/base/date-and-time-picker/utils.ts
  15. 25 0
      web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx
  16. 27 0
      web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx
  17. 55 0
      web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx
  18. 14 1
      web/app/styles/globals.css
  19. 3 0
      web/i18n/de-DE/time.ts
  20. 37 0
      web/i18n/en-US/time.ts
  21. 3 0
      web/i18n/es-ES/time.ts
  22. 3 0
      web/i18n/fa-IR/time.ts
  23. 3 0
      web/i18n/fr-FR/time.ts
  24. 3 0
      web/i18n/hi-IN/time.ts
  25. 1 0
      web/i18n/i18next-config.ts
  26. 3 0
      web/i18n/it-IT/time.ts
  27. 3 0
      web/i18n/ja-JP/time.ts
  28. 3 0
      web/i18n/ko-KR/time.ts
  29. 3 0
      web/i18n/pl-PL/time.ts
  30. 3 0
      web/i18n/pt-BR/time.ts
  31. 3 0
      web/i18n/ro-RO/time.ts
  32. 3 0
      web/i18n/ru-RU/time.ts
  33. 3 0
      web/i18n/sl-SI/time.ts
  34. 3 0
      web/i18n/th-TH/time.ts
  35. 3 0
      web/i18n/tr-TR/time.ts
  36. 3 0
      web/i18n/uk-UA/time.ts
  37. 3 0
      web/i18n/vi-VN/time.ts
  38. 37 0
      web/i18n/zh-Hans/time.ts
  39. 3 0
      web/i18n/zh-Hant/time.ts

+ 21 - 0
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 (
+    <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)

+ 27 - 0
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<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

+ 30 - 0
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<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)

+ 38 - 0
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<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)

+ 59 - 0
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<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)

+ 41 - 0
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<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)

+ 279 - 0
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<HTMLDivElement>(null)
+
+  const [currentDate, setCurrentDate] = useState(value || dayjs())
+  const [selectedDate, setSelectedDate] = useState(value)
+
+  const [selectedMonth, setSelectedMonth] = useState((value || dayjs()).month())
+  const [selectedYear, setSelectedYear] = useState((value || dayjs()).year())
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+        setIsOpen(false)
+        setView(ViewType.date)
+      }
+    }
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => document.removeEventListener('mousedown', handleClickOutside)
+  }, [])
+
+  const handleClickTrigger = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (isOpen) {
+      setIsOpen(false)
+      return
+    }
+    setView(ViewType.date)
+    setIsOpen(true)
+  }
+
+  const handleClear = (e: React.MouseEvent) => {
+    const newDate = dayjs()
+    e.stopPropagation()
+    setSelectedDate(undefined)
+    setCurrentDate(prev => prev || newDate)
+    setSelectedMonth(prev => prev || newDate.month())
+    setSelectedYear(prev => prev || newDate.year())
+    if (!isOpen)
+      onClear()
+  }
+
+  const days = useMemo(() => {
+    return getDaysInMonth(currentDate)
+  }, [currentDate])
+
+  const handleClickNextMonth = useCallback(() => {
+    setCurrentDate(currentDate.clone().add(1, 'month'))
+  }, [currentDate])
+
+  const handleClickPrevMonth = useCallback(() => {
+    setCurrentDate(currentDate.clone().subtract(1, 'month'))
+  }, [currentDate])
+
+  const handleDateSelect = useCallback((day: Dayjs) => {
+    const newDate = cloneTime(day, selectedDate || dayjs())
+    setCurrentDate(newDate)
+    setSelectedDate(newDate)
+  }, [selectedDate])
+
+  const handleSelectCurrentDate = () => {
+    const newDate = dayjs()
+    setCurrentDate(newDate)
+    setSelectedDate(newDate)
+    onChange(newDate)
+    setIsOpen(false)
+  }
+
+  const handleConfirmDate = () => {
+    onChange(selectedDate)
+    setIsOpen(false)
+  }
+
+  const handleClickTimePicker = () => {
+    if (view === ViewType.date) {
+      setView(ViewType.time)
+      return
+    }
+    if (view === ViewType.time)
+      setView(ViewType.date)
+  }
+
+  const handleTimeSelect = (hour: string, minute: string, period: Period) => {
+    const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
+    setSelectedDate((prev) => {
+      return prev ? cloneTime(prev, newTime) : newTime
+    })
+  }
+
+  const handleSelectHour = useCallback((hour: string) => {
+    const selectedTime = selectedDate || dayjs()
+    handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
+  }, [selectedDate])
+
+  const handleSelectMinute = useCallback((minute: string) => {
+    const selectedTime = selectedDate || dayjs()
+    handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
+  }, [selectedDate])
+
+  const handleSelectPeriod = useCallback((period: Period) => {
+    const selectedTime = selectedDate || dayjs()
+    handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
+  }, [selectedDate])
+
+  const handleOpenYearMonthPicker = () => {
+    setSelectedMonth(currentDate.month())
+    setSelectedYear(currentDate.year())
+    setView(ViewType.yearMonth)
+  }
+
+  const handleCloseYearMonthPicker = useCallback(() => {
+    setView(ViewType.date)
+  }, [])
+
+  const handleMonthSelect = useCallback((month: number) => {
+    setSelectedMonth(month)
+  }, [])
+
+  const handleYearSelect = useCallback((year: number) => {
+    setSelectedYear(year)
+  }, [])
+
+  const handleYearMonthCancel = useCallback(() => {
+    setView(ViewType.date)
+  }, [])
+
+  const handleYearMonthConfirm = () => {
+    setCurrentDate((prev) => {
+      return prev ? prev.clone().month(selectedMonth).year(selectedYear) : dayjs().month(selectedMonth).year(selectedYear)
+    })
+    setView(ViewType.date)
+  }
+
+  const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
+  const displayValue = value?.format(timeFormat) || ''
+  const displayTime = (selectedDate || dayjs().startOf('day')).format('hh:mm A')
+  const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
+
+  return (
+    <PortalToFollowElem
+      open={isOpen}
+      onOpenChange={setIsOpen}
+      placement='bottom-end'
+    >
+      <PortalToFollowElemTrigger>
+        {renderTrigger ? (renderTrigger({
+          value,
+          selectedDate,
+          isOpen,
+          handleClear,
+          handleClickTrigger,
+        })) : (
+          <div
+            className='w-[252px] flex items-center gap-x-0.5 rounded-lg px-2 py-1 bg-components-input-bg-normal cursor-pointer group hover:bg-state-base-hover-alt'
+            onClick={handleClickTrigger}
+          >
+            <input
+              className='flex-1 p-1 bg-transparent text-components-input-text-filled placeholder:text-components-input-text-placeholder truncate system-xs-regular
+            outline-none appearance-none cursor-pointer'
+              readOnly
+              value={isOpen ? '' : displayValue}
+              placeholder={placeholderDate}
+            />
+            <RiCalendarLine className={cn(
+              'shrink-0 w-4 h-4 text-text-quaternary',
+              isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
+              (displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
+            )} />
+            <RiCloseCircleFill
+              className={cn(
+                'hidden shrink-0 w-4 h-4 text-text-quaternary',
+                (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary',
+              )}
+              onClick={handleClear}
+            />
+          </div>
+        )}
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent>
+        <div className='w-[252px] mt-1 bg-components-panel-bg rounded-xl shadow-lg shadow-shadow-shadow-5 border-[0.5px] border-components-panel-border'>
+          {/* Header */}
+          {view === ViewType.date ? (
+            <DatePickerHeader
+              handleOpenYearMonthPicker={handleOpenYearMonthPicker}
+              currentDate={currentDate}
+              onClickNextMonth={handleClickNextMonth}
+              onClickPrevMonth={handleClickPrevMonth}
+            />
+          ) : view === ViewType.yearMonth ? (
+            <YearAndMonthPickerHeader
+              selectedYear={selectedYear}
+              selectedMonth={selectedMonth}
+              onClick={handleCloseYearMonthPicker}
+            />
+          ) : (
+            <TimePickerHeader />
+          )}
+
+          {/* Content */}
+          {
+            view === ViewType.date ? (
+              <Calendar
+                days={days}
+                selectedDate={selectedDate}
+                onDateClick={handleDateSelect}
+              />
+            ) : view === ViewType.yearMonth ? (
+              <YearAndMonthPickerOptions
+                selectedMonth={selectedMonth}
+                selectedYear={selectedYear}
+                handleMonthSelect={handleMonthSelect}
+                handleYearSelect={handleYearSelect}
+              />
+            ) : (
+              <TimePickerOptions
+                selectedTime={selectedDate}
+                handleSelectHour={handleSelectHour}
+                handleSelectMinute={handleSelectMinute}
+                handleSelectPeriod={handleSelectPeriod}
+              />
+            )
+          }
+
+          {/* Footer */}
+          {
+            [ViewType.date, ViewType.time].includes(view) ? (
+              <DatePickerFooter
+                needTimePicker={needTimePicker}
+                displayTime={displayTime}
+                view={view}
+                handleClickTimePicker={handleClickTimePicker}
+                handleSelectCurrentDate={handleSelectCurrentDate}
+                handleConfirmDate={handleConfirmDate}
+              />
+            ) : (
+              <YearAndMonthPickerFooter
+                handleYearMonthCancel={handleYearMonthCancel}
+                handleYearMonthConfirm={handleYearMonthConfirm}
+              />
+            )
+          }
+        </div>
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default DatePicker

+ 49 - 0
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,
+  }
+}

+ 37 - 0
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<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)

+ 16 - 0
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 (
+    <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)

+ 151 - 0
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<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

+ 71 - 0
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<TimeOptionsProps> = ({
+  selectedTime,
+  handleSelectHour,
+  handleSelectMinute,
+  handleSelectPeriod,
+}) => {
+  const { hourOptions, minuteOptions, periodOptions } = useTimeOptions()
+
+  return (
+    <div className='grid grid-cols-3 gap-x-1 p-2'>
+      {/* Hour */}
+      <ul className='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar pb-[184px]'>
+        {
+          hourOptions.map((hour) => {
+            const isSelected = selectedTime?.format('hh') === hour
+            return (
+              <OptionListItem
+                key={hour}
+                isSelected={isSelected}
+                onClick={handleSelectHour.bind(null, hour)}
+              >
+                {hour}
+              </OptionListItem>
+            )
+          })
+        }
+      </ul>
+      {/* Minute */}
+      <ul className='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar pb-[184px]'>
+        {
+          minuteOptions.map((minute) => {
+            const isSelected = selectedTime?.format('mm') === minute
+            return (
+              <OptionListItem
+                key={minute}
+                isSelected={isSelected}
+                onClick={handleSelectMinute.bind(null, minute)}
+              >
+                {minute}
+              </OptionListItem>
+            )
+          })
+        }
+      </ul>
+      {/* Period */}
+      <ul className='flex flex-col gap-y-0.5 h-[208px] overflow-y-auto no-scrollbar pb-[184px]'>
+        {
+          periodOptions.map((period) => {
+            const isSelected = selectedTime?.format('A') === period
+            return (
+              <OptionListItem
+                key={period}
+                isSelected={isSelected}
+                onClick={handleSelectPeriod.bind(null, period)}
+              >
+                {period}
+              </OptionListItem>
+            )
+          })
+        }
+      </ul>
+    </div>
+  )
+}
+
+export default React.memo(Options)

+ 101 - 0
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
+}

+ 64 - 0
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<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
+}

+ 25 - 0
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<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)

+ 27 - 0
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<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)

+ 55 - 0
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<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)

+ 14 - 1
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;
+@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 - 0
web/i18n/de-DE/time.ts

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

+ 37 - 0
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

+ 3 - 0
web/i18n/es-ES/time.ts

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

+ 3 - 0
web/i18n/fa-IR/time.ts

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

+ 3 - 0
web/i18n/fr-FR/time.ts

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

+ 3 - 0
web/i18n/hi-IN/time.ts

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

+ 1 - 0
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,
   },
 })
 

+ 3 - 0
web/i18n/it-IT/time.ts

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

+ 3 - 0
web/i18n/ja-JP/time.ts

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

+ 3 - 0
web/i18n/ko-KR/time.ts

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

+ 3 - 0
web/i18n/pl-PL/time.ts

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

+ 3 - 0
web/i18n/pt-BR/time.ts

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

+ 3 - 0
web/i18n/ro-RO/time.ts

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

+ 3 - 0
web/i18n/ru-RU/time.ts

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

+ 3 - 0
web/i18n/sl-SI/time.ts

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

+ 3 - 0
web/i18n/th-TH/time.ts

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

+ 3 - 0
web/i18n/tr-TR/time.ts

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

+ 3 - 0
web/i18n/uk-UA/time.ts

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

+ 3 - 0
web/i18n/vi-VN/time.ts

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

+ 37 - 0
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

+ 3 - 0
web/i18n/zh-Hant/time.ts

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