ReactでGoolgeカレンダーっぽいものをなるべく簡単につくってみる。

July 20, 2020

Reactで「Googleカレンダーのようなカレンダーを作りたいな。」と思って、作成したときのメモです。

コードの全量:GitHub

※このカレンダーで実装できていないこと。
・予定の変更/削除。

環境の説明

言語

  • Typescript

使用するライブラリ一覧

create-react-app (公式サイト)
Reactの環境を作成してくれるもの。1からReactの環境を作成しようとすると結構手間がかかりますが、これだとコマンド一発で構築してくれるのでとても便利。

@material-ui/core (公式サイト)
マテリアルデザインが簡単に実装できるライブラリ。別プロジェクトで使っていたため今回も採用。 今回は、Styleを定義できるHookAPIのみ使用し、Material-UIのデザインコンポーネントは使っておりません。

@fullcalendar/react
React用のFullCalendarのコアライブラリ。実現したいことによって、これ以外にもインストールする必要があります。

@fullcalendar/daygrid
@fullcalendar/timegrid
週表示をさせる時に左のサイドバーに時間を表示させる。 react fullcalendar 001

@fullcalendar/interaction
日付を選択して予定を追加するために必要。 react fullcalendar 002

react-datepicker (公式サイト)
カレンダーから日付/時間を選択して、フォームに入力することができるようになる。
react fullcalendar 003

date-fns
react-datepickerでロケールを設定する時に必要。

公式サイトより:
「The date picker relies on date-fns internationalization to localize its display components」
要は、「datepickerはdate-fnsに依存している」ってことらしいです。

@types/react-datepicker
react-datepickerの型定義情報。

環境の準備

それでは、環境を構築していきます。

① React環境(Typescirpt)を構築する。

$ yarn create react-app sample-calendar --typescript

② 各種ライブラリをインストールしていく。

$ yarn add @material-ui/core \
    @fullcalendar/react \
    @fullcalendar/timegrid \
    @fullcalendar/interaction \
    react-datepicker \
    date-fns

# 型定義は開発環境のみに入れたいため。
$ yarn add -D @types/react-datepicker

以上で、準備完了です。

実装

とりあえずカレンダーを表示してみる。

まずは、以下の要件が満たせる最低限のカレンダーを実装していきます。

  • 月/週のカレンダーが表示できるようにする。
  • 日付を選択できるようにする。

こんな感じのカレンダーができます。 react fullcalendar 004 01 react fullcalendar 004

それではコードを見ていきます。

./src/components/SampleCalendar.tsx

/**
 * 各種モジュールのインストール
*/
import React from 'react'

// FullCalendarコンポーネント。
import FullCalendar from '@fullcalendar/react'

// FullCalendarで週表示を可能にするモジュール。
import timeGridPlugin from '@fullcalendar/timegrid'

// FullCalendarで月表示を可能にするモジュール。
import dayGridPlugin from '@fullcalendar/daygrid'

// FullCalendarで日付や時間が選択できるようになるモジュール。
import interactionPlugin from '@fullcalendar/interaction'

const SampleCalendar: React.FC = props => {

  return (
    <div>
      <FullCalendar
        locale="ja" // ロケール設定。
        plugins={[timeGridPlugin, dayGridPlugin, interactionPlugin]} // 週表示、月表示、日付等のクリックを可能にするプラグインを設定。
        initialView="timeGridWeek" // カレンダーの初期表示設定。この場合、週表示。
        slotDuration="00:30:00" // 週表示した時の時間軸の単位。
        selectable={true} // 日付選択を可能にする。interactionPluginが有効になっている場合のみ。
        businessHours={{ // ビジネス時間の設定。
          daysOfWeek: [1, 2, 3, 4, 5], // 0:日曜 〜 7:土曜
          startTime: '00:00',
          endTIme: '24:00'
        }} 
        weekends={true} // 週末を強調表示する。
        titleFormat={{ // タイトルのフォーマット。(詳細は後述。※1)
          year: 'numeric',
          month: 'short'
        }}
        headerToolbar={{ // カレンダーのヘッダー設定。(詳細は後述。※2)
          start: 'title',
          center: 'prev, next, today',
          end: 'dayGridMonth,timeGridWeek'
        }}
      />
    </div>
  )
}

export default SampleCalendar

上記のソースで$ yarn startするとブラウザでカレンダーが確認できます。

※1 titleFormat
この設定の場合、「2020年7月」のように年と月で表示されます。

titleFormat={{
  year: 'numeric',
  month: 'short'
}}

※2 headerToolbar

headerToolbar={{
  start: 'title', // タイトルを左に表示する。
  center: 'prev, next, today',  // 「前月を表示」、「次月を表示」、「今日を表示」ができるボタンを画面の中央に表示する。
  end: 'dayGridMonth,timeGridWeek' // 月・週表示を切り替えるボタンを表示する。
}}

こんな感じで表示されます。 react fullcalendar 005

予定登録用フォームを作成する。

カレンダーに予定を追加するためのフォームを作成していきます。
できあがりは、こんな感じです。(最低限のデザイン)
react fullcalendar 006

そして、こちらがコード。前回から追加したコードには「★」が付いています。

// useStateを追加。import React, {useState} from 'react'
import FullCalendar from '@fullcalendar/react'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'

/** 
 * 開始時間などを入力する際に、カレンダーから入力できるようにするためのライブラリとしてDatePickerを使用。
 * DatePickerコンポーネント、ロケール設定用のモジュール。
*/import DatePicker, { registerLocale } from "react-datepicker";

// DatePickerのロケールを設定に使用。import ja from 'date-fns/locale/ja'

/**
 * Material-UIを通して、Styleを適用するためのモジュール。
 * - createStyles: 型推論を解決してくれるモジュール。
 * - makeStyles: StyleをHookAPIで適用させるモジュール。
*/import {createStyles, makeStyles} from "@material-ui/core/styles";

// Styleconst useStyles = makeStyles(() =>
  createStyles({
    cover: {
      opacity: 0,
      visibility: 'hidden',
      position: 'fixed',
      width: '100%',
      height: '100%',
      zIndex: 1000,
      top: 0,
      left: 0,
      background: 'rgba(0, 0, 0, 0.3)'
    },
    form: {
      opacity: 0,
      visibility: 'hidden',
      position: 'fixed',
      top: '30%',
      left: '40%',
      fontWeight: 'bold',
      background: 'rgba(255, 255, 255)',
      width: '400px',
      height: '300px',
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
      zIndex: 2000,
    },
    inView: { // cover, formを表示する時に適用するStyle。
      opacity: 1,
      visibility: 'visible'
    },
  })
)

// DatePickerのロケールを日本に設定。registerLocale('ja', ja)

// 追加するイベントの型。interface myEventsType {
  id: number
  title: string
  start: Date
  end: Date
}

const SampleCalendar: React.FC = props => {const classes = useStyles()

  /**
   * 予定を追加する際にCalendarオブジェクトのメソッドを使用する必要がある。
   * (CalendarオブジェクトはRef経由でアクセスする必要がある。)
   */const ref = React.createRef<any>()const [inputTitle, setInputTitle] = useState('') // フォームに入力されたタイトル。const [inputStart, setInputStart] = useState(new Date) // イベントの開始時刻。const [inputEnd, setInputEnd] = useState(new Date) // イベントの終了時刻。const [inView, setInView] = useState(false) // イベント登録フォームの表示有無。(trueなら表示する。)const [myEvents, setMyEvents] = useState<myEventsType[]>([]) // 登録されたイベントが格納されていく。myEventsTypタイプの配列。

  /**
   * カレンダーがクリックされた時にイベント登録用のフォームを表示する。
   * それぞれのフォームが下記の状態で表示される。
   *  - タイトル: 空欄
   *  - 開始: クリックしたカレンダーの開始時間
   *  - 終了: クリックしたカレンダーの終了時間
   */const handleCLick = (info: any) => {
    /**
     * infoにはカレンダーに登録されたイベントが入ってくる。そのイベントのIDを元にmyEvents
     * に格納されたイベントを取り出してStateに保存する。
     */
    const event = myEvents[info.event.id]
    const title = event.title
    const start = event.start
    const end = event.end

    setInputTitle(title)
    setInputStart(start)
    setInputEnd(end)
    setInView(true)
   }

  /**
   * カレンダーから登録された予定をクリックした時にイベント変更用のフォームを表示する。
   * それぞれのフォームが下記の状態で表示される。
   *  - タイトル: 選択した予定のタイトル
   *  - 開始: 選択した予定の開始時間
   *  - 終了: 選択した予定の終了時間
   */const handleSelect = (selectinfo: any) => {
    const start = new Date(selectinfo.start)
    const end = new Date(selectinfo.end)
    start.setHours(start.getHours())
    end.setHours(end.getHours())

    setInputTitle('')
    setInputStart(start)
    setInputEnd(end)
    setInView(true)
  }

  /**
   * カレンダーに予定を追加する。
   */const onAddEvent = () => {
    const startTime = inputStart
    const endTime = inputEnd

    if (startTime >= endTime) {
      alert('開始時間と終了時間を確認してください。')
      return
    }
    const event: myEventsType = {
      id: myEvents.length,
      title: inputTitle,
      start: startTime,
      end: endTime
    }

    // Stateにイベントを追加する。ここで登録されたイベントは、予定を変更するときなどに使用する。
    setMyEvents([...myEvents, event])
    
    // カレンダーに予定を登録して表示するための処理。
    ref.current.getApi().addEvent(event)
  }

  /**
   * ここからはフォームを構成する要素。
   */ 
  //フォームが表示された時に、グレー背景でフォーム以外を非アクティブ化に見えるようにするための要素。const coverElement = (
    <div
      onClick={() => setInView(false)}
      className={
        inView
          ? `${classes.cover} ${classes.inView}`
          : classes.cover
      }
    />
  )const titleElement = (
    <div>
      <label>タイトル</label>
      <input
        type="text"
        value={inputTitle}
        name="inputTitle"
        onChange={e => {
          // タイトルが入力されたら、その値をStateに登録する。
          setInputTitle(e.target.value)
        }}
      />
    </div>
  )const startTimeElement = (
    <div>
      <label>開始</label>
      <DatePicker
        locale="ja"
        dateFormat="yyyy/MM/d HH:mm"
        selected={inputStart}
        showTimeSelect
        timeFormat="HH:mm"
        timeIntervals={10}
        todayButton="today"
        name="inputStart"
        onChange={(time: Date) => {
          setInputStart(time)
        }}
      />
    </div>
  )const endTimeElement = (
    <div>
      <label>終了</label>
      <DatePicker
        locale="ja"
        dateFormat="yyyy/MM/d HH:mm"
        selected={inputEnd}
        showTimeSelect
        timeFormat="HH:mm"
        timeIntervals={10}
        todayButton="today"
        name="inputEnd"
        onChange={(time: Date) => {
          setInputEnd(time)
        }}
      />
    </div>
  )const btnElement = (
    <div>
      <input
        type="button"
        value="キャンセル"
        onClick={() => {
          setInView(false)
        }}
      />
      <input
        type="button"
        value="保存"
        onClick={() => onAddEvent()}
      />
    </div>
  )const formElement = (
    <div
      className={
        inView
          ? `${classes.form} ${classes.inView}`
          : classes.form
      }
    >
      <form>
        <div>予定を入力</div>
        {titleElement}
        {startTimeElement}
        {endTimeElement}
        {btnElement}
      </form>
    </div>
  )

  return (
    <div>
      {coverElement}
      {formElement}
      <FullCalendar
        locale="ja"
        plugins={[timeGridPlugin, dayGridPlugin, interactionPlugin]}
        initialView="timeGridWeek"
        slotDuration="00:30:00"
        selectable={true}
        businessHours={{
          daysOfWeek: [1, 2, 3, 4, 5],
          startTime: '00:00',
          endTIme: '24:00'
        }}
        weekends={true}
        titleFormat={{
          year: 'numeric',
          month: 'short'
        }}
        headerToolbar={{
          start: 'title',
          center: 'prev, next, today',
          end: 'dayGridMonth,timeGridWeek'
        }}
        ★ ref={ref}
        ★ eventClick={handleCLick}
        ★ select={handleSelect}
      />
    </div>
  )
}

export default SampleCalendar

終わりに

以上でカレンダーの作成は完了です。まだ、予定の変更や削除が実装できていないので追加する必要があるので後日更新したいと思います。