You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 6.0KB

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