|
|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
import { useMemo, useRef, useState } from 'react';
|
|
|
|
|
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
|
|
|
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
|
|
|
|
|
import { Calendar } from '@/components/ui/calendar';
|
|
|
|
|
@@ -89,6 +89,14 @@ export function DateTimeField({
|
|
|
|
|
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
|
|
|
|
|
day: null, month: null, year: null, hour: null, minute: null,
|
|
|
|
|
});
|
|
|
|
|
// Stable per-segment ref setters (avoid new-function-per-render).
|
|
|
|
|
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
|
|
|
|
|
day: (el) => { refs.current.day = el; },
|
|
|
|
|
month: (el) => { refs.current.month = el; },
|
|
|
|
|
year: (el) => { refs.current.year = el; },
|
|
|
|
|
hour: (el) => { refs.current.hour = el; },
|
|
|
|
|
minute: (el) => { refs.current.minute = el; },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function focusSeg(key: SegKey) {
|
|
|
|
|
const el = refs.current[key];
|
|
|
|
|
@@ -101,117 +109,149 @@ export function DateTimeField({
|
|
|
|
|
sel?.addRange(range);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function focusNext(curr: SegKey) {
|
|
|
|
|
const idx = order.indexOf(curr);
|
|
|
|
|
const next = order[idx + 1];
|
|
|
|
|
if (next) focusSeg(next);
|
|
|
|
|
}
|
|
|
|
|
function focusPrev(curr: SegKey) {
|
|
|
|
|
const idx = order.indexOf(curr);
|
|
|
|
|
const prev = order[idx - 1];
|
|
|
|
|
if (prev) focusSeg(prev);
|
|
|
|
|
}
|
|
|
|
|
// Note: typing updates LOCAL state only. We deliberately don't call
|
|
|
|
|
// onChange on every keystroke — otherwise the parent re-renders on each
|
|
|
|
|
// keypress, which re-renders the (heavy) Calendar grid and the rest of
|
|
|
|
|
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
|
|
|
|
|
|
|
|
|
|
function applyState(next: SegState) {
|
|
|
|
|
setSeg(next);
|
|
|
|
|
const dt = toDate(next, withTime);
|
|
|
|
|
onChange(dt);
|
|
|
|
|
}
|
|
|
|
|
// Stable across renders: uses functional setSeg, refs, and order via ref.
|
|
|
|
|
const orderRef = useRef(order);
|
|
|
|
|
orderRef.current = order;
|
|
|
|
|
const withTimeRef = useRef(withTime);
|
|
|
|
|
withTimeRef.current = withTime;
|
|
|
|
|
const onChangeRef = useRef(onChange);
|
|
|
|
|
onChangeRef.current = onChange;
|
|
|
|
|
const onCommitRef = useRef(onCommit);
|
|
|
|
|
onCommitRef.current = onCommit;
|
|
|
|
|
|
|
|
|
|
function commit(state: SegState) {
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const filled: SegState = {
|
|
|
|
|
day: state.day || String(today.getDate()).padStart(2, '0'),
|
|
|
|
|
month: state.month || String(today.getMonth() + 1).padStart(2, '0'),
|
|
|
|
|
year: state.year || String(today.getFullYear()),
|
|
|
|
|
hour: withTime ? (state.hour || '00') : '00',
|
|
|
|
|
minute: withTime ? (state.minute || '00') : '00',
|
|
|
|
|
};
|
|
|
|
|
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
|
|
|
|
|
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
|
|
|
|
|
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
|
|
|
|
|
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
|
|
|
|
|
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
|
|
|
|
|
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
|
|
|
|
|
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
|
|
|
|
|
const finalState = fromDate(dt);
|
|
|
|
|
setSeg(finalState);
|
|
|
|
|
onChange(dt);
|
|
|
|
|
onCommit?.(dt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onSegKeyDown(e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) {
|
|
|
|
|
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
|
|
|
|
|
const def = SEGS[key];
|
|
|
|
|
const cur = seg[key];
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
focusNext(key);
|
|
|
|
|
const idx = orderRef.current.indexOf(key);
|
|
|
|
|
const nxt = orderRef.current[idx + 1];
|
|
|
|
|
if (nxt) focusSeg(nxt);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
focusPrev(key);
|
|
|
|
|
const idx = orderRef.current.indexOf(key);
|
|
|
|
|
const prv = orderRef.current[idx - 1];
|
|
|
|
|
if (prv) focusSeg(prv);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const delta = e.key === 'ArrowUp' ? 1 : -1;
|
|
|
|
|
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
|
|
|
|
|
let n = base + delta;
|
|
|
|
|
if (n < def.min) n = def.max;
|
|
|
|
|
if (n > def.max) n = def.min;
|
|
|
|
|
const next = { ...seg, [key]: String(n).padStart(def.len, '0') };
|
|
|
|
|
applyState(next);
|
|
|
|
|
setSeg((prev) => {
|
|
|
|
|
const cur = prev[key];
|
|
|
|
|
const delta = e.key === 'ArrowUp' ? 1 : -1;
|
|
|
|
|
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
|
|
|
|
|
let n = base + delta;
|
|
|
|
|
if (n < def.min) n = def.max;
|
|
|
|
|
if (n > def.max) n = def.min;
|
|
|
|
|
return { ...prev, [key]: String(n).padStart(def.len, '0') };
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (cur === '') {
|
|
|
|
|
focusPrev(key);
|
|
|
|
|
} else {
|
|
|
|
|
const next = { ...seg, [key]: '' };
|
|
|
|
|
applyState(next);
|
|
|
|
|
}
|
|
|
|
|
setSeg((prev) => {
|
|
|
|
|
if (prev[key] === '') {
|
|
|
|
|
const idx = orderRef.current.indexOf(key);
|
|
|
|
|
const prv = orderRef.current[idx - 1];
|
|
|
|
|
if (prv) focusSeg(prv);
|
|
|
|
|
return prev;
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, [key]: '' };
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (/^[0-9]$/.test(e.key)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const incoming = cur.length >= def.len ? e.key : cur + e.key;
|
|
|
|
|
const numeric = parseInt(incoming, 10);
|
|
|
|
|
const final = numeric > def.max ? e.key : incoming;
|
|
|
|
|
const padded = final.padStart(Math.min(final.length, def.len), '0');
|
|
|
|
|
const next = { ...seg, [key]: padded };
|
|
|
|
|
applyState(next);
|
|
|
|
|
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
|
|
|
|
|
focusNext(key);
|
|
|
|
|
let advance = false;
|
|
|
|
|
setSeg((prev) => {
|
|
|
|
|
const cur = prev[key];
|
|
|
|
|
const incoming = cur.length >= def.len ? e.key : cur + e.key;
|
|
|
|
|
const numeric = parseInt(incoming, 10);
|
|
|
|
|
const final = numeric > def.max ? e.key : incoming;
|
|
|
|
|
const padded = final.padStart(Math.min(final.length, def.len), '0');
|
|
|
|
|
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
|
|
|
|
|
advance = true;
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, [key]: padded };
|
|
|
|
|
});
|
|
|
|
|
if (advance) {
|
|
|
|
|
const idx = orderRef.current.indexOf(key);
|
|
|
|
|
const nxt = orderRef.current[idx + 1];
|
|
|
|
|
if (nxt) focusSeg(nxt);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
commit(seg);
|
|
|
|
|
// Read current seg via functional updater; commit then propagate.
|
|
|
|
|
setSeg((prev) => {
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const wt = withTimeRef.current;
|
|
|
|
|
const filled: SegState = {
|
|
|
|
|
day: prev.day || String(today.getDate()).padStart(2, '0'),
|
|
|
|
|
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
|
|
|
|
|
year: prev.year || String(today.getFullYear()),
|
|
|
|
|
hour: wt ? (prev.hour || '00') : '00',
|
|
|
|
|
minute: wt ? (prev.minute || '00') : '00',
|
|
|
|
|
};
|
|
|
|
|
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
|
|
|
|
|
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
|
|
|
|
|
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
|
|
|
|
|
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
|
|
|
|
|
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
|
|
|
|
|
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
|
|
|
|
|
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
|
|
|
|
|
onChangeRef.current(dt);
|
|
|
|
|
onCommitRef.current?.(dt);
|
|
|
|
|
return fromDate(dt);
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
focusNext(key);
|
|
|
|
|
const idx = orderRef.current.indexOf(key);
|
|
|
|
|
const nxt = orderRef.current[idx + 1];
|
|
|
|
|
if (nxt) focusSeg(nxt);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
function onCalendarSelect(d: Date | undefined) {
|
|
|
|
|
const onCalendarSelect = useCallback((d: Date | undefined) => {
|
|
|
|
|
if (!d) return;
|
|
|
|
|
const next: SegState = {
|
|
|
|
|
...seg,
|
|
|
|
|
day: String(d.getDate()).padStart(2, '0'),
|
|
|
|
|
month: String(d.getMonth() + 1).padStart(2, '0'),
|
|
|
|
|
year: String(d.getFullYear()),
|
|
|
|
|
};
|
|
|
|
|
applyState(next);
|
|
|
|
|
}
|
|
|
|
|
setSeg((prev) => {
|
|
|
|
|
const next: SegState = {
|
|
|
|
|
...prev,
|
|
|
|
|
day: String(d.getDate()).padStart(2, '0'),
|
|
|
|
|
month: String(d.getMonth() + 1).padStart(2, '0'),
|
|
|
|
|
year: String(d.getFullYear()),
|
|
|
|
|
};
|
|
|
|
|
const dt = toDate(next, withTime);
|
|
|
|
|
if (dt) onChange(dt);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, [withTime, onChange]);
|
|
|
|
|
|
|
|
|
|
const selectedDate = toDate(seg, withTime);
|
|
|
|
|
const selectedMs = selectedDate ? selectedDate.getTime() : null;
|
|
|
|
|
const calendarEl = useMemo(
|
|
|
|
|
() => (
|
|
|
|
|
<Calendar
|
|
|
|
|
mode="single"
|
|
|
|
|
selected={selectedDate}
|
|
|
|
|
onSelect={onCalendarSelect}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
[selectedMs, onCalendarSelect],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
|
|
|
|
|
@@ -225,7 +265,7 @@ export function DateTimeField({
|
|
|
|
|
segKey={sk}
|
|
|
|
|
value={seg[sk]}
|
|
|
|
|
onKeyDown={onSegKeyDown}
|
|
|
|
|
registerRef={(el) => { refs.current[sk] = el; }}
|
|
|
|
|
registerRef={refSetters.current[sk]}
|
|
|
|
|
sep={sep}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
@@ -236,31 +276,25 @@ export function DateTimeField({
|
|
|
|
|
segKey="hour"
|
|
|
|
|
value={seg.hour}
|
|
|
|
|
onKeyDown={onSegKeyDown}
|
|
|
|
|
registerRef={(el) => { refs.current.hour = el; }}
|
|
|
|
|
registerRef={refSetters.current.hour}
|
|
|
|
|
sep=":"
|
|
|
|
|
/>
|
|
|
|
|
<SegmentSpan
|
|
|
|
|
segKey="minute"
|
|
|
|
|
value={seg.minute}
|
|
|
|
|
onKeyDown={onSegKeyDown}
|
|
|
|
|
registerRef={(el) => { refs.current.minute = el; }}
|
|
|
|
|
registerRef={refSetters.current.minute}
|
|
|
|
|
sep={null}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-md border">
|
|
|
|
|
<Calendar
|
|
|
|
|
mode="single"
|
|
|
|
|
selected={selectedDate}
|
|
|
|
|
onSelect={onCalendarSelect}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-md border">{calendarEl}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SegmentSpan({
|
|
|
|
|
const SegmentSpan = memo(function SegmentSpan({
|
|
|
|
|
segKey,
|
|
|
|
|
value,
|
|
|
|
|
onKeyDown,
|
|
|
|
|
@@ -305,4 +339,4 @@ function SegmentSpan({
|
|
|
|
|
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|