Search

Ctrl + K

DatePicker

This document outlines the steps to create DatePicker component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • 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.
Step 1: Create useClickAway hook
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
Step 2: Create TypeScript types
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
Step 3: Create helper functions
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
Step 4: Create DateInput component
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
Step 5: Create DatePopup component
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
Step 6: Create DatePicker component
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