index.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import React, { useCallback, useEffect, useRef, useState } from 'react'
  2. import dayjs from 'dayjs'
  3. import type { Period, TimePickerProps } from '../types'
  4. import { cloneTime, getHourIn12Hour } from '../utils'
  5. import {
  6. PortalToFollowElem,
  7. PortalToFollowElemContent,
  8. PortalToFollowElemTrigger,
  9. } from '@/app/components/base/portal-to-follow-elem'
  10. import Footer from './footer'
  11. import Options from './options'
  12. import Header from './header'
  13. import { useTranslation } from 'react-i18next'
  14. import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
  15. import cn from '@/utils/classnames'
  16. const TimePicker = ({
  17. value,
  18. placeholder,
  19. onChange,
  20. onClear,
  21. renderTrigger,
  22. }: TimePickerProps) => {
  23. const { t } = useTranslation()
  24. const [isOpen, setIsOpen] = useState(false)
  25. const containerRef = useRef<HTMLDivElement>(null)
  26. const [selectedTime, setSelectedTime] = useState(value)
  27. useEffect(() => {
  28. const handleClickOutside = (event: MouseEvent) => {
  29. if (containerRef.current && !containerRef.current.contains(event.target as Node))
  30. setIsOpen(false)
  31. }
  32. document.addEventListener('mousedown', handleClickOutside)
  33. return () => document.removeEventListener('mousedown', handleClickOutside)
  34. }, [])
  35. const handleClickTrigger = (e: React.MouseEvent) => {
  36. e.stopPropagation()
  37. if (isOpen) {
  38. setIsOpen(false)
  39. return
  40. }
  41. setIsOpen(true)
  42. }
  43. const handleClear = (e: React.MouseEvent) => {
  44. e.stopPropagation()
  45. setSelectedTime(undefined)
  46. if (!isOpen)
  47. onClear()
  48. }
  49. const handleTimeSelect = (hour: string, minute: string, period: Period) => {
  50. const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
  51. setSelectedTime((prev) => {
  52. return prev ? cloneTime(prev, newTime) : newTime
  53. })
  54. }
  55. const handleSelectHour = useCallback((hour: string) => {
  56. const time = selectedTime || dayjs().startOf('day')
  57. handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
  58. }, [selectedTime])
  59. const handleSelectMinute = useCallback((minute: string) => {
  60. const time = selectedTime || dayjs().startOf('day')
  61. handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
  62. }, [selectedTime])
  63. const handleSelectPeriod = useCallback((period: Period) => {
  64. const time = selectedTime || dayjs().startOf('day')
  65. handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
  66. }, [selectedTime])
  67. const handleSelectCurrentTime = useCallback(() => {
  68. const newDate = dayjs()
  69. setSelectedTime(newDate)
  70. onChange(newDate)
  71. setIsOpen(false)
  72. }, [onChange])
  73. const handleConfirm = useCallback(() => {
  74. onChange(selectedTime)
  75. setIsOpen(false)
  76. }, [onChange, selectedTime])
  77. const timeFormat = 'hh:mm A'
  78. const displayValue = value?.format(timeFormat) || ''
  79. const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
  80. return (
  81. <PortalToFollowElem
  82. open={isOpen}
  83. onOpenChange={setIsOpen}
  84. placement='bottom-end'
  85. >
  86. <PortalToFollowElemTrigger>
  87. {renderTrigger ? (renderTrigger()) : (
  88. <div
  89. 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'
  90. onClick={handleClickTrigger}
  91. >
  92. <input
  93. className='flex-1 p-1 bg-transparent text-components-input-text-filled placeholder:text-components-input-text-placeholder truncate system-xs-regular
  94. outline-none appearance-none cursor-pointer'
  95. readOnly
  96. value={isOpen ? '' : displayValue}
  97. placeholder={placeholderDate}
  98. />
  99. <RiTimeLine className={cn(
  100. 'shrink-0 w-4 h-4 text-text-quaternary',
  101. isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
  102. (displayValue || (isOpen && selectedTime)) && 'group-hover:hidden',
  103. )} />
  104. <RiCloseCircleFill
  105. className={cn(
  106. 'hidden shrink-0 w-4 h-4 text-text-quaternary',
  107. (displayValue || (isOpen && selectedTime)) && 'group-hover:inline-block hover:text-text-secondary',
  108. )}
  109. onClick={handleClear}
  110. />
  111. </div>
  112. )}
  113. </PortalToFollowElemTrigger>
  114. <PortalToFollowElemContent>
  115. <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'>
  116. {/* Header */}
  117. <Header />
  118. {/* Time Options */}
  119. <Options
  120. selectedTime={selectedTime}
  121. handleSelectHour={handleSelectHour}
  122. handleSelectMinute={handleSelectMinute}
  123. handleSelectPeriod={handleSelectPeriod}
  124. />
  125. {/* Footer */}
  126. <Footer
  127. handleSelectCurrentTime={handleSelectCurrentTime}
  128. handleConfirm={handleConfirm}
  129. />
  130. </div>
  131. </PortalToFollowElemContent>
  132. </PortalToFollowElem>
  133. )
  134. }
  135. export default TimePicker