feat(date-picker): enhance DatePicker component with new features and styling updates

- Added a portal option for rendering the calendar in a portal, improving usability in modals and overflow containers.
- Introduced new utility functions for converting form values to picker values, enhancing date handling for single and multiple selections.
- Refactored DatePickerRHFInput to utilize memoization for performance optimization and improved value management.
- Updated CSS styles for the date picker to ensure better visibility and interaction in both light and dark modes.
This commit is contained in:
AmirReza Jamali
2026-05-11 17:09:36 +03:30
parent 5855ce33d4
commit 71f961ee49
2 changed files with 229 additions and 150 deletions
@@ -1,6 +1,7 @@
"use client";
import { CalendarIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import {
Control,
Controller,
@@ -25,12 +26,187 @@ interface IProps<T extends FieldValues> {
maxDate?: number;
timePicker?: boolean;
clearable?: boolean;
/** Renders the calendar in a portal (recommended inside modals / overflow containers). */
portal?: boolean;
rules?: Omit<
RegisterOptions<T, Path<T>>,
"valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
>;
}
const toUnixSeconds = (v: unknown): number | null => {
if (v === "" || v === undefined || v === null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
const convertFormValueToPickerValue = (
value: unknown,
range: boolean,
multiple: boolean,
): DateObject | DateObject[] | undefined => {
if (value === "" || value === undefined || value === null) {
return undefined;
}
if (multiple && Array.isArray(value)) {
const secs = value
.map(toUnixSeconds)
.filter((s): s is number => s !== null);
if (!secs.length) return undefined;
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
}
if (range && Array.isArray(value)) {
const secs = value
.map(toUnixSeconds)
.filter((s): s is number => s !== null);
if (!secs.length) return undefined;
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
}
if (range && !Array.isArray(value)) {
return undefined;
}
const unix = toUnixSeconds(value);
if (unix === null) return undefined;
return new DateObject(new Date(unix * 1000));
};
type PickerInputProps<T extends FieldValues> = {
name: Path<T>;
value: unknown;
onChange: (...event: unknown[]) => void;
error?: { message?: string };
range: boolean;
multiple: boolean;
onlyMonthPicker: boolean;
onlyYearPicker: boolean;
minDate?: number;
maxDate?: number;
timePicker: boolean;
clearable: boolean;
portal: boolean;
placeholder?: string;
};
const DatePickerRHFInput = <T extends FieldValues>({
name,
value,
onChange,
error,
range,
multiple,
onlyMonthPicker,
onlyYearPicker,
minDate,
maxDate,
timePicker,
clearable,
portal,
placeholder,
}: PickerInputProps<T>) => {
const valueFingerprint = useMemo(
() =>
JSON.stringify({
value,
range,
multiple,
}),
[value, range, multiple],
);
const pickerValue = useMemo(
() => convertFormValueToPickerValue(value, range, multiple),
[valueFingerprint, range, multiple],
);
const hasValue =
pickerValue !== undefined &&
(Array.isArray(pickerValue) ? pickerValue.length > 0 : true);
return (
<>
<div className="relative">
<MultiDatePicker
range={range}
minDate={minDate}
maxDate={maxDate}
onlyMonthPicker={onlyMonthPicker}
plugins={
timePicker ? [<TimePicker key={String(name)} position="bottom" />] : []
}
onlyYearPicker={onlyYearPicker}
multiple={multiple}
{...(portal ? { portal: true } : {})}
value={pickerValue}
onChange={(date: DateObject | DateObject[] | null) => {
if (!date) {
onChange("");
return;
}
if (Array.isArray(date)) {
const unixTimestamps = date.map((d) => d.toUnix());
onChange(unixTimestamps);
} else if (date instanceof DateObject) {
onChange(date.isValid ? date.toUnix() : "");
}
}}
format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
containerClassName="w-full"
inputClass={cn(
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 pl-12.5 text-dark shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark",
clearable && hasValue && "pr-11",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
)}
placeholder={placeholder}
/>
<span className="pointer-events-none absolute left-4.5 top-1/2 -translate-y-1/2 text-dark-5 transition-colors dark:text-dark-6">
<CalendarIcon />
</span>
{clearable && hasValue && (
<button
type="button"
aria-label="Clear date"
className="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-md p-1 text-dark-5 transition hover:bg-gray-2 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3 dark:hover:text-white"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange("");
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
{error && (
<p className="mt-1.5 text-sm font-medium text-error">
{error.message as string}
</p>
)}
</>
);
};
const DatePicker = <T extends FieldValues>({
control,
name,
@@ -46,11 +222,12 @@ const DatePicker = <T extends FieldValues>({
maxDate,
minDate,
clearable = false,
portal = false,
}: IProps<T>) => {
return (
<div className="space-y-3">
<label
htmlFor={name}
htmlFor={String(name)}
className="block text-body-sm font-semibold capitalize tracking-[0.01em] text-dark dark:text-white"
>
{label}
@@ -62,137 +239,24 @@ const DatePicker = <T extends FieldValues>({
control={control}
name={name}
rules={rules}
render={({
field: { onChange, name, value },
formState: { errors },
}) => {
const error = errors[name];
const toUnixSeconds = (v: unknown): number | null => {
if (v === "" || v === undefined || v === null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
const toPickerValue = ():
| DateObject
| DateObject[]
| undefined => {
if (value === "" || value === undefined || value === null) {
return undefined;
}
if (multiple && Array.isArray(value)) {
const secs = value
.map(toUnixSeconds)
.filter((s): s is number => s !== null);
if (!secs.length) return undefined;
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
}
if (range && Array.isArray(value)) {
const secs = value
.map(toUnixSeconds)
.filter((s): s is number => s !== null);
if (!secs.length) return undefined;
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
}
if (range && !Array.isArray(value)) {
return undefined;
}
const unix = toUnixSeconds(value);
if (unix === null) return undefined;
return new DateObject(new Date(unix * 1000));
};
const pickerValue = toPickerValue();
const hasValue =
pickerValue !== undefined &&
(Array.isArray(pickerValue) ? pickerValue.length > 0 : true);
return (
<>
<div className="relative">
<MultiDatePicker
range={range}
minDate={minDate}
maxDate={maxDate}
onlyMonthPicker={onlyMonthPicker}
plugins={
timePicker
? [<TimePicker key={name} position="bottom" />]
: []
}
onlyYearPicker={onlyYearPicker}
multiple={multiple}
value={pickerValue}
onChange={(date: DateObject | DateObject[] | null) => {
if (!date) {
onChange("");
return;
}
if (Array.isArray(date)) {
const unixTimestamps = date.map((d) => d.toUnix());
onChange(unixTimestamps);
} else if (date instanceof DateObject) {
onChange(date.isValid ? date.toUnix() : "");
}
}}
format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
containerClassName="w-full"
inputClass={cn(
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 pl-12.5 text-dark shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] disabled:cursor-default disabled:bg-gray-2/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] dark:disabled:bg-dark",
clearable && hasValue && "pr-11",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
)}
placeholder={placeholder}
/>
<span className="pointer-events-none absolute left-4.5 top-1/2 -translate-y-1/2 text-dark-5 transition-colors dark:text-dark-6">
<CalendarIcon />
</span>
{clearable && hasValue && (
<button
type="button"
aria-label="Clear date"
className="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-md p-1 text-dark-5 transition hover:bg-gray-2 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3 dark:hover:text-white"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange("");
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
{error && (
<p className="mt-1.5 text-sm font-medium text-error">
{error.message as string}
</p>
)}
</>
);
}}
render={({ field: { onChange, name: fieldName, value }, formState: { errors } }) => (
<DatePickerRHFInput<T>
name={fieldName}
value={value}
onChange={onChange}
error={errors[fieldName] as { message?: string } | undefined}
range={range}
multiple={multiple}
onlyMonthPicker={onlyMonthPicker}
onlyYearPicker={onlyYearPicker}
minDate={minDate}
maxDate={maxDate}
timePicker={timePicker}
clearable={clearable}
portal={portal}
placeholder={placeholder}
/>
)}
/>
</div>
</div>