Skip to content

Commit 5297a03

Browse files
committed
workaround for date-time-input
1 parent 2328791 commit 5297a03

3 files changed

Lines changed: 244 additions & 79 deletions

File tree

frontend/src/components/redpanda-ui/components/code-block-dynamic.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function DynamicCodeBlock({
293293

294294
return (
295295
<>
296+
{/* @ts-expect-error React 19 style hoisting attributes (href/precedence) not yet in TS DOM types */}
296297
<style href="shiki-dual-theme" precedence="medium">
297298
{shikiDualThemeStyles}
298299
</style>

frontend/src/components/redpanda-ui/components/editable-text.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function EditableText({
4242
const [isEditing, setIsEditing] = useState(autoFocus ?? false);
4343
const [draft, setDraft] = useState(value);
4444
const [textWidth, setTextWidth] = useState<number | undefined>(undefined);
45-
const inputRef = useRef<HTMLInputElement>(null);
45+
const inputRef = useRef<HTMLInputElement | null>(null);
4646
const spanRef = useRef<HTMLButtonElement>(null);
4747
const measureRef = useRef<HTMLSpanElement>(null);
4848

frontend/src/components/ui/date-time-input.tsx

Lines changed: 242 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -11,115 +11,279 @@
1111

1212
import { Button } from 'components/redpanda-ui/components/button';
1313
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';
1716
import { CalendarIcon } from 'lucide-react';
1817
import { useMemo, useState } from 'react';
1918

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;
2245

2346
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
2447

25-
const toLocalDate = (utcMs: number) => new Date(utcMs);
48+
const isFiniteNumber = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
2649

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;
3057
};
3158

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+
};
3574
};
3675

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);
4480
}
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)}`;
4787
};
4888

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 = {
50115
value: number | undefined;
51116
onChange: (utcMs: number) => void;
117+
mode: DateTimeInputMode;
118+
onModeChange: (mode: DateTimeInputMode) => void;
52119
disabled?: boolean;
53-
className?: string;
54-
'data-testid'?: string;
120+
hideTimezoneToggle?: boolean;
55121
};
56122

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();
66132

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]);
69137

70-
const setDate = (next: Date | undefined) => {
138+
const timeInputValue = useMemo(() => formatTimeInputValue(baseMs, mode), [baseMs, mode]);
139+
140+
const setFromCalendar = (next: Date | undefined) => {
71141
if (!next) {
72142
return;
73143
}
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+
)
77157
);
78-
if (ms !== null) {
79-
onChange(ms);
80-
}
81158
};
82159

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;
87167
}
168+
const date = partsFor(baseMs, mode);
169+
onChange(composeUtcMs({ ...date, hours, minutes, seconds }, mode));
88170
};
89171

90172
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} />}
123198
</div>
124199
);
125200
};
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

Comments
 (0)