こんにちは!
フューチャースピリッツ開発チームにおけるフロントエンドのエースこと、マッシュルームです!!
今回は僕がReactプロジェクトでよく使用している自作カレンダーを公開します!
全ソースを公開するのでそのままコピーするだけで実装できます!
是非真似してみてください!!
完成イメージ
今風のフラットデザインを取り入れつつ、ボタン類にはレイヤーの概念を取り入れ、
Googleのマテリアルデザインチックに仕上げています。
ディレクトリ構成
次の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>
使い方
テキストボックスにフォーカスを当てると、カレンダーが表示されます。
日付を押下する、またはフォーカスを外すと、カレンダーが閉じられます。
日付を押下すると、テキストボックスに日付が入力されます。
今日の日付は緑色で、選択(入力)された日付はグレーで表示されます。
また、テキストボックス内に直接日付(yyyy-MM-dd)を入力することも可能で、自動でカレンダーの日付が選択されます。
カレンダーは1980-01-01 ~ 9999-12-31までの範囲で対応しており、範囲外の日付を入力すると、強制で当月のカレンダーが表示されます。
選択(入力)された日付は引数:setDateを経由して、引数:dateから取得することができます。
おわりに
以上、自作カレンダーの実装方法でした!
超クールな今風フラットデザインカレンダーを是非導入してみてください!! ^^