frontend

Upiększanie date pickera za pomocą react-spring i Tailwind CSS ❤

Will4_U
#react#date picker#react-spring#tailwind css#frontend

W końcu kontynuacja przygody z budowaniem własnego date pickera!

dla tych, którzy nie czytali pierwszego artykułu - warto obczaić

Dzisiaj dowiesz się, w jaki sposób zbudować TAKI DATE PICKER: 😍

prezentacja pięknego date pickera

Czyli w skrócie:

dobra, koniec pierdolenia - jazda

Najpierw zaczniemy od animacji

zdecydowałem się na wybranie react-spring ze względu na wielkość - ok. 2x mniejsza od framer-motion plus nie potrzebujemy takiej kobyły do animacji

ponieważ to było moje starcie z animacjami w CSS to zapytałem się ChatGPT o to, w jaki sposób mogę użyć tej libki. później już sam zacząłem grzebać w dokumentacji libki i zacząłem robić swoje.

najpierw - animacja pojawiania się date pickera:

import { animated, useTransition } from "@react-spring/web";

// useTransition pozwala na to, aby zanimować "zamontowanie" jak i "odmontowanie" komponentu.

const menuTransitions = useTransition(menuMode === "expanded", {
    from: { opacity: 1, transform: "translate(-30%, -200%)" },
    enter: { opacity: 1, transform: "translate(-30%, -20%)" },
    leave: { opacity: 0, transform: "translate(-30%, -200%)" },
  });

// i podmieniamy <menu> na:

{menuTransitions(
        (styles, isExpanded) =>
          isExpanded && (
             <animated.menu
              style={styles}
            >
            // ...
            </animated.menu>
          ))
}

tak to się prezentuje:

animacja pojawiania się i znikania date pickera w zależności czy menu jest rozwinięte czy zawinięte

no i najs - teraz zajmiemy się przejściami pomiędzy dniami, miesiącami i latami.

const pickerTransitions = useTransition(pickerMode, {
    from: { opacity: 0 },
    enter: { opacity: 1 },
    leave: { opacity: 0 },
    config: {
      duration: 100,
    },
  });


// w JSX:

{pickerTransitions(
                  (styles, mode) =>
                    mode === "days" && (
                      <animated.div style={styles}>
                        <animated.ul>
                          // renderowanie dni
                        </animated.ul>
                      </animated.div>
                    )
                )}
{pickerTransitions(
                  (styles, mode) =>
                    mode === "months" && (
                      <animated.div style={styles}>
                        <animated.ul>
                          // renderowanie miesięcy
                        </animated.ul>
                      </animated.div>
                    )
                )}

{pickerTransitions(
                  (styles, mode) =>
                    mode === "years" && (
                      <animated.div style={styles}>
                        <animated.ul>
                          // renderowanie lat
                        </animated.ul>
                      </animated.div>
                    )
                )}

w pickerTransition dodatkowo ustawiam długość animacji, aby nie była zbyt długa.

no i tak to wygląda:

animacja przejścia pomiędzy dniami, miesiącami i latami

elegancko! teraz zajmiemy się najważniejszą animacją:

przejścia pomiędzy miesiącami, gdy wybieramy datę

na początku skorzystamy z useSpring i zdefiniujemy animację startową:

const [daysListProps, daysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(100%)" },
    }),
    [],
  );

const [virtualizedDaysListProps, virtualizedDaysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(0%)" },
    }),
    [],
  );

no i teraz możesz się zastanawiać, co tu robi virtualizedDaysListProps skoro mamy jeden ul?

otóż aby zrobić taką animację, to potrzebujemy tak naprawdę dwóch ul - jeden, który będzie renderował dni, a drugi, który też będzie renderował dni, ale wirtualnie.

co oznacza “wirtualnie”? może w tym przypadku nie jest to najlepsze słowo, ale chodzi o to, żeby podczas animowania użytkownik miał takie wrażenie, że jeden “ul` z poprzednią datą będzie się przesuwał w górę (albo w dół, w zależności czy kliknie “Previous month” czy “Next month), a drugi “ul” już z nowym miesiącem zajmie jego miejsce.

dlatego potrzebujemy dwóch elementów.

teraz musimy nadpisać dotychczasowe działanie handleNextMonth i handlePreviousMonth:

const TRANSITION_DURATION = 500;

const handlePreviousMonth = () => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(-100%)" });
        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(0%)" });

        setDatePickerDate(
            new Date(datePickerDate.setMonth(datePickerDate.getMonth() - 1))
        );

        await next({ transform: "translateY(100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });
  };

const handleNextMonth = () => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(0%)" });
        await next({ transform: "translateY(-100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(100%)" });

        setDatePickerDate(
            new Date(datePickerDate.setMonth(datePickerDate.getMonth() + 1))
        );

        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });
  };

hola hola hola, co tu się odpierdala?

już wyjaśniam dla widza:

efekt?

animacja przejścia pomiędzy miesiącami

no panie, to wygląda już całkiem całkiem 😉

Teraz czas na refactor tego wszystkiego

może ten kod nie jest zły (bo nie jest), ale możemy sprawić, aby był łatwiejszy do utrzymania i czytania.

co można zrobić?

dobra, zatem do roboty:

po co przenosić do useReducera?

aktualnie mamy kilka useState’ów, które są ze sobą powiązane:

const [mode, setMode] = useState<"simple" | "expanded">("simple");
const [pickerMode, setPickerMode] = useState<"days" | "months" | "years">(
    "days"
);
const [datePickerDate, setDatePickerDate] = useState(new Date());
const [shortDate, setShortDate] = useState(new Date());

i w zależności od tego, co się dzieje, to zmieniamy jeden lub kilka useState’ów.

czy coś jest z tym nie tak? no nie, ale już samo zarządzanie stanem może być problematyczne, bo już w tym momencie:

zmiana useState na useReducer to również zmiana “mentalna” - z podejścia, gdzie widzimy szczegóły logiki zarządzania stanem, do podejścia, gdzie te szczegóły są schowane do reducera

tak więc tak to będzie wyglądać po refactorze:

type DatePickerState = {
  menuMode: "simple" | "expanded";
  pickerMode: "days" | "months" | "years";
  datePickerDate: Date;
  shortDate: Date;
};

type DatePickerAction =
  | { type: "UPDATE_SHORT_DATE" }
  | { type: "PREVIOUS_YEAR" }
  | { type: "NEXT_YEAR" }
  | { type: "PREVIOUS_MONTH" }
  | { type: "NEXT_MONTH" }
  | { type: "TOGGLE_DATE_PICKER" }
  | { type: "SHOW_MONTHS_VIEW" }
  | { type: "SHOW_YEARS_VIEW" }
  | {
      type: "SHOW_DAYS_VIEW";
      payload:
        | {
            value: number;
            dateType: "year";
          }
        | {
            value: number;
            dateType: "month";
          };
    };

const datePickerReducer = (
  state: DatePickerState,
  action: DatePickerAction,
): DatePickerState => {
  switch (action.type) {
    case "UPDATE_SHORT_DATE":
      return {
        ...state,
        shortDate: new Date(),
      };

    case "TOGGLE_DATE_PICKER":
      return {
        ...state,
        datePickerDate:
          state.menuMode === "expanded" ? new Date() : state.datePickerDate,
        menuMode: state.menuMode === "simple" ? "expanded" : "simple",
      };

    case "PREVIOUS_YEAR":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .subtract(1, "year")
          .toDate(),
      };

    case "NEXT_YEAR":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate).add(1, "year").toDate(),
      };

    case "PREVIOUS_MONTH":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .subtract(1, "month")
          .toDate(),
      };

    case "NEXT_MONTH":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate).add(1, "month").toDate(),
      };

    case "SHOW_MONTHS_VIEW":
      return {
        ...state,
        pickerMode: "months",
      };

    case "SHOW_YEARS_VIEW":
      return {
        ...state,
        pickerMode: "years",
      };

    case "SHOW_DAYS_VIEW":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .set(action.payload.dateType, action.payload.value)
          .toDate(),
        pickerMode: "days",
      };

    default:
      return state;
  }
};


export function DatePicker() {
  const [{ menuMode, pickerMode, datePickerDate, shortDate }, dispatch] =
    useReducer(datePickerReducer, {
      menuMode: "simple",
      pickerMode: "days",
      datePickerDate: new Date(),
      shortDate: new Date(),
    });

    // zamiast setState'ów, teraz używamy dispatcha do wykonania odpowiedniej akcji / "rozkazu"
    useEffect(() => {
      const ONE_SECOND = 1000;

      const interval = setInterval(() => {
        dispatch({ type: "UPDATE_SHORT_DATE" });
      }, ONE_SECOND);

      return () => clearInterval(interval);
    }, []);

    // reszta logiki...
}

może na początek to wydawać się bardziej przetłaczające od wariantu z useState, ale w dłuższej perspektywie czasu jest to o wiele lepsze rozwiązanie.

czy w przypadku naszego date pickera nie jest to może przesadą? być może, ale warto wiedzieć, że takie podejście istnieje i jest bardzo często stosowane w większych projektach.

teraz czas na wyrzucenie wszystkiego związanego z react-spring do custom hooka

po co to robić? a dlatego, żeby nie “uzależniać” DatePickera bezpośrednio od react-springa i łatwiej będzie wprowadzić zmiany w przyszłości.

najpiew tworzymy custom hook:

const useAnimatedDatePicker = ({
  menuMode,
  pickerMode,
}: Pick<DatePickerState, "menuMode" | "pickerMode">) => {
  const menuTransitions = useTransition(menuMode === "expanded", {
    from: { opacity: 1, transform: "translate(-30%, -200%)" },
    enter: { opacity: 1, transform: "translate(-30%, -20%)" },
    leave: { opacity: 0, transform: "translate(-30%, -200%)" },
  });

  const pickerTransitions = useTransition(pickerMode, {
    from: { opacity: 0 },
    enter: { opacity: 1 },
    leave: { opacity: 0 },
    config: {
      duration: 100,
    },
  });

  const [daysListProps, daysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(100%)" },
    }),
    [],
  );

  const [virtualizedDaysListProps, virtualizedDaysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(0%)" },
    }),
    [],
  );

  const TRANSITION_DURATION = 500;

  const animatePreviousMonthTransition = (onTransiton: () => void) => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(-100%)" });
        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(0%)" });

        onTransiton();

        await next({ transform: "translateY(100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });
  };

  const animateNextMonthTransition = (onTransiton: () => void) => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(0%)" });
        await next({ transform: "translateY(-100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(100%)" });

        onTransiton();

        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });
  };

  return {
    menuTransitions,
    pickerTransitions,
    daysListProps,
    virtualizedDaysListProps,
    animatePreviousMonthTransition,
    animateNextMonthTransition,
  };
};

w komponencie:

export function DatePicker() {
    // reducer...

    const {
        menuTransitions,
        pickerTransitions,
        daysListProps,
        virtualizedDaysListProps,
        animatePreviousMonthTransition,
        animateNextMonthTransition,
    } = useAnimatedDatePicker({ menuMode, pickerMode });

    // ...

    // podmianka w handlerach:
    const handlePreviousMonth = () => {
        animatePreviousMonthTransition(() => dispatch({ type: "PREVIOUS_MONTH" }));
    };

    const handleNextMonth = () => {
        animateNextMonthTransition(() => dispatch({ type: "NEXT_MONTH" }));
    };

    // ...
}

jest gituwa 😎

no i ostatnia rzecz - podmiana na dayjs

a dlatego, że wbudowane Date API to ściek i warto użyć czegoś bardziej “cywilizowanego” do operowania na datach.

import dayjs from "dayjs";

const generateMonths = () => {
  return Array.from({ length: 12 }, (_, i) => {
    const date = dayjs().month(i).startOf("month");
    return {
      month: date.toDate().toLocaleString(navigator.language, {
        month: "short",
      }),
      num: date.get("month"),
    };
  });
};

const months = generateMonths();

// generate the last 100 years and the next 100 years
const generateYears = () => {
  const currentYear = dayjs().get("year") - 100;
  return Array.from({ length: 200 }, (_, year) => currentYear + year);
};

const years = generateYears();

const daysInCurrentMonth = (now: Date) => {
  return Array.from({ length: dayjs(now).daysInMonth() }, (_, i) => i + 1);
};

const daysInPreviousMonth = (now: Date) => {
  const firstDayInMonth = dayjs(now).startOf("month");
  const firstDayOfWeek = firstDayInMonth.startOf("week");

  const generateDays = () => {
    return Array.from(
      { length: firstDayInMonth.diff(firstDayOfWeek, "day") },
      (_, i) => firstDayOfWeek.add(i, "day").get("D"),
    );
  };

  return generateDays();
};

const daysInNextMonth = (daysToGenerate: number) => {
  const now = dayjs();
  const lastDayInCurrentMonth = now.add(1, "month").startOf("month");

  return Array.from({ length: daysToGenerate }, (_, i) =>
    lastDayInCurrentMonth.add(i, "day").get("D"),
  );
};

const generateDays = (date: Date = new Date()) => {
  const DATES_TO_GENERATE = 42; // 6 rows of 7 days, like in Windows calendar

  const prevMonth = daysInPreviousMonth(date);
  const currentMonth = daysInCurrentMonth(date);

  const generatedDays = prevMonth.length + currentMonth.length;

  return [
    ...prevMonth,
    ...currentMonth,
    ...daysInNextMonth(DATES_TO_GENERATE - generatedDays),
  ];
};

dodatkowo pokażę buga, którego przeoczyłem

do tej pory gdy zmieniało się miesiące to one nie były do końca konsystetne z tym, jak rzeczywiście działa date picker:

przed: bug związany z niekonsystentnymi miesiącami

po: poprawnie działający date picker

cały błąd leżał w funkcji daysInPreviousMonth, która nie do końca poprawnie działała. dzięki dayjs mogliśmy to łatwo ogarnąć:

const daysInPreviousMonth = (now: Date) => {
  const firstDayInMonth = dayjs(now).startOf("month");
  const firstDayOfWeek = firstDayInMonth.startOf("week");

  const generateDays = () => {
    return Array.from(
      { length: firstDayInMonth.diff(firstDayOfWeek, "day") },
      (_, i) => firstDayOfWeek.add(i, "day").get("D"),
    );
  };

  return generateDays();

najważniejsza jest ta linijka: firstDayInMonth.diff(firstDayOfWeek, "day"), ponieważ ona sprawia, że bierze różnicę dni pomiędzy pierwszym dniem w miesiącu, a pierwszym dniem w tygodniu, dzięki czemu ten problem został rozwiązany.

no i została nam ostateczna rzecz - ostylowanie tego wszystkiego za pomocą Tailwind CSS

póki co komponent wygląda bardzo “surowo”, tak więc ogarnijmy ładne kolory, dark mode oraz responsywność, ale nie chcę tu męczyć kodem, tak więc od razu przejdźmy do finału:

tak prezentuje się ostatecznie kod:

import { useEffect, useRef, ElementRef, useReducer } from "react";
import { animated, useSpring, useTransition, easings } from "@react-spring/web";
import dayjs from "dayjs";

const generateMonths = () => {
  return Array.from({ length: 12 }, (_, i) => {
    const date = dayjs().month(i).startOf("month");
    return {
      month: date.toDate().toLocaleString(navigator.language, {
        month: "short",
      }),
      num: date.get("month"),
    };
  });
};

const months = generateMonths();

// generate the last 100 years and the next 100 years
const generateYears = () => {
  const currentYear = dayjs().get("year") - 100;
  return Array.from({ length: 200 }, (_, year) => currentYear + year);
};

const years = generateYears();

const daysInCurrentMonth = (now: Date) => {
  return Array.from({ length: dayjs(now).daysInMonth() }, (_, i) => i + 1);
};

const daysInPreviousMonth = (now: Date) => {
  const firstDayInMonth = dayjs(now).startOf("month");
  const firstDayOfWeek = firstDayInMonth.startOf("week");

  const generateDays = () => {
    return Array.from(
      { length: firstDayInMonth.diff(firstDayOfWeek, "day") },
      (_, i) => firstDayOfWeek.add(i, "day").get("D"),
    );
  };

  return generateDays();
};

const daysInNextMonth = (daysToGenerate: number) => {
  const now = dayjs();
  const lastDayInCurrentMonth = now.add(1, "month").startOf("month");

  return Array.from({ length: daysToGenerate }, (_, i) =>
    lastDayInCurrentMonth.add(i, "day").get("D"),
  );
};

const generateDays = (date: Date = new Date()) => {
  const DATES_TO_GENERATE = 42; // 6 rows of 7 days, like in Windows calendar

  const prevMonth = daysInPreviousMonth(date);
  const currentMonth = daysInCurrentMonth(date);

  const generatedDays = prevMonth.length + currentMonth.length;

  return [
    ...prevMonth,
    ...currentMonth,
    ...daysInNextMonth(DATES_TO_GENERATE - generatedDays),
  ];
};

type DatePickerState = {
  menuMode: "simple" | "expanded";
  pickerMode: "days" | "months" | "years";
  datePickerDate: Date;
  shortDate: Date;
};

type DatePickerAction =
  | { type: "UPDATE_SHORT_DATE" }
  | { type: "PREVIOUS_YEAR" }
  | { type: "NEXT_YEAR" }
  | { type: "PREVIOUS_MONTH" }
  | { type: "NEXT_MONTH" }
  | { type: "TOGGLE_DATE_PICKER" }
  | { type: "SHOW_MONTHS_VIEW" }
  | { type: "SHOW_YEARS_VIEW" }
  | {
      type: "SHOW_DAYS_VIEW";
      payload:
        | {
            value: number;
            dateType: "year";
          }
        | {
            value: number;
            dateType: "month";
          };
    };

const datePickerReducer = (
  state: DatePickerState,
  action: DatePickerAction,
): DatePickerState => {
  switch (action.type) {
    case "UPDATE_SHORT_DATE":
      return {
        ...state,
        shortDate: new Date(),
      };

    case "TOGGLE_DATE_PICKER":
      return {
        ...state,
        datePickerDate:
          state.menuMode === "expanded" ? new Date() : state.datePickerDate,
        menuMode: state.menuMode === "simple" ? "expanded" : "simple",
      };

    case "PREVIOUS_YEAR":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .subtract(1, "year")
          .toDate(),
      };

    case "NEXT_YEAR":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate).add(1, "year").toDate(),
      };

    case "PREVIOUS_MONTH":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .subtract(1, "month")
          .toDate(),
      };

    case "NEXT_MONTH":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate).add(1, "month").toDate(),
      };

    case "SHOW_MONTHS_VIEW":
      return {
        ...state,
        pickerMode: "months",
      };

    case "SHOW_YEARS_VIEW":
      return {
        ...state,
        pickerMode: "years",
      };

    case "SHOW_DAYS_VIEW":
      return {
        ...state,
        datePickerDate: dayjs(state.datePickerDate)
          .set(action.payload.dateType, action.payload.value)
          .toDate(),
        pickerMode: "days",
      };

    default:
      return state;
  }
};

const useAnimatedDatePicker = ({
  menuMode,
  pickerMode,
}: Pick<DatePickerState, "menuMode" | "pickerMode">) => {
  const menuTransitions = useTransition(menuMode === "expanded", {
    from: { opacity: 1, transform: "translate(-30%, -200%)" },
    enter: { opacity: 1, transform: "translate(-30%, -20%)" },
    leave: { opacity: 0, transform: "translate(-30%, -200%)" },
  });

  const pickerTransitions = useTransition(pickerMode, {
    from: { opacity: 0 },
    enter: { opacity: 1 },
    leave: { opacity: 0 },
    config: {
      duration: 100,
    },
  });

  const [daysListProps, daysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(100%)" },
    }),
    [],
  );

  const [virtualizedDaysListProps, virtualizedDaysListApi] = useSpring(
    () => ({
      from: { transform: "translateY(0%)" },
    }),
    [],
  );

  const TRANSITION_DURATION = 500;

  const animatePreviousMonthTransition = (onTransiton: () => void) => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(-100%)" });
        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(0%)" });

        onTransiton();

        await next({ transform: "translateY(100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: TRANSITION_DURATION,
      },
    });
  };

  const animateNextMonthTransition = (onTransiton: () => void) => {
    virtualizedDaysListApi.start({
      to: async (next) => {
        virtualizedDaysListApi.set({ transform: "translateY(0%)" });
        await next({ transform: "translateY(-100%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });

    daysListApi.start({
      to: async (next) => {
        daysListApi.set({ transform: "translateY(100%)" });

        onTransiton();

        await next({ transform: "translateY(0%)" });
      },
      config: {
        easing: easings.easeOutExpo,
        duration: 500,
      },
    });
  };

  return {
    menuTransitions,
    pickerTransitions,
    daysListProps,
    virtualizedDaysListProps,
    animatePreviousMonthTransition,
    animateNextMonthTransition,
  };
};

export function DatePicker() {
  const [{ menuMode, pickerMode, datePickerDate, shortDate }, dispatch] =
    useReducer(datePickerReducer, {
      menuMode: "simple",
      pickerMode: "days",
      datePickerDate: new Date(),
      shortDate: new Date(),
    });

  const {
    menuTransitions,
    pickerTransitions,
    daysListProps,
    virtualizedDaysListProps,
    animatePreviousMonthTransition,
    animateNextMonthTransition,
  } = useAnimatedDatePicker({ menuMode, pickerMode });

  useEffect(() => {
    const ONE_SECOND = 1000;

    const interval = setInterval(() => {
      dispatch({ type: "UPDATE_SHORT_DATE" });
    }, ONE_SECOND);

    return () => clearInterval(interval);
  }, []);

  const toggleDatePicker = () => {
    dispatch({ type: "TOGGLE_DATE_PICKER" });
  };

  const handlePreviousYear = () => {
    dispatch({ type: "PREVIOUS_YEAR" });
  };

  const handleNextYears = () => {
    dispatch({ type: "NEXT_YEAR" });
  };

  const days = generateDays(datePickerDate);

  const yearRef = useRef<ElementRef<"li">>(null);

  useEffect(() => {
    if (pickerMode !== "years") return;

    yearRef.current?.scrollIntoView({
      block: "center",
    });
  }, [menuMode, pickerMode]);

  const handlePreviousMonth = () => {
    animatePreviousMonthTransition(() => dispatch({ type: "PREVIOUS_MONTH" }));
  };

  const handleNextMonth = () => {
    animateNextMonthTransition(() => dispatch({ type: "NEXT_MONTH" }));
  };

  return (
    <div className="relative">
      {menuTransitions(
        (styles, isExpanded) =>
          isExpanded && (
            <animated.menu
              className="absolute bottom-24 h-[200px] w-[250px] bg-slate-200 pt-4 shadow-date-picker-menu dark:bg-black dark:text-white sm:h-[300px] sm:w-[350px]"
              style={styles}
            >
              <div className="flex w-full justify-around pb-4 ">
                <button
                  className="w-[10%] border border-slate-400 transition-colors duration-200 hover:bg-slate-300 dark:border-neutral-400 dark:bg-black dark:text-white dark:hover:bg-neutral-800 sm:h-[35px]"
                  onClick={
                    pickerMode === "years" || pickerMode === "months"
                      ? handlePreviousYear
                      : handlePreviousMonth
                  }
                  aria-label="Previous month"
                >

                </button>

                {pickerMode === "days" && (
                  <button
                    className="w-[50%] text-center font-medium"
                    onClick={() => dispatch({ type: "SHOW_MONTHS_VIEW" })}
                  >
                    <span className="sr-only">Change to months view</span>
                    {datePickerDate.toLocaleString(navigator.language, {
                      month: "long",
                      year: "numeric",
                    })}
                  </button>
                )}

                {pickerMode === "months" || pickerMode === "years" ? (
                  <button
                    disabled={pickerMode === "years"}
                    className="w-[50%] text-center font-medium"
                    onClick={() => dispatch({ type: "SHOW_YEARS_VIEW" })}
                  >
                    <span className="sr-only">Change to years view</span>
                    {datePickerDate.toLocaleString(navigator.language, {
                      year: "numeric",
                    })}
                  </button>
                ) : null}

                <button
                  className="w-[10%] border border-slate-400 transition-colors duration-200 hover:bg-slate-300 dark:border-neutral-400 dark:bg-black dark:text-white dark:hover:bg-neutral-800"
                  onClick={
                    pickerMode === "years" || pickerMode === "months"
                      ? handleNextYears
                      : handleNextMonth
                  }
                  aria-label="Next month"
                >

                </button>
              </div>

              <div className="relative h-full overflow-hidden bg-slate-200 dark:bg-black dark:text-white">
                {pickerTransitions(
                  (styles, mode) =>
                    mode === "days" && (
                      <animated.div style={styles}>
                        <animated.ul
                          className="grid-rows-7 absolute grid h-full w-full grid-cols-7 items-center text-center"
                          style={daysListProps}
                        >
                          {days.map((day, i) => (
                            <li key={i}>
                              <button className="h-full w-full font-medium transition-colors duration-200 hover:bg-slate-300 dark:hover:bg-neutral-800 sm:p-2">
                                {day}
                              </button>
                            </li>
                          ))}
                        </animated.ul>

                        <animated.ul
                          className="grid-rows-7 absolute grid h-full w-full grid-cols-7 items-center text-center"
                          style={virtualizedDaysListProps}
                        >
                          {days.map((day, i) => (
                            <li key={i}>
                              <button className="h-full w-full font-medium transition-colors duration-200 hover:bg-slate-300 dark:hover:bg-neutral-800 sm:p-2">
                                {day}
                              </button>
                            </li>
                          ))}
                        </animated.ul>
                      </animated.div>
                    ),
                )}

                {pickerTransitions(
                  (styles, mode) =>
                    mode === "months" && (
                      <animated.ul
                        className="absolute grid h-full w-full list-none grid-cols-4 grid-rows-3 items-center justify-items-center"
                        style={styles}
                      >
                        {months.map(({ month, num }) => (
                          <li className="h-full w-full" key={month}>
                            <button
                              className="h-full w-full font-medium transition-colors duration-200 hover:bg-slate-300 dark:hover:bg-neutral-800"
                              onClick={() => {
                                dispatch({
                                  type: "SHOW_DAYS_VIEW",
                                  payload: {
                                    value: num,
                                    dateType: "month",
                                  },
                                });
                              }}
                            >
                              {month}
                            </button>
                          </li>
                        ))}
                      </animated.ul>
                    ),
                )}

                {pickerTransitions(
                  (styles, mode) =>
                    mode === "years" && (
                      <animated.ul
                        className="grid h-full grid-cols-4 grid-rows-4 items-center justify-items-center overflow-y-scroll"
                        style={styles}
                      >
                        {years.map((year, i) => (
                          <li
                            key={i}
                            ref={year === dayjs().get("year") ? yearRef : null}
                            className="mt-2 h-[50px] w-full"
                          >
                            <button
                              className="h-full w-full font-medium transition-colors duration-200 hover:bg-slate-300 dark:hover:bg-neutral-800"
                              onClick={() => {
                                dispatch({
                                  type: "SHOW_DAYS_VIEW",
                                  payload: {
                                    value: year,
                                    dateType: "year",
                                  },
                                });
                              }}
                            >
                              {year}
                            </button>
                          </li>
                        ))}
                      </animated.ul>
                    ),
                )}
              </div>
            </animated.menu>
          ),
      )}

      <button
        onClick={toggleDatePicker}
        className="rounded-md bg-slate-200 p-2 transition-colors duration-200 hover:bg-slate-300 dark:bg-black dark:text-white dark:hover:bg-neutral-800"
      >
        <span className="sr-only">
          {menuMode === "simple"
            ? "Open the date picker"
            : "Close the date picker"}
        </span>
        <menu className="p-2">
          <p className="text-sm">
            {shortDate.toLocaleTimeString(navigator.language, {
              hour: "numeric",
              minute: "numeric",
            })}
          </p>
          <p className="text-xs">{shortDate.toLocaleDateString()}</p>
        </menu>
      </button>
    </div>
  );
}

Tutaj możesz sprawdzić ten komponent w akcji

Czy jest to najlepszy komponent jaki kiedykolwiek powstał? no nie XD

ale co jak co to było ciekawe doświadczenie, żeby odtworzyć jakiś element UI - nie macie co się martwić, bo takich artykułów czy nawet filmów na YT będzie tylko więcej ;p

no i to tyle

← wracaj na bloga