react-dates をカスタマイズして Material-UI の DatePicker っぽくしてみた

はじめに

テントの中から失礼します、CX事業本部のてんとタカハシです!

今回は Airbnb 製の DatePicker ライブラリ react-dates のカスタマイズを頑張って、Material-UI の DatePicker っぽくするチャレンジをしてみました。今回はあくまでも Material-UI っぽさを目指しますが、CSS を書いて元々の Styles を上書きしていくだけなので、同じような手順で他のデザインに似せたり、独自のデザインを実現することだって可能だと思います。

尚、react-dates とはなんぞやってのと、インストール手順、基本的な使い方等は下記の記事に書いていますので、そちらをご参照頂ければと思います。

react-dates を使った DatePicker のレスポンシブ対応をやる(DateRangePicker 編)

また、下記のリポジトリに、ソースコードを置いているので、併せて参考にして頂ければと思います。

GitHub - make-react-dates-look-like-mui

なんでこれやるのか

React で Web アプリを開発する際、UI のフレームワークとして Material-UI を使用するケースってよくあると思います。そのケースで、Web アプリ内にカレンダーを設置する場合は、Material-UI の DatePicker を使用することになると思います。

単純に日付を選択する用途であれば問題ないのですが、1つの DatePicker 上で日付の範囲を指定する必要がある場合、Material-UI の DatePicker にはそういった機能が無いので実現することができません (2020/09/17 時点での最新 ver - v3.2.10 では)。

さて困った。ということで代わりの DatePicker ライブラリとして react-dates を採用したとします。ああ、良かった。というところで次のお悩みになるのがデザインの問題。ベースとして Material-UI を使用しているのに、DatePicker だけ違和感ありありやんけ、さてどうしようとなります。

んじゃあ上手くカスタマイズするしかない。頑張ればそれなりに似せることだってできるんじゃね?って思ったのが、今回これをやろうと思ったきっかけです。

尚、Material-UI の DatePicker にはいくつかのコンポーネントが用意されています。今回は、最もベーシックな DatePicker コンポーネントを Inline mode かつ Toolbar を無効にしたデザインに似せていこうと思います。

環境

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.6
BuildVersion:	19G2021

$ node --version
v12.18.2

$ yarn --version
1.22.4

$ yarn list --depth=0
├─ @material-ui/core@4.11.0
├─ @material-ui/pickers@3.2.10
├─ react-dates@21.8.0
├─ react@16.13.1
├─ typescript@3.7.5

Material-UI の DatePicker

まずは、今回のターゲットである Material-UI の DatePicker を見てみましょう。実装は下記の通りです。ライブラリの Installation はこちら

import React, { useState } from 'react';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import { MuiPickersUtilsProvider } from '@material-ui/pickers';
import { DatePicker } from '@material-ui/pickers';
import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date';
import DateFnsUtils from '@date-io/date-fns';
import jaLocale from 'date-fns/locale/ja';

const useStyles = makeStyles(() =>
  createStyles({
    datePicker: {
      width: '25ch',
    },
  })
);

const MuiDatePicker: React.FC = () => {
  const classes = useStyles();
  const [selectedDate, setSelectedData] = useState<MaterialUiPickersDate>(null);

  return (
    <MuiPickersUtilsProvider utils={DateFnsUtils} locale={jaLocale}>
      <DatePicker
        className={classes.datePicker}
        disableToolbar
        variant="inline"
        inputVariant="standard"
        format="yyyy/MM/dd"
        id="@material-ui/pickers"
        label="@material-ui/pickers"
        value={selectedDate}
        onChange={setSelectedData}
      />
    </MuiPickersUtilsProvider>
  );
};

export default MuiDatePicker;

UI は下記の通りになります。TextField を Focus すると、DatePicker が表示されます。

react-dates の DatePicker

続いて、react-dates の DatePicker を実装します。日付範囲の指定が可能な DateRangePicker コンポーネントを使用します。

import React, { useState } from 'react';
import moment from 'moment';
import { DateRangePicker } from 'react-dates';

import 'moment/locale/ja';
import 'react-dates/initialize';
import 'react-dates/lib/css/_datepicker.css';

const ReactDatesDateRangePicker: React.FC = () => {
  const [startDate, setStartDate] = useState<moment.Moment | null>(null);
  const [endDate, setEndDate] = useState<moment.Moment | null>(null);
  const [focusedInput, setFocusedInput] = useState<
    'startDate' | 'endDate' | null
  >(null);

  return (
    <DateRangePicker
      startDate={startDate}
      startDateId="startDateId"
      endDate={endDate}
      endDateId="endDateId"
      focusedInput={focusedInput}
      keepOpenOnDateSelect={true}
      onFocusChange={setFocusedInput}
      onDatesChange={(selectedDates) => {
        setStartDate(selectedDates.startDate);
        setEndDate(selectedDates.endDate);
      }}
    />
  );
};

export default ReactDatesDateRangePicker;

UI は下記の通りになります。こちらも TextField を Focus すると、DatePicker が表示されます。デフォルトの状態では、Material-UI の DatePicker とはデザインが全然違うのが分かるかと思います。

react-dates のカスタマイズを頑張る

TextField を Material-UI のものにする

react-dates には、日付範囲の指定が可能な DatePicker のみを持つ DayPickerRangeController コンポーネントが用意されています。こちらを使用することで、TextField は 別のもの、つまり Material-UI の TextField と組み合わせることができます。

実装は下記の通りです。Material-UI の TextField を Focus したら、react-dates の DatePicker を表示するようにしています。

import React, { useState } from 'react';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import TextField from '@material-ui/core/TextField';
import moment from 'moment';
import { DayPickerRangeController } from 'react-dates';

import 'moment/locale/ja';
import 'react-dates/initialize';
import 'react-dates/lib/css/_datepicker.css';

const useStyles = makeStyles(() =>
  createStyles({
    textField: {
      width: '25ch',
    },
  })
);

const CustomReactDatesDateRangePicker: React.FC = () => {
  const classes = useStyles();
  const dateFormat = 'YYYY/MM/DD';
  const [startDate, setStartDate] = useState<moment.Moment | null>(null);
  const [endDate, setEndDate] = useState<moment.Moment | null>(null);
  const [focusedInput, setFocusedInput] = useState<'startDate' | 'endDate'>(
    'startDate'
  );
  const [display, setDisplay] = useState<boolean>(false);

  return (
    <>
      <TextField
        className={classes.textField}
        label="react-dates"
        value={
          startDate && endDate
            ? `${startDate.format(dateFormat)} ~ ${endDate.format(dateFormat)}`
            : ''
        }
        onFocus={() => setDisplay(true)}
      />
      {display && (
        <DayPickerRangeController
          startDate={startDate}
          endDate={endDate}
          focusedInput={focusedInput}
          onFocusChange={(focusedInput) => {
            setFocusedInput(!focusedInput ? 'startDate' : focusedInput);
          }}
          onDatesChange={(selectedDates) => {
            if (focusedInput === 'startDate') {
              setStartDate(selectedDates.startDate);
            } else {
              setEndDate(selectedDates.endDate);
            }
          }}
          onOutsideClick={() => setDisplay(false)}
        />
      )}
    </>
  );
};

export default CustomReactDatesDateRangePicker;

UI は下記の通りになります。Text Field が変わるだけでもそれっぽく見えますね。

DateRangePicker コンポーネントでは、デフォルトで2ヶ月分のカレンダーが表示されていましたが、こちらのコンポーネントでは単月表示がデフォルトのようです。

月を変更するボタンをカスタマイズする

月を変更するボタンを Material-UI のアイコンに差し替えます。DayPickerRangeController の navPrevnavNext でカスタマイズ可能です。

import IconButton from '@material-ui/core/IconButton';
import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';

...

{display && (
  <DayPickerRangeController
    startDate={startDate}
    endDate={endDate}
    focusedInput={focusedInput}
    navPrev={ // 前の月に戻るボタン ★
      <IconButton>
        <NavigateBeforeIcon />
      </IconButton>
    }
    navNext={ // 次の月に進むボタン ★
      <IconButton>
        <NavigateNextIcon />
      </IconButton>
    }
    ...
  />
)}

UI は下記の通りになります。残念ながらボタンの位置がズレてしまいます。

これを修正するには、CSS で Styles を上書きする必要があります。

CSS で Styles を上書きする

GitHub - react-dates#overriding-styles に記載があるように、react-dates の Styles をカスタマイズするためには、それ用の CSS ファイルを用意してインポートする必要があります。

CSS ファイルの名前は何でもよいのですが、ここでは react-dates-custom.css とします。これをインポートします。

import 'moment/locale/ja';
import 'react-dates/initialize';
import 'react-dates/lib/css/_datepicker.css';

import './style/react-dates-custom.css'; // 追加 ★

CSS の中身は下記の通りです。

.DayPickerNavigation {
  display: flex;
  justify-content: space-between;
}

UI は下記の通りになります。良い感じにボタンの位置を調節することができました。

Help ボタンを非表示にする

DatePicker の右下にある ? を非表示にします。DayPickerRangeController の hideKeyboardShortcutsPanel を true にします。

{display && (
  <DayPickerRangeController
    startDate={startDate}
    endDate={endDate}
    focusedInput={focusedInput}
    navPrev={
      <IconButton>
        <NavigateBeforeIcon />
      </IconButton>
    }
    navNext={
      <IconButton>
        <NavigateNextIcon />
      </IconButton>
    }
    hideKeyboardShortcutsPanel={true} // 追加 ★
    ...
  />
)}

UI は下記の通りになります。不要なボタンが非表示になりました。良い感じになってきましたね。次は日付周りの Styles を変更します。

日付周りの Styles を変更する

先ほどインポートした CSS を使って、元々の Styles を変更します。日付周りに付く border を削除して、日付指定時 もしくは hover 時の border を丸める & 色を変更します。

.CalendarDay,
.CalendarDay:hover {
  border: none;
  border-radius: 50%;
}

/* 指定した日付範囲の開始日・終了日 */
.CalendarDay__selected,
.CalendarDay__selected:active,
.CalendarDay__selected:hover {
  background: #3f51b5; /* 最も濃い青 */
}

/* 指定した日付範囲の開始日+1 ~ 終了日-1 */
.CalendarDay__selected_span,
.CalendarDay__selected_span:active,
.CalendarDay__selected_span:hover {
  background: #737fc4; /* 少し濃いめの青 */
}

/* 日付範囲の終了日を指定中 */
.CalendarDay__hovered_span,
.CalendarDay__hovered_span:hover {
  color: #fff;
  background: #a2aad6; /* 薄めの青 */
}

DatePicker を開いた状態は、下記のように元々あった日付周りの border が消えています。

日付を hover すると、背景がグレーの円形で表示されます。

この状態で、日付範囲の開始日を指定すると、背景が濃い青に変わります。また、他の日付を hover すると、開始日から hover している日付までの背景が薄めの青に変わります。

日付範囲の終了日を指定すると、終了日は濃い青、開始日から終了日の間は少し濃いめの青に変わります。

良い感じにカスタマイズできましたね。日付範囲が全て円形になっているのは少し微妙かもしれませんが、今回はひとまず OK として次に進みます。

影をつける

DatePicker に影をつけます。これも CSS で Styles を上書きします。

.DayPicker {
  box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2),
    0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12);
}

UI は下記の通りになります。良い感じに Material-UI の DatePicker っぽくなりましたね。

おわりに

今回のカスタマイズは以上になります。そこそこ良い感じに Material-UI の DatePicker っぽくカスタマイズできたのではないでしょうか。今回は Material-UI っぽさを目指しましたが、冒頭でも書いた通り、基本的には CSS で Styles を変えているだけなので、独自のデザインにカスタマイズすることも可能です。

ちなみに、Material-UI の DatePicker は v4 が開発途中であり、日付の範囲を指定できる DateRangePicker コンポーネントが追加される予定です。嬉しい。公式ドキュメントの方で触れるようになっているので試してみてください。早くリリースされてほしいなあ。

Material-UI pickers - Date range picker

今回は以上になります。最後まで読んで頂きありがとうございました!