diff --git a/src/components/FormElements/DatePicker/DatePicker.tsx b/src/components/FormElements/DatePicker/DatePicker.tsx index 5c487d6..be88dec 100644 --- a/src/components/FormElements/DatePicker/DatePicker.tsx +++ b/src/components/FormElements/DatePicker/DatePicker.tsx @@ -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 { maxDate?: number; timePicker?: boolean; clearable?: boolean; + /** Renders the calendar in a portal (recommended inside modals / overflow containers). */ + portal?: boolean; rules?: Omit< RegisterOptions>, "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 = { + name: Path; + 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 = ({ + name, + value, + onChange, + error, + range, + multiple, + onlyMonthPicker, + onlyYearPicker, + minDate, + maxDate, + timePicker, + clearable, + portal, + placeholder, +}: PickerInputProps) => { + 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 ( + <> +
+ ] : [] + } + 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} + /> + + + + {clearable && hasValue && ( + + )} +
+ + {error && ( +

+ {error.message as string} +

+ )} + + ); +}; + const DatePicker = ({ control, name, @@ -46,11 +222,12 @@ const DatePicker = ({ maxDate, minDate, clearable = false, + portal = false, }: IProps) => { return (
diff --git a/src/css/style.css b/src/css/style.css index 637cf1f..f5105c0 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -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; }