TimeInput.tsx155 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { isNaN, noop } from 'lodash' |
| 3 | import { Clock } from 'lucide-react' |
| 4 | import { ChangeEvent, useEffect, useState } from 'react' |
| 5 | import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 6 | |
| 7 | import type { Time } from './PITR.types' |
| 8 | import { formatNumberToTwoDigits, formatTimeToTimeString } from './PITR.utils' |
| 9 | |
| 10 | // [Joshen] This is trying to do the same thing as TimeSplitInput.tsx |
| 11 | // so can we please look to try to combine these 2 components together if possible |
| 12 | // The problem with TimeSplitInput is that it's tightly coupled to the date + its |
| 13 | // tightly coupled to the context of a range. Ideally it should be more modular |
| 14 | // which is what this component is trying to achieve |
| 15 | |
| 16 | // [Joshen] Potential extension, give option to toggle 24 hours or AM/PM |
| 17 | |
| 18 | interface TimeInputProps { |
| 19 | defaultTime?: Time |
| 20 | minimumTime?: Time |
| 21 | maximumTime?: Time |
| 22 | onChange?: (time: Time) => void |
| 23 | } |
| 24 | |
| 25 | const TimeInput = ({ defaultTime, minimumTime, maximumTime, onChange = noop }: TimeInputProps) => { |
| 26 | const [isFocused, setIsFocused] = useState(false) |
| 27 | const [error, setError] = useState<string>() |
| 28 | const [time, setTime] = useState<Time>(defaultTime || { h: 0, m: 0, s: 0 }) |
| 29 | |
| 30 | const formattedMinimumTime = minimumTime |
| 31 | ? dayjs(formatTimeToTimeString(minimumTime), 'HH:mm:ss', true) |
| 32 | : undefined |
| 33 | |
| 34 | const formattedMaximumTime = maximumTime |
| 35 | ? dayjs(formatTimeToTimeString(maximumTime), 'HH:mm:ss', true) |
| 36 | : undefined |
| 37 | |
| 38 | useEffect(() => { |
| 39 | if (minimumTime || maximumTime) validate(time) |
| 40 | }, [JSON.stringify(minimumTime), JSON.stringify(maximumTime)]) |
| 41 | |
| 42 | useEffect(() => { |
| 43 | if (defaultTime) { |
| 44 | setTime(defaultTime) |
| 45 | validate(defaultTime) |
| 46 | } |
| 47 | }, [defaultTime]) |
| 48 | |
| 49 | const validate = (time: Time) => { |
| 50 | let error = undefined |
| 51 | const formattedTime = dayjs(formatTimeToTimeString(time), 'HH:mm:ss', true) |
| 52 | |
| 53 | if (!formattedTime.isValid()) { |
| 54 | error = 'Please enter a valid time' |
| 55 | } else if (formattedMinimumTime && formattedTime.isBefore(formattedMinimumTime)) { |
| 56 | error = 'Selected time is before the minimum time allowed' |
| 57 | } else if (formattedMaximumTime && formattedTime.isAfter(formattedMaximumTime)) { |
| 58 | error = 'Selected time is after the maximum time allowed' |
| 59 | } |
| 60 | |
| 61 | setError(error) |
| 62 | return error === undefined |
| 63 | } |
| 64 | |
| 65 | const onFocus = () => setIsFocused(true) |
| 66 | |
| 67 | const onInputChange = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => { |
| 68 | if (isNaN(Number(event.target.value))) return |
| 69 | setTime({ ...time, [unit]: event.target.value }) |
| 70 | } |
| 71 | |
| 72 | const onInputBlur = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => { |
| 73 | const formattedInput = Number(event.target.value) |
| 74 | const updatedTime = { ...time, [unit]: formattedInput } |
| 75 | setTime(updatedTime) |
| 76 | |
| 77 | validate(updatedTime) |
| 78 | onChange(updatedTime) |
| 79 | setIsFocused(false) |
| 80 | } |
| 81 | |
| 82 | return ( |
| 83 | <> |
| 84 | <div |
| 85 | className={[ |
| 86 | 'flex items-center justify-between transition', |
| 87 | 'rounded-md bg-studio border px-3.5 py-2 w-[200px]', |
| 88 | `${ |
| 89 | isFocused ? 'border-stronger' : error === undefined ? 'border-strong' : 'border-red-800' |
| 90 | }`, |
| 91 | ].join(' ')} |
| 92 | > |
| 93 | <Clock className="text-foreground-light" size={18} strokeWidth={1.5} /> |
| 94 | <Tooltip> |
| 95 | <TooltipTrigger className="w-1/4" tabIndex={-1}> |
| 96 | <input |
| 97 | type="text" |
| 98 | maxLength={2} |
| 99 | pattern="[0-9]*" |
| 100 | placeholder="HH" |
| 101 | value={formatNumberToTwoDigits(time.h)} |
| 102 | onFocus={onFocus} |
| 103 | aria-label="Hours" |
| 104 | onBlur={(event) => onInputBlur(event, 'h')} |
| 105 | onChange={(event) => onInputChange(event, 'h')} |
| 106 | className="w-full text-sm bg-transparent p-0 text-center outline-hidden border-none focus:ring-0" |
| 107 | /> |
| 108 | </TooltipTrigger> |
| 109 | |
| 110 | <TooltipContent side="bottom">Hours (HH)</TooltipContent> |
| 111 | </Tooltip> |
| 112 | <span>:</span> |
| 113 | <Tooltip> |
| 114 | <TooltipTrigger className="w-1/4" tabIndex={-1}> |
| 115 | <input |
| 116 | type="text" |
| 117 | maxLength={2} |
| 118 | pattern="[0-9]*" |
| 119 | placeholder="MM" |
| 120 | value={formatNumberToTwoDigits(time.m)} |
| 121 | onFocus={onFocus} |
| 122 | aria-label="Minutes" |
| 123 | onBlur={(event) => onInputBlur(event, 'm')} |
| 124 | onChange={(event) => onInputChange(event, 'm')} |
| 125 | className="w-full text-sm bg-transparent p-0 text-center outline-hidden border-none focus:ring-0" |
| 126 | /> |
| 127 | </TooltipTrigger> |
| 128 | |
| 129 | <TooltipContent side="bottom">Minutes (MM)</TooltipContent> |
| 130 | </Tooltip> |
| 131 | <span>:</span> |
| 132 | <Tooltip> |
| 133 | <TooltipTrigger className="w-1/4" tabIndex={-1}> |
| 134 | <input |
| 135 | type="text" |
| 136 | maxLength={2} |
| 137 | pattern="[0-9]*" |
| 138 | placeholder="SS" |
| 139 | value={formatNumberToTwoDigits(time.s)} |
| 140 | onFocus={onFocus} |
| 141 | aria-label="Seconds" |
| 142 | onBlur={(event) => onInputBlur(event, 's')} |
| 143 | onChange={(event) => onInputChange(event, 's')} |
| 144 | className="w-full text-sm bg-transparent p-0 text-center outline-hidden border-none focus:ring-0" |
| 145 | /> |
| 146 | </TooltipTrigger> |
| 147 | <TooltipContent side="bottom">Seconds (SS)</TooltipContent> |
| 148 | </Tooltip> |
| 149 | </div> |
| 150 | {error && <p className="text-sm text-red-900">{error}</p>} |
| 151 | </> |
| 152 | ) |
| 153 | } |
| 154 | |
| 155 | export default TimeInput |