Switch DatePicker input variant to native date/datetime-local types

Replaces <input type="text"> with custom display format conversion
with native <input type="date"> / <input type="datetime-local"> for
exact visual parity with Chrome's built-in segmented editing UI.
Removes ~50 lines of isoToDisplay/displayToIso conversion code.
Hides native picker icon inside .datepicker-wrapper via CSS so only
the custom Calendar icon (opening the popup) is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-03 03:33:02 +08:00
parent 247c701e12
commit a30483fbbc
2 changed files with 12 additions and 74 deletions

View File

@ -34,43 +34,6 @@ function to24Hour(h12: number, ampm: string): number {
return isPM ? h12 + 12 : h12; return isPM ? h12 + 12 : h12;
} }
/** ISO string → user-friendly display: DD/MM/YYYY or DD/MM/YYYY hh:mm AM */
function isoToDisplay(iso: string, mode: 'date' | 'datetime'): string {
if (!iso) return '';
const parts = iso.split('T');
const dp = parts[0]?.split('-');
if (!dp || dp.length !== 3) return '';
const dateStr = `${dp[2]}/${dp[1]}/${dp[0]}`;
if (mode === 'date') return dateStr;
const tp = parts[1]?.split(':');
if (!tp || tp.length < 2) return dateStr;
const h = parseInt(tp[0], 10);
if (isNaN(h)) return dateStr;
const { hour: h12, ampm } = to12Hour(h);
return `${dateStr} ${pad(h12)}:${tp[1].slice(0, 2)} ${ampm}`;
}
/** User-friendly display → ISO string, or null if incomplete/invalid */
function displayToIso(display: string, mode: 'date' | 'datetime'): string | null {
if (!display) return null;
if (mode === 'date') {
const m = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (!m) return null;
const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10);
if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null;
return `${m[3]}-${pad(mo)}-${pad(d)}`;
}
const m = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!m) return null;
const d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10);
if (mo < 1 || mo > 12 || d < 1 || d > getDaysInMonth(y, mo - 1)) return null;
let h = parseInt(m[4], 10);
const min = parseInt(m[5], 10);
if (h < 1 || h > 12 || min < 0 || min > 59) return null;
h = to24Hour(h, m[6]);
return `${m[3]}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(min)}`;
}
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December', 'July', 'August', 'September', 'October', 'November', 'December',
@ -152,12 +115,6 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [hour, setHour] = React.useState(parsed?.hour ?? 0);
const [minute, setMinute] = React.useState(parsed?.minute ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0);
// Input variant: user-friendly display string (DD/MM/YYYY or DD/MM/YYYY hh:mm AM)
const [displayValue, setDisplayValue] = React.useState(() =>
variant === 'input' ? isoToDisplay(value, mode) : ''
);
const isInternalChange = React.useRef(false);
// Refs // Refs
const triggerRef = React.useRef<HTMLButtonElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null); const wrapperRef = React.useRef<HTMLDivElement>(null);
@ -182,18 +139,6 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, open]); }, [value, open]);
// Sync display string from ISO value (input variant).
// Skips when the change originated from the text input to avoid
// overwriting the user's in-progress typing.
React.useEffect(() => {
if (variant !== 'input') return;
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setDisplayValue(value ? isoToDisplay(value, mode) : '');
}, [value, mode, variant]);
// Position popup // Position popup
const updatePosition = React.useCallback(() => { const updatePosition = React.useCallback(() => {
const el = variant === 'input' ? wrapperRef.current : triggerRef.current; const el = variant === 'input' ? wrapperRef.current : triggerRef.current;
@ -484,32 +429,19 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
) )
: null; : null;
// ── Input variant: typeable input + calendar icon trigger ── // ── Input variant: native date/datetime-local + calendar icon for custom popup ──
if (variant === 'input') { if (variant === 'input') {
return ( return (
<> <>
<div ref={wrapperRef} className="relative"> <div ref={wrapperRef} className="datepicker-wrapper relative">
<input <input
ref={inputElRef} ref={inputElRef}
type="text" type={mode === 'datetime' ? 'datetime-local' : 'date'}
id={id} id={id}
name={name} name={name}
autoComplete={autoComplete} autoComplete={autoComplete}
value={displayValue} value={value}
onChange={(e) => { onChange={(e) => onChange(e.target.value)}
const typed = e.target.value;
setDisplayValue(typed);
if (typed === '') {
isInternalChange.current = true;
onChange('');
return;
}
const iso = displayToIso(typed, mode);
if (iso) {
isInternalChange.current = true;
onChange(iso);
}
}}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
if (open && e.key === 'Enter') { if (open && e.key === 'Enter') {
@ -520,7 +452,8 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
}} }}
required={required} required={required}
disabled={disabled} disabled={disabled}
placeholder={placeholder || (mode === 'datetime' ? 'DD/MM/YYYY hh:mm AM' : 'DD/MM/YYYY')} min={min}
max={max}
className={cn( className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className className

View File

@ -200,6 +200,11 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
cursor: pointer; cursor: pointer;
} }
/* Hide native picker icon inside DatePicker wrapper (custom icon replaces it) */
.datepicker-wrapper input::-webkit-calendar-picker-indicator {
display: none;
}
/* ── Form validation — red outline only after submit attempt ── */ /* ── Form validation — red outline only after submit attempt ── */
form[data-submitted] input:invalid, form[data-submitted] input:invalid,
form[data-submitted] select:invalid, form[data-submitted] select:invalid,