|
11 | 11 |
|
12 | 12 | import { Button } from 'components/redpanda-ui/components/button'; |
13 | 13 | import { Calendar } from 'components/redpanda-ui/components/calendar'; |
14 | | -import { Input } from 'components/redpanda-ui/components/input'; |
15 | | -import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; |
16 | | -import { cn } from 'components/redpanda-ui/lib/utils'; |
| 14 | +import { Input, InputEnd } from 'components/redpanda-ui/components/input'; |
| 15 | +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; |
17 | 16 | import { CalendarIcon } from 'lucide-react'; |
18 | 17 | import { useMemo, useState } from 'react'; |
19 | 18 |
|
20 | | -const DATE_PART_LENGTH = 10; // YYYY-MM-DD |
21 | | -const TIME_PART_LENGTH = 8; // HH:mm:ss |
| 19 | +// --------------------------------------------------------------------------- |
| 20 | +// Types |
| 21 | +// --------------------------------------------------------------------------- |
| 22 | + |
| 23 | +export type DateTimeInputMode = 'local' | 'utc'; |
| 24 | + |
| 25 | +export type DateTimeInputProps = { |
| 26 | + /** UTC milliseconds, or undefined when no value is set yet. */ |
| 27 | + value: number | undefined; |
| 28 | + /** Always called with UTC milliseconds, regardless of the displayed mode. */ |
| 29 | + onChange: (utcMs: number) => void; |
| 30 | + disabled?: boolean; |
| 31 | + /** Default `local`. Lets the picker display values in UTC. */ |
| 32 | + defaultMode?: DateTimeInputMode; |
| 33 | + /** Hide the Local/UTC toggle (e.g. for sites that don't care about UTC). */ |
| 34 | + hideTimezoneToggle?: boolean; |
| 35 | + className?: string; |
| 36 | + placeholder?: string; |
| 37 | + 'data-testid'?: string; |
| 38 | +}; |
| 39 | + |
| 40 | +// --------------------------------------------------------------------------- |
| 41 | +// Helpers |
| 42 | +// --------------------------------------------------------------------------- |
| 43 | + |
| 44 | +const MS_PER_MINUTE = 60_000; |
22 | 45 |
|
23 | 46 | const pad = (n: number, len = 2) => String(n).padStart(len, '0'); |
24 | 47 |
|
25 | | -const toLocalDate = (utcMs: number) => new Date(utcMs); |
| 48 | +const isFiniteNumber = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n); |
26 | 49 |
|
27 | | -const formatDateLabel = (utcMs: number) => { |
28 | | - const d = toLocalDate(utcMs); |
29 | | - return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; |
| 50 | +type DateParts = { |
| 51 | + year: number; |
| 52 | + month: number; |
| 53 | + day: number; |
| 54 | + hours: number; |
| 55 | + minutes: number; |
| 56 | + seconds: number; |
30 | 57 | }; |
31 | 58 |
|
32 | | -const formatTimeLabel = (utcMs: number) => { |
33 | | - const d = toLocalDate(utcMs); |
34 | | - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; |
| 59 | +/** |
| 60 | + * Break a UTC moment into Y/M/D/h/m/s parts for display in either the user's |
| 61 | + * local zone or UTC. The UTC path adjusts by the local offset so `Date#getX` |
| 62 | + * returns UTC values. |
| 63 | + */ |
| 64 | +const partsFor = (utcMs: number, mode: DateTimeInputMode): DateParts => { |
| 65 | + const d = mode === 'utc' ? new Date(utcMs + new Date(utcMs).getTimezoneOffset() * MS_PER_MINUTE) : new Date(utcMs); |
| 66 | + return { |
| 67 | + year: d.getFullYear(), |
| 68 | + month: d.getMonth(), |
| 69 | + day: d.getDate(), |
| 70 | + hours: d.getHours(), |
| 71 | + minutes: d.getMinutes(), |
| 72 | + seconds: d.getSeconds(), |
| 73 | + }; |
35 | 74 | }; |
36 | 75 |
|
37 | | -const composeUtcMs = (datePart: string, timePart: string): number | null => { |
38 | | - if (datePart.length !== DATE_PART_LENGTH) { |
39 | | - return null; |
40 | | - } |
41 | | - const normalizedTime = timePart.length === 5 ? `${timePart}:00` : timePart; |
42 | | - if (normalizedTime.length !== TIME_PART_LENGTH) { |
43 | | - return null; |
| 76 | +/** Inverse of `partsFor`. */ |
| 77 | +const composeUtcMs = (parts: DateParts, mode: DateTimeInputMode): number => { |
| 78 | + if (mode === 'utc') { |
| 79 | + return Date.UTC(parts.year, parts.month, parts.day, parts.hours, parts.minutes, parts.seconds); |
44 | 80 | } |
45 | | - const ms = Date.parse(`${datePart}T${normalizedTime}`); |
46 | | - return Number.isFinite(ms) ? ms : null; |
| 81 | + return new Date(parts.year, parts.month, parts.day, parts.hours, parts.minutes, parts.seconds).getTime(); |
| 82 | +}; |
| 83 | + |
| 84 | +const formatTimeInputValue = (utcMs: number, mode: DateTimeInputMode): string => { |
| 85 | + const p = partsFor(utcMs, mode); |
| 86 | + return `${pad(p.hours)}:${pad(p.minutes)}:${pad(p.seconds)}`; |
47 | 87 | }; |
48 | 88 |
|
49 | | -type DateTimeInputProps = { |
| 89 | +// --------------------------------------------------------------------------- |
| 90 | +// Sub-components (kept local — easy to lift into the registry later) |
| 91 | +// --------------------------------------------------------------------------- |
| 92 | + |
| 93 | +type ModeToggleProps = { |
| 94 | + mode: DateTimeInputMode; |
| 95 | + onModeChange: (mode: DateTimeInputMode) => void; |
| 96 | +}; |
| 97 | + |
| 98 | +const ModeToggle = ({ mode, onModeChange }: ModeToggleProps) => ( |
| 99 | + <div className="flex w-fit gap-1 rounded-md border p-1"> |
| 100 | + <Button |
| 101 | + onClick={() => onModeChange('local')} |
| 102 | + size="sm" |
| 103 | + type="button" |
| 104 | + variant={mode === 'local' ? 'outline' : 'ghost'} |
| 105 | + > |
| 106 | + Local |
| 107 | + </Button> |
| 108 | + <Button onClick={() => onModeChange('utc')} size="sm" type="button" variant={mode === 'utc' ? 'outline' : 'ghost'}> |
| 109 | + UTC |
| 110 | + </Button> |
| 111 | + </div> |
| 112 | +); |
| 113 | + |
| 114 | +type DateTimePickerPanelProps = { |
50 | 115 | value: number | undefined; |
51 | 116 | onChange: (utcMs: number) => void; |
| 117 | + mode: DateTimeInputMode; |
| 118 | + onModeChange: (mode: DateTimeInputMode) => void; |
52 | 119 | disabled?: boolean; |
53 | | - className?: string; |
54 | | - 'data-testid'?: string; |
| 120 | + hideTimezoneToggle?: boolean; |
55 | 121 | }; |
56 | 122 |
|
57 | | -/** |
58 | | - * Local date+time picker. Replaces `<DateTimeInput>` from `@redpanda-data/ui`, |
59 | | - * which depended on date-fns-tz@2 and forced a build-time shim. Uses the |
60 | | - * Calendar + Popover + Input registry components and works in the user's local |
61 | | - * timezone — value/onChange remain UTC milliseconds for parity with the old API. |
62 | | - */ |
63 | | -export const DateTimeInput = ({ value, onChange, disabled, className, ...rest }: DateTimeInputProps) => { |
64 | | - const [open, setOpen] = useState(false); |
65 | | - const effectiveValue = value ?? Date.now(); |
| 123 | +const DateTimePickerPanel = ({ |
| 124 | + value, |
| 125 | + onChange, |
| 126 | + mode, |
| 127 | + onModeChange, |
| 128 | + disabled, |
| 129 | + hideTimezoneToggle, |
| 130 | +}: DateTimePickerPanelProps) => { |
| 131 | + const baseMs = isFiniteNumber(value) ? value : Date.now(); |
66 | 132 |
|
67 | | - const dateLabel = useMemo(() => formatDateLabel(effectiveValue), [effectiveValue]); |
68 | | - const timeLabel = useMemo(() => formatTimeLabel(effectiveValue), [effectiveValue]); |
| 133 | + const calendarSelected = useMemo(() => { |
| 134 | + const p = partsFor(baseMs, mode); |
| 135 | + return new Date(p.year, p.month, p.day); |
| 136 | + }, [baseMs, mode]); |
69 | 137 |
|
70 | | - const setDate = (next: Date | undefined) => { |
| 138 | + const timeInputValue = useMemo(() => formatTimeInputValue(baseMs, mode), [baseMs, mode]); |
| 139 | + |
| 140 | + const setFromCalendar = (next: Date | undefined) => { |
71 | 141 | if (!next) { |
72 | 142 | return; |
73 | 143 | } |
74 | | - const ms = composeUtcMs( |
75 | | - `${pad(next.getFullYear(), 4)}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`, |
76 | | - timeLabel |
| 144 | + const time = partsFor(baseMs, mode); |
| 145 | + onChange( |
| 146 | + composeUtcMs( |
| 147 | + { |
| 148 | + year: next.getFullYear(), |
| 149 | + month: next.getMonth(), |
| 150 | + day: next.getDate(), |
| 151 | + hours: time.hours, |
| 152 | + minutes: time.minutes, |
| 153 | + seconds: time.seconds, |
| 154 | + }, |
| 155 | + mode |
| 156 | + ) |
77 | 157 | ); |
78 | | - if (ms !== null) { |
79 | | - onChange(ms); |
80 | | - } |
81 | 158 | }; |
82 | 159 |
|
83 | | - const setTime = (nextTime: string) => { |
84 | | - const ms = composeUtcMs(dateLabel, nextTime); |
85 | | - if (ms !== null) { |
86 | | - onChange(ms); |
| 160 | + const setFromTime = (raw: string) => { |
| 161 | + const [hh, mm, ss = '0'] = raw.split(':'); |
| 162 | + const hours = Number(hh); |
| 163 | + const minutes = Number(mm); |
| 164 | + const seconds = Number(ss); |
| 165 | + if (![hours, minutes, seconds].every(Number.isFinite)) { |
| 166 | + return; |
87 | 167 | } |
| 168 | + const date = partsFor(baseMs, mode); |
| 169 | + onChange(composeUtcMs({ ...date, hours, minutes, seconds }, mode)); |
88 | 170 | }; |
89 | 171 |
|
90 | 172 | return ( |
91 | | - <div className={cn('flex items-center gap-2', className)} data-testid={rest['data-testid']}> |
92 | | - <Popover onOpenChange={setOpen} open={open}> |
93 | | - <PopoverTrigger> |
94 | | - <Button |
95 | | - className={cn('w-[160px] justify-start font-normal', !value && 'text-muted-foreground')} |
96 | | - disabled={disabled} |
97 | | - type="button" |
98 | | - variant="outline" |
99 | | - > |
100 | | - <CalendarIcon className="mr-2 size-4" /> |
101 | | - {dateLabel} |
102 | | - </Button> |
103 | | - </PopoverTrigger> |
104 | | - <PopoverContent align="start" className="w-auto p-0"> |
105 | | - <Calendar |
106 | | - mode="single" |
107 | | - onSelect={(d) => { |
108 | | - setDate(d); |
109 | | - setOpen(false); |
110 | | - }} |
111 | | - selected={toLocalDate(effectiveValue)} |
112 | | - /> |
113 | | - </PopoverContent> |
114 | | - </Popover> |
115 | | - <Input |
116 | | - className="w-[120px]" |
117 | | - disabled={disabled} |
118 | | - onChange={(e) => setTime(e.target.value)} |
119 | | - step={1} |
120 | | - type="time" |
121 | | - value={timeLabel} |
122 | | - /> |
| 173 | + <div |
| 174 | + className="flex flex-col gap-3" |
| 175 | + // Stop pointer/focus events from bubbling to base-ui's outside-press / focus-out |
| 176 | + // detectors, which can otherwise misclassify in-popover interactions (notably |
| 177 | + // Calendar day clicks and the native <input type="time"> picker) as outside |
| 178 | + // presses and close-then-reopen the popup. |
| 179 | + onFocus={(e) => e.stopPropagation()} |
| 180 | + onMouseDown={(e) => e.stopPropagation()} |
| 181 | + onPointerDown={(e) => e.stopPropagation()} |
| 182 | + > |
| 183 | + <Calendar mode="single" onSelect={setFromCalendar} selected={calendarSelected} /> |
| 184 | + <div className="flex items-center gap-2"> |
| 185 | + <Input |
| 186 | + className="flex-1" |
| 187 | + disabled={disabled} |
| 188 | + onChange={(e) => setFromTime(e.target.value)} |
| 189 | + step={1} |
| 190 | + type="time" |
| 191 | + value={timeInputValue} |
| 192 | + /> |
| 193 | + <Button disabled={disabled} onClick={() => onChange(Date.now())} size="sm" type="button" variant="primary"> |
| 194 | + Now |
| 195 | + </Button> |
| 196 | + </div> |
| 197 | + {!hideTimezoneToggle && <ModeToggle mode={mode} onModeChange={onModeChange} />} |
123 | 198 | </div> |
124 | 199 | ); |
125 | 200 | }; |
| 201 | + |
| 202 | +// --------------------------------------------------------------------------- |
| 203 | +// Public component |
| 204 | +// --------------------------------------------------------------------------- |
| 205 | + |
| 206 | +const DEFAULT_PLACEHOLDER = 'Enter unix timestamp'; |
| 207 | + |
| 208 | +/** |
| 209 | + * Replacement for `<DateTimeInput>` from `@redpanda-data/ui`. Mirrors the |
| 210 | + * original's display: the input shows the raw unix-millisecond number and |
| 211 | + * accepts direct entry (commits on Enter / blur). A calendar icon button at |
| 212 | + * the trailing edge opens a popover with a calendar, time input, "Now" |
| 213 | + * shortcut, and an optional Local/UTC toggle. `value` and `onChange` always |
| 214 | + * operate in UTC milliseconds. |
| 215 | + */ |
| 216 | +export const DateTimeInput = ({ |
| 217 | + value, |
| 218 | + onChange, |
| 219 | + disabled, |
| 220 | + defaultMode = 'local', |
| 221 | + hideTimezoneToggle = false, |
| 222 | + className, |
| 223 | + placeholder = DEFAULT_PLACEHOLDER, |
| 224 | + ...rest |
| 225 | +}: DateTimeInputProps) => { |
| 226 | + const [mode, setMode] = useState<DateTimeInputMode>(defaultMode); |
| 227 | + const [draft, setDraft] = useState<string>(''); |
| 228 | + |
| 229 | + const numericText = isFiniteNumber(value) ? String(value) : ''; |
| 230 | + const displayedNumber = draft === '' ? numericText : draft; |
| 231 | + |
| 232 | + const commitNumeric = () => { |
| 233 | + if (draft === '') { |
| 234 | + return; |
| 235 | + } |
| 236 | + const parsed = Number(draft); |
| 237 | + if (Number.isFinite(parsed)) { |
| 238 | + onChange(parsed); |
| 239 | + } |
| 240 | + setDraft(''); |
| 241 | + }; |
| 242 | + |
| 243 | + return ( |
| 244 | + <Popover> |
| 245 | + <PopoverAnchor asChild> |
| 246 | + <Input |
| 247 | + className={className} |
| 248 | + data-testid={rest['data-testid']} |
| 249 | + disabled={disabled} |
| 250 | + onBlur={commitNumeric} |
| 251 | + onChange={(e) => setDraft(e.target.value)} |
| 252 | + onKeyDown={(e) => { |
| 253 | + if (e.key === 'Enter') { |
| 254 | + e.preventDefault(); |
| 255 | + commitNumeric(); |
| 256 | + } |
| 257 | + }} |
| 258 | + placeholder={placeholder} |
| 259 | + size="lg" |
| 260 | + type="number" |
| 261 | + value={displayedNumber} |
| 262 | + > |
| 263 | + <InputEnd className="pointer-events-auto"> |
| 264 | + <PopoverTrigger asChild> |
| 265 | + <button |
| 266 | + aria-label="Open calendar" |
| 267 | + className="text-muted-foreground hover:text-foreground" |
| 268 | + disabled={disabled} |
| 269 | + type="button" |
| 270 | + > |
| 271 | + <CalendarIcon className="size-4" /> |
| 272 | + </button> |
| 273 | + </PopoverTrigger> |
| 274 | + </InputEnd> |
| 275 | + </Input> |
| 276 | + </PopoverAnchor> |
| 277 | + <PopoverContent align="start" className="w-auto p-3"> |
| 278 | + <DateTimePickerPanel |
| 279 | + disabled={disabled} |
| 280 | + hideTimezoneToggle={hideTimezoneToggle} |
| 281 | + mode={mode} |
| 282 | + onChange={onChange} |
| 283 | + onModeChange={setMode} |
| 284 | + value={value} |
| 285 | + /> |
| 286 | + </PopoverContent> |
| 287 | + </Popover> |
| 288 | + ); |
| 289 | +}; |
0 commit comments