216 lines
5.7 KiB
TypeScript
216 lines
5.7 KiB
TypeScript
import * as React from "react";
|
|
import flatpickr from "flatpickr";
|
|
import "flatpickr/dist/flatpickr.min.css";
|
|
|
|
/** Subset of flatpickr instance API we use (avoids tight coupling to type export paths). */
|
|
interface FlatpickrHandle {
|
|
destroy(): void;
|
|
readonly selectedDates: Date[];
|
|
setDate(date: Date | string, triggerChange?: boolean, formatStr?: string): void;
|
|
clear(triggerChange?: boolean): void;
|
|
set<K extends string>(key: K, value: unknown): void;
|
|
input: HTMLInputElement;
|
|
}
|
|
|
|
function pad2(n: number) {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
/** Same string shape previously used for `datetime-local` inputs (local wall time). */
|
|
export function toDatetimeLocalValue(isoOrDate: string | Date | undefined): string {
|
|
if (!isoOrDate) return "";
|
|
const d = typeof isoOrDate === "string" ? new Date(isoOrDate) : isoOrDate;
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
}
|
|
|
|
function parseDatetimeLocal(s: string): Date | undefined {
|
|
if (!s.trim()) return undefined;
|
|
const d = new Date(s);
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
function parseDateOnly(s: string): Date | undefined {
|
|
const raw = s.trim();
|
|
if (!raw) return undefined;
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
const [y, m, d] = raw.split("-").map(Number);
|
|
const date = new Date(y, m - 1, d);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
const d = new Date(raw);
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
/** Local calendar date as `YYYY-MM-DD`. */
|
|
export function toDateOnlyValue(isoOrDate: string | Date | undefined): string {
|
|
if (!isoOrDate) return "";
|
|
const d = typeof isoOrDate === "string" ? parseDateOnly(isoOrDate) : isoOrDate;
|
|
if (!d || Number.isNaN(d.getTime())) return "";
|
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
}
|
|
|
|
export interface FlatpickrDateInputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
style?: React.CSSProperties;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
hasError?: boolean;
|
|
}
|
|
|
|
/** Date-only picker (Flatpickr) that reads/writes `YYYY-MM-DD`. */
|
|
export function FlatpickrDateInput({
|
|
value,
|
|
onChange,
|
|
style,
|
|
disabled,
|
|
placeholder = "Select date…",
|
|
hasError,
|
|
}: FlatpickrDateInputProps) {
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const fpRef = React.useRef<FlatpickrHandle | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
|
|
const fp = flatpickr(el, {
|
|
enableTime: false,
|
|
dateFormat: "Y-m-d",
|
|
allowInput: true,
|
|
defaultDate: parseDateOnly(value),
|
|
onChange: dates => {
|
|
const d = dates[0];
|
|
onChange(d ? toDateOnlyValue(d) : "");
|
|
},
|
|
}) as FlatpickrHandle;
|
|
fpRef.current = fp;
|
|
|
|
return () => {
|
|
fp.destroy();
|
|
fpRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init once
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const fp = fpRef.current;
|
|
if (!fp) return;
|
|
|
|
const selected = fp.selectedDates[0];
|
|
const cur = selected ? toDateOnlyValue(selected) : "";
|
|
if (value === cur) return;
|
|
|
|
const d = parseDateOnly(value);
|
|
if (d) fp.setDate(d, false);
|
|
else fp.clear(false);
|
|
}, [value]);
|
|
|
|
React.useEffect(() => {
|
|
const fp = fpRef.current;
|
|
if (!fp) return;
|
|
const input = fp.input as HTMLInputElement;
|
|
input.disabled = !!disabled;
|
|
fp.set("clickOpens", !disabled);
|
|
}, [disabled]);
|
|
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder={placeholder}
|
|
readOnly={false}
|
|
autoComplete="off"
|
|
style={{
|
|
...style,
|
|
...(hasError ? { borderColor: "var(--destructive)" } : {}),
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export interface FlatpickrDatetimeInputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
style?: React.CSSProperties;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
hasError?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Date + time picker (Flatpickr) that reads/writes the same `YYYY-MM-DDTHH:mm` local values
|
|
* as native `datetime-local` inputs.
|
|
*/
|
|
export function FlatpickrDatetimeInput({
|
|
value,
|
|
onChange,
|
|
style,
|
|
disabled,
|
|
placeholder = "Select date & time…",
|
|
hasError,
|
|
}: FlatpickrDatetimeInputProps) {
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const fpRef = React.useRef<FlatpickrHandle | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
|
|
const fp = flatpickr(el, {
|
|
enableTime: true,
|
|
time_24hr: true,
|
|
dateFormat: "Y-m-d H:i",
|
|
allowInput: true,
|
|
defaultDate: parseDatetimeLocal(value),
|
|
onChange: dates => {
|
|
const d = dates[0];
|
|
onChange(d ? toDatetimeLocalValue(d) : "");
|
|
},
|
|
}) as FlatpickrHandle;
|
|
fpRef.current = fp;
|
|
|
|
return () => {
|
|
fp.destroy();
|
|
fpRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init once
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const fp = fpRef.current;
|
|
if (!fp) return;
|
|
|
|
const selected = fp.selectedDates[0];
|
|
const cur = selected ? toDatetimeLocalValue(selected) : "";
|
|
if (value === cur) return;
|
|
|
|
const d = parseDatetimeLocal(value);
|
|
if (d) fp.setDate(d, false);
|
|
else fp.clear(false);
|
|
}, [value]);
|
|
|
|
React.useEffect(() => {
|
|
const fp = fpRef.current;
|
|
if (!fp) return;
|
|
const input = fp.input as HTMLInputElement;
|
|
input.disabled = !!disabled;
|
|
fp.set("clickOpens", !disabled);
|
|
}, [disabled]);
|
|
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder={placeholder}
|
|
readOnly={false}
|
|
autoComplete="off"
|
|
style={{
|
|
...style,
|
|
...(hasError ? { borderColor: "var(--destructive)" } : {}),
|
|
}}
|
|
/>
|
|
);
|
|
}
|