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,59 +26,25 @@ 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 DatePicker = <T extends FieldValues>({
control,
name,
label,
multiple = false,
placeholder,
required,
range = false,
timePicker = false,
onlyMonthPicker = false,
onlyYearPicker = false,
rules,
maxDate,
minDate,
clearable = false,
}: IProps<T>) => {
return (
<div className="space-y-3">
<label
htmlFor={name}
className="block text-body-sm font-semibold capitalize tracking-[0.01em] text-dark dark:text-white"
>
{label}
{required && <span className="ml-1 select-none text-error">*</span>}
</label>
<div>
<Controller
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 => {
const convertFormValueToPickerValue = (
value: unknown,
range: boolean,
multiple: boolean,
): DateObject | DateObject[] | undefined => {
if (value === "" || value === undefined || value === null) {
return undefined;
}
@@ -107,7 +74,54 @@ const DatePicker = <T extends FieldValues>({
return new DateObject(new Date(unix * 1000));
};
const pickerValue = toPickerValue();
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);
@@ -121,12 +135,11 @@ const DatePicker = <T extends FieldValues>({
maxDate={maxDate}
onlyMonthPicker={onlyMonthPicker}
plugins={
timePicker
? [<TimePicker key={name} position="bottom" />]
: []
timePicker ? [<TimePicker key={String(name)} position="bottom" />] : []
}
onlyYearPicker={onlyYearPicker}
multiple={multiple}
{...(portal ? { portal: true } : {})}
value={pickerValue}
onChange={(date: DateObject | DateObject[] | null) => {
if (!date) {
@@ -192,7 +205,58 @@ const DatePicker = <T extends FieldValues>({
)}
</>
);
}}
};
const DatePicker = <T extends FieldValues>({
control,
name,
label,
multiple = false,
placeholder,
required,
range = false,
timePicker = false,
onlyMonthPicker = false,
onlyYearPicker = false,
rules,
maxDate,
minDate,
clearable = false,
portal = false,
}: IProps<T>) => {
return (
<div className="space-y-3">
<label
htmlFor={String(name)}
className="block text-body-sm font-semibold capitalize tracking-[0.01em] text-dark dark:text-white"
>
{label}
{required && <span className="ml-1 select-none text-error">*</span>}
</label>
<div>
<Controller
control={control}
name={name}
rules={rules}
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>
+33 -18
View File
@@ -47,7 +47,7 @@
/* third-party libraries CSS */
.rmdp-wrapper {
@apply !overflow-hidden !rounded-2xl !border !border-stroke/60 !bg-white/95 !shadow-[0_10px_30px_rgba(16,24,40,0.12)] dark:!border-dark-3/80 dark:!bg-dark-2/95 dark:!shadow-[0_20px_40px_rgba(2,6,23,0.55)];
@apply !overflow-visible !rounded-2xl !border !border-stroke/60 !bg-white/95 !shadow-[0_10px_30px_rgba(16,24,40,0.12)] dark:!border-dark-3/80 dark:!bg-dark-2/95 dark:!shadow-[0_20px_40px_rgba(2,6,23,0.55)];
}
.rmdp-calendar {
@@ -63,11 +63,11 @@
.rmdp-time-picker div input {
@apply !rounded-lg !border !border-stroke/70 !bg-white !text-dark !shadow-sm focus:!border-primary/70 dark:!border-dark-3/80 dark:!bg-dark-2 dark:!text-gray-5;
}
.rmdp-month-picker {
@apply dark:!bg-transparent;
}
/* Month/year panels sit on top of the day grid (absolute inset). They must stay
opaque in dark mode — transparent lets the day cells show through underneath. */
.rmdp-month-picker,
.rmdp-year-picker {
@apply dark:!bg-transparent;
@apply !z-[2] !rounded-xl !bg-white/95 dark:!bg-dark-2;
}
.rmdp-header-values,
@@ -91,19 +91,21 @@
@apply !rounded-lg transition-colors duration-200 hover:!bg-gray-2 dark:hover:!bg-dark-3;
}
.rmdp-day {
/* Day grid only — month/year pickers reuse .rmdp-day inside .rmdp-ym; fixed w/h
here would clip long month names (e.g. "February"). */
.rmdp-day-picker .rmdp-day {
@apply !m-0 !p-0 dark:!text-dark-6;
}
.rmdp-day span {
.rmdp-day-picker .rmdp-day span {
@apply !h-8 !w-8 !rounded-lg !font-medium;
}
.rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover {
.rmdp-day-picker .rmdp-day:not(.rmdp-disabled):not(.rmdp-day-hidden) span:hover {
@apply !border-primary/20 !bg-primary/10 !text-primary dark:!border-primary/30 dark:!bg-primary/20 dark:!text-white;
}
.rmdp-day.rmdp-today span {
.rmdp-day-picker .rmdp-day.rmdp-today span {
@apply !border !border-primary/35 !bg-transparent !text-primary dark:!border-primary/45 dark:!bg-transparent dark:!text-primary;
}
@@ -112,33 +114,46 @@
box-shadow: none !important;
}
.rmdp-day.rmdp-range {
.rmdp-day-picker .rmdp-day.rmdp-range {
@apply !bg-transparent !shadow-none;
color: inherit !important;
}
.rmdp-day.rmdp-range span {
.rmdp-day-picker .rmdp-day.rmdp-range span {
@apply !h-8 !w-full !rounded-none !border-transparent !bg-primary/10 !text-primary dark:!bg-primary/20 dark:!text-white;
}
.rmdp-day.rmdp-range.start span,
.rmdp-day.rmdp-range.end span {
.rmdp-day-picker .rmdp-day.rmdp-range.start span,
.rmdp-day-picker .rmdp-day.rmdp-range.end span {
@apply !h-8 !w-full !border-primary !bg-primary !text-white;
}
.rmdp-day.rmdp-range.start span {
.rmdp-day-picker .rmdp-day.rmdp-range.start span {
@apply !rounded-l-lg !rounded-r-none;
}
.rmdp-day.rmdp-range.end span {
.rmdp-day-picker .rmdp-day.rmdp-range.end span {
@apply !rounded-l-none !rounded-r-lg;
}
.rmdp-day.rmdp-today.rmdp-range.start span,
.rmdp-day.rmdp-today.rmdp-range.end span,
.rmdp-day.rmdp-today.rmdp-selected span {
.rmdp-day-picker .rmdp-day.rmdp-today.rmdp-range.start span,
.rmdp-day-picker .rmdp-day.rmdp-today.rmdp-range.end span,
.rmdp-day-picker .rmdp-day.rmdp-today.rmdp-selected span {
@apply !border-primary !bg-primary !text-white;
}
/* Month/year cells: center label; let highlight grow with text (library uses
position:absolute on .rmdp-day span — reset so flex + padding size the pill). */
.rmdp-month-picker .rmdp-ym .rmdp-day,
.rmdp-year-picker .rmdp-ym .rmdp-day {
/* Library defaults color:#000 on .rmdp-day — scope readable labels off the day grid */
@apply !relative !min-w-0 !text-dark/90 dark:!text-gray-5;
}
.rmdp-month-picker .rmdp-ym .rmdp-day span,
.rmdp-year-picker .rmdp-ym .rmdp-day span {
@apply !relative !inset-auto !inline-flex !h-auto !min-h-8 !w-auto !min-w-0 !max-w-none !items-center !justify-center !px-2 !py-1;
}
.rmdp-disabled {
@apply !opacity-45;
}