index.tsx 5.8 KB

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