This document outlines the steps to create DatePicker
component styled withTailwind CSS and using some npm dependency libraries.
Node.js
and npm
installed on your machine.Tailwind CSS
installed in your project.CVA(class-variance-authority)
is a utility for managing CSS class names based on various conditions.clsx
is a tiny utility for constructing className strings conditionally.dayjs
is a minimalist JavaScript library that parses, validates, manipulates, and displays dates and times.1// use-click-away.ts
2import { RefObject, useEffect } from "react";
3
4const defaultEvents = ["mousedown", "touchstart"];
5type DefaultEventType = "mousedown" | "touchstart";
6
7const useClickAway = (
8 ref: RefObject<HTMLElement | null>,
9 onClickAway: (event: MouseEvent | TouchEvent) => void,
10) => {
11 useEffect(() => {
12 const handler = (event: MouseEvent | TouchEvent) => {
13 const el = ref.current!;
14 if (!el) return;
15 if (el.contains(event.target as HTMLElement)) return;
16 onClickAway(event);
17 };
18
19 for (const eventName of defaultEvents) {
20 document.addEventListener(eventName as DefaultEventType, handler);
21 }
22
23 return () => {
24 for (const eventName of defaultEvents) {
25 document.removeEventListener(eventName as DefaultEventType, handler);
26 }
27 };
28 // eslint-disable-next-line react-hooks/exhaustive-deps
29 }, [ref]);
30};
31
32export default useClickAway;
33
1// date-picker.types.ts
2export enum PositionType {
3 Top,
4 Bottom,
5}
6
7export type TimeType = "day" | "month" | "year";
8
9export interface DateInputProps {
10 type: TimeType;
11 value: string | null | undefined;
12 disabled: boolean | undefined;
13 onChange: (value: string) => void;
14 onCompleted?: () => void;
15}
16
17export interface DatePopupProps {
18 dateValue: string | null;
19 position: PositionType;
20 currentMonth: string;
21 opening: boolean;
22 onGoPrevMonth: () => void;
23 onGoNextMonth: () => void;
24 onSelectDay: (value: string) => void;
25}
26
27export interface DatePickerProps {
28 value: string | null;
29 className?: string;
30 disabled?: boolean;
31 onChange: (value: string) => void;
32}
33
1// date-picker.helpers.ts
2import dayjs from "dayjs";
3import padStart from "lodash/padStart";
4
5export const MONTH_FORMAT = "YYYY/MM";
6export const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
7
8export const checkIsSameMonth = (month: string, day: string) =>
9 +month.slice(-2) === +day.split("-")[1];
10
11export const getMonth = (date: string | null | undefined) => {
12 const formattedDate = date?.replace("MM", "01").replace("DD", "01");
13 const dateInstance = formattedDate ? dayjs(formattedDate) : dayjs();
14 return dateInstance.format(MONTH_FORMAT);
15};
16
17export const formatDateString = (year: number, month: number, day: number) =>
18 (Number.isNaN(year) ? "YYYY" : padStart(year.toString(), 4, "0")) +
19 "-" +
20 (Number.isNaN(month) ? "MM" : padStart(month.toString(), 2, "0")) +
21 "-" +
22 (Number.isNaN(day) ? "DD" : padStart(day.toString(), 2, "0"));
23
24export const getDaysOfMonthWithAdjacentDays = (monthDate: string) => {
25 const year = +monthDate.split("/")[0];
26 const month = +monthDate.split("/")[1];
27
28 if (Number.isNaN(year) || Number.isNaN(month)) return [];
29
30 const daysInMonth = dayjs(`${year}-${month}-01`).daysInMonth();
31 const startDay = dayjs(`${year}-${month}-01`).day();
32 const endDay = dayjs(`${year}-${month}-${daysInMonth}`).day();
33
34 const days = [];
35
36 // Add days from the previous month
37 const daysFromPrevMonth = startDay;
38 const prevMonth = month === 1 ? 12 : month - 1;
39 const prevYear = month === 1 ? year - 1 : year;
40 const daysInPrevMonth = dayjs(`${prevYear}-${prevMonth}-01`).daysInMonth();
41
42 for (
43 let i = daysInPrevMonth - daysFromPrevMonth + 1;
44 i <= daysInPrevMonth;
45 i++
46 ) {
47 days.push(formatDateString(prevYear, prevMonth, i));
48 }
49
50 // Add days from the current month
51 for (let i = 1; i <= daysInMonth; i++) {
52 days.push(formatDateString(year, month, i));
53 }
54
55 // Add days from the next month
56 const daysFromNextMonth = 6 - endDay;
57 const nextMonth = month === 12 ? 1 : month + 1;
58 const nextYear = month === 12 ? year + 1 : year;
59
60 for (let i = 1; i <= daysFromNextMonth; i++) {
61 days.push(formatDateString(nextYear, nextMonth, i));
62 }
63
64 return days;
65};
66
67export const checkIsValidDayAndMonth = (month: number, day: number) => {
68 if (month === 2) {
69 if (day > 29) return false;
70 } else if (month === 4 || month === 6 || month === 9 || month === 11) {
71 if (day > 30) return false;
72 } else {
73 if (day > 31) return false;
74 }
75 return true;
76};
77
78export const checkIsValidDate = (year: number, month: number, day: number) => {
79 if (day > 31) return false;
80 if (month > 12) return false;
81 if (year > 9999) return false;
82
83 if (month === 4 || month === 6 || month === 9 || month === 11)
84 if (day > 30) return false;
85
86 if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8)
87 if (day > 31) return false;
88
89 if (month === 2) {
90 const isLeafYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
91 if (day > (isLeafYear ? 29 : 28)) return false;
92 }
93
94 return true;
95};
96
97export const getLastDayOfMonth = (month: number, year?: number) => {
98 if (month === 4 || month === 6 || month === 9 || month === 11) return 30;
99 else if (month === 2) {
100 if (year === undefined) return 29;
101 const isLeafYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
102 return isLeafYear ? 29 : 28;
103 } else return 31;
104};
105
106export const getNextMonth = (month: string) =>
107 dayjs(month).add(1, "month").format(MONTH_FORMAT);
108
109export const getPrevMonth = (month: string) =>
110 dayjs(month).subtract(1, "month").format(MONTH_FORMAT);
111
1// date-input.tsx
2import clsx from "clsx";
3import { forwardRef, useImperativeHandle, useRef, useState } from "react";
4import { formatDateString, getLastDayOfMonth } from "./date-picker.helpers";
5import { type DateInputProps } from "./date-picker.types";
6
7const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
8 ({ type, disabled, value, onChange, onCompleted = () => {} }, ref) => {
9 const inputRef = useRef<HTMLInputElement | null>(null);
10
11 useImperativeHandle(ref, () => inputRef.current!, []);
12
13 const [focusing, setFocusing] = useState<boolean>(false);
14 const [isDirty, setIsDirty] = useState<boolean>(false);
15
16 const changeInput = (year: number, month: number, day: number) => {
17 onChange(formatDateString(year, month, day));
18 };
19
20 const getInputData = () => {
21 const placeholder =
22 type === "day" ? "dd" : type === "month" ? "mm" : "yyyy";
23 const input =
24 value?.split("-")?.[type === "day" ? 2 : type === "month" ? 1 : 0];
25
26 if (!input || Number.isNaN(+input))
27 return { placeholder, inputValue: "" };
28
29 return {
30 placeholder,
31 inputValue: type === "year" ? +input.toString() : input,
32 };
33 };
34
35 const changeDay = (num: number) => {
36 const year = Number(value?.split("-")?.[0]);
37 const month = Number(value?.split("-")?.[1]);
38
39 if (!isDirty) {
40 num = num.toString().length > 1 ? +num.toString().slice(-1) : num;
41 } else if (num.toString().length > 2) {
42 num = +num.toString().slice(-1);
43 }
44
45 const maxDay = Number.isNaN(month)
46 ? 31
47 : Number.isNaN(year)
48 ? getLastDayOfMonth(month)
49 : getLastDayOfMonth(year, month);
50
51 if (num > maxDay) {
52 changeInput(year, month, +num.toString().slice(-1));
53 } else {
54 changeInput(year, month, num);
55 }
56
57 if (num.toString().length > 1 || num > 3 || (month === 2 && num > 2))
58 onCompleted();
59 };
60
61 const changeMonth = (num: number) => {
62 const year = Number(value?.split("-")?.[0]);
63 const day = Number(value?.split("-")?.[2]);
64
65 if (!isDirty) {
66 num = num.toString().length > 1 ? +num.toString().slice(-1) : num;
67 } else if (num.toString().length > 2) {
68 num = +num.toString().slice(-1);
69 }
70
71 if (num > 12) {
72 changeInput(year, +num.toString().slice(-1), day);
73 } else {
74 changeInput(year, num, day);
75 }
76
77 if (num.toString().length > 1 || num > 2) onCompleted();
78 };
79
80 const changeYear = (num: number) => {
81 const year = Number(value?.split("-")?.[0]);
82 const month = Number(value?.split("-")?.[1]);
83 const day = Number(value?.split("-")?.[2]);
84
85 const isAdded = year.toString().length < num.toString().length;
86
87 if (!isDirty && isAdded) {
88 num = num.toString().length > 1 ? +num.toString().slice(-1) : num;
89 } else if (num.toString().length > 4) {
90 num = +num.toString().slice(-1);
91 }
92
93 if (num > 9999) {
94 changeInput(+num.toString().slice(-1), month, day);
95 } else {
96 changeInput(num, month, day);
97 }
98 };
99
100 const handleChange = (val: string) => {
101 if (/D/.test(val)) return;
102 if (val === value) return;
103 const num = +val.replace(/D/g, "");
104
105 if (Number.isNaN(num)) return;
106
107 setIsDirty(true);
108
109 if (type === "day") {
110 changeDay(num);
111 } else if (type === "month") {
112 changeMonth(num);
113 } else {
114 changeYear(num);
115 }
116 };
117
118 const handleFocus = () => {
119 setFocusing(true);
120 };
121
122 const handleBlur = () => {
123 setFocusing(false);
124 setIsDirty(false);
125 };
126
127 const { inputValue, placeholder } = getInputData();
128
129 return (
130 <div
131 className={clsx(
132 "relative mx-[1px] rounded-md px-0.5 py-[1px]",
133 focusing && "bg-neutral-300 dark:bg-neutral-500",
134 )}
135 >
136 <span
137 className={clsx(
138 "font-medium",
139 !disabled && Number(inputValue) > 0
140 ? "text-neutral-700 dark:text-neutral-400"
141 : "text-neutral-400 dark:text-neutral-600",
142 )}
143 onClick={() => inputRef?.current?.focus()}
144 >
145 {Number(inputValue) > 0 ? inputValue : placeholder}
146 </span>
147 <input
148 ref={inputRef}
149 className="absolute inset-0 -z-50 opacity-0"
150 disabled={disabled}
151 value={inputValue}
152 onFocus={handleFocus}
153 onBlur={handleBlur}
154 onChange={(e) => handleChange(e.target.value)}
155 />
156 </div>
157 );
158 },
159);
160
161export default DateInput;
162
1// date-popup.tsx
2import clsx from "clsx";
3import dayjs from "dayjs";
4import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
5import { useMemo } from "react";
6import {
7 checkIsSameMonth,
8 getDaysOfMonthWithAdjacentDays,
9 WEEKDAYS,
10} from "./date-picker.helpers";
11import { type DatePopupProps, PositionType } from "./date-picker.types";
12
13const DatePopup: React.FC<DatePopupProps> = ({
14 dateValue,
15 position,
16 currentMonth,
17 opening,
18 onGoPrevMonth,
19 onGoNextMonth,
20 onSelectDay,
21}) => {
22 const days = useMemo(
23 () => getDaysOfMonthWithAdjacentDays(currentMonth),
24 [currentMonth],
25 );
26
27 const handleClickDay = (day: string) => {
28 if (checkIsSameMonth(currentMonth, day)) onSelectDay(day);
29 };
30
31 return (
32 <div
33 className={clsx(
34 "absolute left-0 z-10 w-[18rem] overflow-auto rounded-md border border-neutral-200 bg-white pb-4 pt-3 dark:border-neutral-600 dark:bg-neutral-800",
35 {
36 "bottom-[calc(100%+0.5rem)]": position === PositionType.Top,
37 "top-[calc(100%+0.5rem)]": position === PositionType.Bottom,
38 },
39 { hidden: !opening },
40 )}
41 >
42 <div className="flex items-center text-neutral-700 dark:text-neutral-300">
43 <div className="px-2">
44 <ChevronLeftIcon
45 className="h-6 w-6 cursor-pointer select-none"
46 onClick={onGoPrevMonth}
47 />
48 </div>
49 <div className="flex-1 text-center font-semibold leading-none">
50 {dayjs(currentMonth).format("MMMM YYYY")}
51 </div>
52 <div className="px-2">
53 <ChevronRightIcon
54 className="h-6 w-6 cursor-pointer select-none"
55 onClick={onGoNextMonth}
56 />
57 </div>
58 </div>
59 <div className="grid grid-cols-7 gap-1 px-2">
60 {WEEKDAYS.map((day) => (
61 <div
62 key={day}
63 className="mb-4 mt-4 text-center text-sm font-semibold text-neutral-400"
64 >
65 {day}
66 </div>
67 ))}
68 </div>
69 <div className="grid grid-cols-7 gap-x-1 gap-y-2 px-2">
70 {days.map((day) => (
71 <div
72 key={day}
73 className={clsx(
74 "rounded-sm py-1.5 text-center text-sm font-semibold",
75 day === dateValue
76 ? "cursor-default bg-blue-600 text-white"
77 : checkIsSameMonth(currentMonth, day)
78 ? "cursor-pointer text-neutral-700 hover:bg-blue-100 hover:text-blue-400 dark:text-neutral-300 dark:hover:bg-blue-200 dark:hover:text-neutral-700"
79 : "cursor-default text-neutral-300 dark:text-neutral-700",
80 )}
81 onClick={() => handleClickDay(day)}
82 >
83 {day.slice(-2)}
84 </div>
85 ))}
86 </div>
87 </div>
88 );
89};
90
91export default DatePopup;
92
1// date-picker.component.tsx
2import useClickAway from "@/hooks/use-click-away";
3import clsx from "clsx";
4import { CalendarIcon } from "lucide-react";
5import { useRef, useState } from "react";
6import DateInput from "./date-input";
7import { getMonth, getNextMonth, getPrevMonth } from "./date-picker.helpers";
8import { PositionType, type DatePickerProps } from "./date-picker.types";
9import DatePopup from "./date-popup";
10
11const DatePicker: React.FC<DatePickerProps> = ({
12 value,
13 className,
14 disabled,
15 onChange,
16}) => {
17 const dropdownRef = useRef<HTMLDivElement | null>(null);
18 const dayRef = useRef<HTMLInputElement | null>(null);
19 const monthRef = useRef<HTMLInputElement | null>(null);
20 const yearRef = useRef<HTMLInputElement | null>(null);
21
22 const [currentMonth, setCurrentMonth] = useState<string>(getMonth(value));
23 const [opening, setOpening] = useState<boolean>(false);
24 const [position, setPosition] = useState<PositionType>(PositionType.Top);
25
26 useClickAway(dropdownRef, () => setOpening(false));
27
28 const handleOpen = () => {
29 const { bottom } = dropdownRef.current!.getBoundingClientRect();
30 const distanceToBottom = window.innerHeight - bottom;
31 setPosition(
32 distanceToBottom > 230 ? PositionType.Bottom : PositionType.Top,
33 );
34 setOpening(true);
35 };
36
37 const changeDate = (value: string) => {
38 setCurrentMonth(getMonth(value));
39 onChange(value);
40 };
41
42 const selectDay = (day: string) => {
43 setOpening(false);
44 changeDate(day);
45 };
46
47 const goNextMonth = () => {
48 setCurrentMonth(getNextMonth(currentMonth));
49 };
50
51 const goPrevMonth = () => {
52 setCurrentMonth(getPrevMonth(currentMonth));
53 };
54
55 return (
56 <div ref={dropdownRef} className={clsx("relative w-[12rem]", className)}>
57 <div
58 className={clsx(
59 "flex items-center rounded-md border px-4 py-2",
60 {
61 "cursor-not-allowed border-neutral-300 text-neutral-300 dark:border-neutral-800 dark:text-neutral-600":
62 disabled,
63 "border-neutral-400 text-neutral-600 hover:border-cyan-500 dark:border-neutral-600 dark:text-neutral-400 dark:hover:border-cyan-500":
64 !disabled,
65 },
66 { "border-cyan-500 dark:border-cyan-500": opening },
67 )}
68 >
69 <div className="flex w-full flex-1 items-center overflow-hidden text-ellipsis whitespace-nowrap pr-2 text-sm font-medium">
70 <DateInput
71 ref={dayRef}
72 type="day"
73 disabled={disabled}
74 value={value}
75 onChange={changeDate}
76 onCompleted={() => monthRef.current!.focus()}
77 />
78 <span className="text-neutral-500 dark:text-neutral-600">/</span>
79 <DateInput
80 ref={monthRef}
81 type="month"
82 disabled={disabled}
83 value={value}
84 onChange={changeDate}
85 onCompleted={() => yearRef.current!.focus()}
86 />
87 <span className="text-neutral-500 dark:text-neutral-600">/</span>
88 <DateInput
89 ref={yearRef}
90 type="year"
91 disabled={disabled}
92 value={value}
93 onChange={changeDate}
94 />
95 </div>
96 <CalendarIcon
97 className="h-4 w-4 cursor-pointer"
98 onClick={() => !disabled && handleOpen()}
99 />
100 </div>
101 <DatePopup
102 dateValue={value}
103 opening={opening}
104 position={position}
105 currentMonth={currentMonth}
106 onGoPrevMonth={goPrevMonth}
107 onGoNextMonth={goNextMonth}
108 onSelectDay={selectDay}
109 />
110 </div>
111 );
112};
113
114export default DatePicker;
115