【React】超クールな今風フラットカレンダーの作り方

こんにちは!
フューチャースピリッツ開発チームにおけるフロントエンドのエースこと、マッシュルームです!!

今回は僕がReactプロジェクトでよく使用している自作カレンダーを公開します!

全ソースを公開するのでそのままコピーするだけで実装できます!
是非真似してみてください!!

完成イメージ

今風のフラットデザインを取り入れつつ、ボタン類にはレイヤーの概念を取り入れ、
Googleのマテリアルデザインチックに仕上げています。

custom_calendar_1

ディレクトリ構成

次の2ファイルを作成します。

  • CustomCalendar / CustomCalendar.tsx
  • CustomCalendar / CustomCalendar.css

 

CustomCalendar.tsx

import React, { useEffect, useRef, useState } from "react";
import './CustomCalendar.css';


type Props = {
    placeholder?: string;
    date: string;
    setDate: React.Dispatch<React.SetStateAction<string>>;
};

const CustomCalendar: React.FC<Props> = ({ placeholder, date, setDate }) => {


    /** ref */
    const calendarRef = useRef<HTMLInputElement>(null);   // カスタム日付テキストボックス
    const calendarDivRef = useRef<HTMLDivElement>(null);  // カレンダーブロック


    /** state */
    const [currentYear, setCurrentYear] = useState<number | null>(null);    // 表示対象の年
    const [currentMonth, setCurrentMonth] = useState<number | null>(null);  // 表示対象の月
    const [days, setDays] = useState<React.ReactElement[]>([]);             // 表示対象の日にち


    /**
     * 初期化処理
     */
    useEffect(() => {

        if (!calendarRef.current) return;
        calendarRef.current.value = date ?? "";
    }, [date]);


    /**
     * カレンダー外クリック処理
     */
    useEffect(() => {

        function handleClickOutside(event: MouseEvent) {

            const calendarEl = calendarDivRef.current;
            const inputEl = calendarRef.current;

            // クリック先がカレンダー内なら何もしない
            if ((inputEl && inputEl.contains(event.target as Node)) ||
                (calendarEl && calendarEl.contains(event.target as Node))) return;

            // それ以外ならカレンダーを閉じる
            if (calendarEl) calendarEl.style.display = "none";
        }

        document.addEventListener("mousedown", handleClickOutside);

        return () => {
            document.removeEventListener("mousedown", handleClickOutside);
        };
    }, []);


    /**
     * (日付テキストボックス) フォーカスイベント
     */
    function focusCalendar (event: any) {
        
        const target = event.relatedTarget;

        // 前月・次月・今日ボタンの場合は処理終了
        if (target && ["PREV-MONTH", "NEXT-MONTH", "TODAY"].includes(target.className)) return;
        // カレンダー表示処理
        if (calendarRef.current) openCalendar();
    };


    /**
     * カレンダー表示処理
     */
    function openCalendar (argYear: number | null = null, argMonth: number | null = null) {

        if (!calendarRef.current) return;

        // 当日日付取得
        const today = new Date();
        const nowYear = today.getFullYear();
        const nowMonth = today.getMonth() + 1;
        const nowDay = today.getDate();

        // 入力日付取得
        const inputDate = new Date(calendarRef.current.value);
        let year = argYear ?? inputDate.getFullYear();
        let month = argMonth ?? inputDate.getMonth() + 1;

        if (isNaN(year) || isNaN(month) || year < 1980 || year > 9999 || month < 1 || month > 12) {
            year = nowYear;
            month = nowMonth;
        }

        // カレンダー表示
        if (calendarDivRef.current) calendarDivRef.current.style.display = "block";

        // ヘッダ表示
        setCurrentYear(year);
        setCurrentMonth(month);

        // 日数取得
        const daysInMonth = new Date(year, month, 0).getDate();

        // 月初の曜日取得
        const firstDay = new Date(year, month - 1, 1).getDay();

        // 空白の曜日箇所生成
        const calendarBody:React.ReactElement[] = [];
        for (let i = 0; i < firstDay; i++) {
            calendarBody.push(<div key={`blank-${i}`}></div>);
        }

        // 日にち生成
        for (let day = 1; day <= daysInMonth; day++) {

            // 当日クラス付与判定
            const isToday = year === nowYear && month === nowMonth && day === nowDay;

            // 選択中の日にちクラス付与付与判定
            const isSelected =
                    inputDate.getFullYear() === year &&
                    inputDate.getMonth() + 1 === month &&
                    inputDate.getDate() === day;

            // 要素追加
            calendarBody.push(
                <div
                    key={`day-${day}`}
                    className={`DAY-ITEM${isToday ? " DAY-TODAY" : ""}${isSelected ? " DAY-SELECT" : ""}`}
                    tabIndex={0}
                    onClick={() => (clickDay(year, month, day))}
                >
                    {day}
                </div>
            );
        }

        setDays(calendarBody);
    }


    /**
     * 日付クリックイベント
     */
    function clickDay (year: number, month: number, day: number) {

        if (calendarRef.current) {
            const fmtDate = formatDate(new Date(year, month - 1, day))
            calendarRef.current.value = fmtDate;
            setDate(fmtDate);
        }
        if (calendarDivRef.current) calendarDivRef.current.style.display = "none";
    }


    /**
     * 選択日付フォーマット
     */
    function formatDate (dateStr: Date): string {

        let year = dateStr.getFullYear();
        let month = String(dateStr.getMonth() + 1).padStart(2, "0");
        let day = String(dateStr.getDate()).padStart(2, "0");

        if (Number(month) < 1) {
            month = "12";
            year--;
        }

        return `${year}-${month}-${day}`;
    }


    /**
     * カレンダー入力イベント処理
     */
    function inputCalendar () {

        if (!calendarRef.current) return;

        // 入力日付取得
        const inputDate = new Date(calendarRef.current.value);
        const inputYear = inputDate.getFullYear();
        const inputMonth = inputDate.getMonth() + 1;

        // 当日日付取得
        const today = new Date();
        const nowYear = today.getFullYear();
        const nowMonth = today.getMonth() + 1;

        // 1980年未満の場合は、強制で当月指定
        if (isNaN(inputYear) || String(inputYear).length !== 4 || Number(calendarRef.current.value.substring(0, 4)) < 1980) {
            openCalendar(nowYear, nowMonth);
        }
        else {
            openCalendar(inputYear, inputMonth);
        }
    }


    /**
     * 前月・次月ボタンクリックイベント
     */
    function clickPrevNext (direction: "prev" | "next") {

        if (!calendarRef.current) return;

        let year = currentYear;
        let month = currentMonth;

        if (!year || !month) return;

        if (direction === "prev") {
            // 1980年1月の場合は処理終了
            if (year === 1980 && month === 1) return calendarRef.current.focus();
            if (--month < 1) {
                month = 12;
                year--;
            }
        }
        else {
            // 9999年12月の場合は処理終了
            if (year === 9999 && month === 12) return calendarRef.current.focus();
            if (++month > 12) {
                month = 1;
                year++;
            }
        }

        calendarRef.current.focus();
        openCalendar(year, month);
    }


    /**
     * 今日ボタンクリックイベント
     */
    function clickToday () {

        if (!calendarRef.current) return;

        // 当日日付取得
        const today = new Date();
        const year = today.getFullYear();
        const month = today.getMonth();
        const day = today.getDate();

        // 日付セット
        const fmtDate = formatDate(new Date(year, month, day))
        calendarRef.current.value = fmtDate;
        setDate(fmtDate);

        // カレンダー非表示
        if (calendarDivRef.current) calendarDivRef.current.style.display = "none";
    }


    return (
        <div className="CUSTOM-DATEPICKER">

            {/*カスタム日付テキストボックス*/}
            <input type="text" ref={calendarRef} className="DATEPICKER" autoComplete="off" placeholder={placeholder ?? "yyyy-mm-dd"} onFocus={focusCalendar} onInput={inputCalendar} onChange={(e) => setDate(e.target.value)} />

            {/* カレンダー全体 */}
            <div className="CALENDAR" ref={calendarDivRef}>
                {/* ヘッダ */}
                <div className="CALENDAR-HEADER">
                    {/* 前月ボタン */}
                    <input type="button" className="BUTTON-PREV-NEXT PREV-MONTH" value="<" onClick={() => clickPrevNext("prev")} />
                    {/* 表示日付 */}
                    <div className="CURRENT-MONTH-YEAR">
                        <div className="CURRENT-YEAR-TEXT">{currentYear}年</div>
                        <div className="CURRENT-MONTH-TEXT">{currentMonth}月</div>
                    </div>
                    {/* 次月ボタン */}
                    <input type="button" className="BUTTON-PREV-NEXT NEXT-MONTH" value=">" onClick={() => clickPrevNext("next")} />
                    {/* 今日ボタン */}
                    <input type="button" className="BUTTON-TODAY TODAY" value="今日" onClick={() => clickToday()} />
                </div>

                {/* 曜日 */}
                <div className="CALENDAR-DAYS">
                    <div className="DAY">日</div>
                    <div className="DAY">月</div>
                    <div className="DAY">火</div>
                    <div className="DAY">水</div>
                    <div className="DAY">木</div>
                    <div className="DAY">金</div>
                    <div className="DAY">土</div>
                </div>

                {/* 日にち */}
                <div className="CALENDAR-BODY">
                    {days}
                </div>
            </div>
        </div>
    );
};

export default CustomCalendar;

CustomCalendar.css

/*カスタム日付テキストボックス*/
.CUSTOM-DATEPICKER {
    position: relative;
    display: inline-block;
    color: #333;
}

/*テキストボックス*/
.DATEPICKER {
    width: 120px;
    height: 20px;
    border: solid 1px;
    border-color: darkgrey;
    border-radius: 5px;
}

/*カレンダー全体*/
.CALENDAR {
    width: 250px;
    position: absolute;
    top: calc(100% + 5px);
    left: 0;
    z-index: 1000;
    display: none;
    background-color: white;
    border: 1px;
    border-color: darkgrey;
    border-radius: 10px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
    padding: 10px;
}

/*ヘッダ*/
.CALENDAR-HEADER {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 5px;
    font-size: 14px;
}

/*現在の年月 (親)*/
.CURRENT-MONTH-YEAR {
    display: flex;
}

/*現在の月*/
.CURRENT-MONTH-TEXT {
    width: 35px;
    text-align: right;
}

/*曜日*/
.CALENDAR-DAYS {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 5px;
    font-size: 14px;
    font-weight: bold;
}

/*各曜日*/
.DAY {
    width: 30px;
    height: 30px;
    font-weight: bold;
    display: flex;
    justify-content: center;
    align-items: center;
}

/*日にち*/
.CALENDAR-BODY {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    column-gap: 6.7px;
    font-size: 14px;
}
.CALENDAR-BODY .DAY-ITEM {
    width: 30px;
    height: 30px;
    border-radius: 50px;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
}
.CALENDAR-BODY .DAY-ITEM:hover {
    background-color: #f0f0f0;
}


/*今日の日にち*/
.DAY-TODAY {
    background-color: #c1efaa;
}


/*選択中の日にち*/
.DAY-SELECT {
    background-color: #a2a2a2;
}


/*前月・次月ボタン*/
.BUTTON-PREV-NEXT {
    width: 35px;
    height: 25px;
    background-color: #333;
    color: #ffffff;
    font-weight: bold;
    border: solid 1px;
    border-color: darkgrey;
    border-radius: 30px;
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    box-shadow: 0 0 6px 0 
      rgba(0, 0, 0, 0.5);
    transition: 0.2s;
    cursor: pointer;
}
.BUTTON-PREV-NEXT:hover {
    box-shadow: 0 4px 7px 0 rgba(0, 0, 0, 0.5);
    transform: translateY(-2px);
    cursor: pointer;
}


/*今日ボタン*/
.BUTTON-TODAY {
    width: 60px;
    height: 25px;
    background-color: #333;
    color: #ffffff;
    font-size: 12px;
    font-weight: bold;
    border: solid 1px;
    border-color: darkgrey;
    border-radius: 30px;
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    box-shadow: 0 0 6px 0 
      rgba(0, 0, 0, 0.5);
    transition: 0.2s;
    cursor: pointer;
}
.BUTTON-TODAY:hover {
    box-shadow: 0 4px 7px 0 rgba(0, 0, 0, 0.5);
    transform: translateY(-2px);
    cursor: pointer;
}

実装方法

カスタムカレンダーには、次の引数が必要となります。

  • placeholder (string)(オプション) :テキストボックスのプレースホルダー
  • date (string) :選択された日付
  • setDate (React.SetStateAction) :日付のセッター

使用したいコンポーネントにて、"CustomCalendar" をインポートします。

 import CustomCalendar from './CustomCalendar/CustomCalendar';

次のようなstateを作成します。

 const [inputDateFrom, setInputDateFrom] = useState<string>("");

jsx(tsx)内にて、次のように書くと実装されます。

<div>
    <CustomCalendar placeholder={"From"} date={inputDateFrom} setDate={setInputDateFrom}/>
</div>

使い方

テキストボックスにフォーカスを当てると、カレンダーが表示されます。
日付を押下する、またはフォーカスを外すと、カレンダーが閉じられます。

custom_calendar_2

日付を押下すると、テキストボックスに日付が入力されます。
今日の日付は緑色で、選択(入力)された日付はグレーで表示されます。

custom_calendar_3

また、テキストボックス内に直接日付(yyyy-MM-dd)を入力することも可能で、自動でカレンダーの日付が選択されます。

custom_calendar_4

カレンダーは1980-01-01 ~ 9999-12-31までの範囲で対応しており、範囲外の日付を入力すると、強制で当月のカレンダーが表示されます。

custom_calendar_5

選択(入力)された日付は引数:setDateを経由して、引数:dateから取得することができます。

おわりに

以上、自作カレンダーの実装方法でした!

超クールな今風フラットデザインカレンダーを是非導入してみてください!! ^^