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

はじめに

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

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

前回の記事では、react-datesDateRangePicker という日付の範囲指定が可能な DatePicker と TextField がセットになったコンポーネントをレスポンシブ対応する方法についてご紹介しました。

今回は、DayPickerRangeController という日付の範囲指定が可能な DatePicker のみを持つコンポーネントのレスポンシブ対応についてご紹介しようと思います。

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

Github - react-dates-mobile-friendly

環境

$ 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
react-dates@21.8.0
react@16.13.1
typescript@3.7.5

DayPickerRangeController とは

冒頭でも説明した通り、日付の範囲指定が可能な DatePicker コンポーネントです。前回の記事でご紹介した DatePickerRange コンポーネントは TextField もセットで付いてきますが、こちらは DatePicker 単体となります。

TextField を独自で用意したい場合や、Button をクリックした際に、DatePicker を表示したいなどの用途で使えるかなと思います。今回は、Material-UI の TextField と DayPickerRangeController を組み合わせつつ、レスポンシブ対応を行ってみようと思います。

Material-UI の TextField と組み合わせる

DayPickerRangeController と Material-UI の TextField を単純に組み合わせると下記の実装になります。TextField を Focus すると、DatePicker が表示され、日付の範囲指定を行うと、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 MyDateRangePicker: 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="日付範囲選択"
        value={
          startDate && endDate
            ? `${startDate.format(dateFormat)} ~ ${endDate.format(dateFormat)}`
            : ''
        }
        InputProps={{
          readOnly: true,
        }}
        onFocus={() => setDisplay(true)}
      />
      {display && (
        <DayPickerRangeController
          startDate={startDate}
          endDate={endDate}
          focusedInput={focusedInput}
          numberOfMonths={2}
          onFocusChange={(focusedInput) => {
            setFocusedInput(!focusedInput ? 'startDate' : focusedInput);
          }}
          onDatesChange={(selectedDates) => {
            if (focusedInput === 'startDate') {
              setStartDate(selectedDates.startDate);
            } else {
              setEndDate(selectedDates.endDate);
              setDisplay(false);
            }
          }}
          onOutsideClick={() => setDisplay(false)}
        />
      )}
    </>
  );
};

export default MyDateRangePicker;

UI は下記のようになります。

TextField を Focus すると、DatePicker が表示されます。

ただ、これをスマホ表示すると、前回の記事と同様に DatePicker が画面から見切れてしまいます。スマホ表示の場合は、DatePicker を全画面で表示するように対応していきましょう。

スマホ表示を整える

全画面表示 & 縦表示にする

前回の記事と同様に、withPortalorientation を使って、DatePicker を全画面表示 & 縦表示にしてみましょう。

{display && (
  <DayPickerRangeController
    startDate={startDate}
    endDate={endDate}
    focusedInput={focusedInput}
    numberOfMonths={2}
    withPortal={true} // ★
    orientation="vertical" // ★
    ...
  />
)}

UI は下記のようになります。

うん、何か微妙に違う。いや、これで良い場合もあるとは思いますが、前回やった DateRangePicker と少し表示が異なります。ちゃんと全画面表示になってくれていませんね。

Style 上書きして全画面表示を頑張る

前回の記事と同様に、Styles 上書き用の CSS ファイルを用意して、DatePicker を全画面表示させます。

import { DayPickerRangeController } from 'react-dates';

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

import './style/react-dates-custom.css'; // 上書き用の CSS ファイルをインポートする ★

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

.DayPicker_portal__vertical {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 10000;
}

.DayPickerNavigation__verticalDefault {
  position: fixed;
}

.DayPicker_portal__vertical の Styles を上書きして、縦表示にした DatePicker を全画面表示にさせます。また、DatePicker の月を変更するボタンがスクロールしないと表示されなくなってしまうので、.DayPickerNavigation__verticalDefault を上書きして、ボタンの位置を画面下に固定します。

UI は下記のようになります。前回の記事と同じ表示になりましたね。あとは DatePicker を閉じるためのボタンを表示させましょう。

閉じるボタンを追加する

この辺は前回の記事と同じような実装になります。

const useStyles = makeStyles(() =>
  createStyles({
    textField: {
      width: '25ch',
    },
    close: { // 閉じるボタンの Styles ★
      position: 'absolute',
      top: '5px',
      right: '5px',
    },
  })
);

...

{display && (
  <DayPickerRangeController
    startDate={startDate}
    endDate={endDate}
    focusedInput={focusedInput}
    numberOfMonths={2}
    withPortal={true}
    orientation="vertical"
    renderCalendarInfo={() => ( // 閉じるボタン ★
      <IconButton
        aria-label="close"
        className={classes.close}
        onClick={() => setDisplay(false)}
      >
        <ClearIcon />
      </IconButton>
    )}
    ...
  />
)}

良い感じになりました!

レスポンシブ対応

これまでの実装を踏まえて、レスポンシブ対応していきましょう。前回の記事と同様に、Material-UI の Hidden コンポーネントを使用します。

全体の実装として下記の通りになります。

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

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

import './style/react-dates-custom.css';

const useStyles = makeStyles(() =>
  createStyles({
    textField: {
      width: '25ch',
    },
    close: {
      position: 'absolute',
      top: '5px',
      right: '5px',
    },
  })
);

const MyDateRangePicker: 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);

  const dateRangePicker = (isMobile?: boolean) => (
    <DayPickerRangeController
      startDate={startDate}
      endDate={endDate}
      focusedInput={focusedInput}
      numberOfMonths={2}
      withPortal={isMobile}
      orientation={isMobile ? 'vertical' : 'horizontal'}
      renderCalendarInfo={() =>
        isMobile ? (
          <IconButton
            aria-label="close"
            className={classes.close}
            onClick={() => setDisplay(false)}
          >
            <ClearIcon />
          </IconButton>
        ) : (
          <></>
        )
      }
      onFocusChange={(focusedInput) => {
        setFocusedInput(!focusedInput ? 'startDate' : focusedInput);
      }}
      onDatesChange={(selectedDates) => {
        if (focusedInput === 'startDate') {
          setStartDate(selectedDates.startDate);
        } else {
          setEndDate(selectedDates.endDate);
          setDisplay(false);
        }
      }}
      onOutsideClick={() => setDisplay(false)}
    />
  );

  return (
    <>
      <TextField
        className={classes.textField}
        label="日付範囲選択"
        value={
          startDate && endDate
            ? `${startDate.format(dateFormat)} ~ ${endDate.format(dateFormat)}`
            : ''
        }
        InputProps={{
          readOnly: true,
        }}
        onFocus={() => setDisplay(true)}
      />
      {display && (
        <>
          <Hidden xsDown implementation="js">
            {dateRangePicker()}
          </Hidden>
          <Hidden smUp implementation="js">
            {dateRangePicker(true)}
          </Hidden>
        </>
      )}
    </>
  );
};

export default MyDateRangePicker;

UI の表示が切り替わる様子は、下記リポジトリの README に gif を載せているので、そちらをご参照ください。

Github - react-dates-mobile-friendly

おわりに

今回は、前回の記事に引き続き、 react-dates を使った DatePicker の レスポンシブ対応について記事にしました。

DateRangePickerDayPickerRangeController どちらも、DatePicker を全画面表示するための Props が用意されているのですが、双方で同じ設定をした際の表示が微妙に異なってしまい、対応に少し苦労しました。Chrome DevTools を使ってクラス名を特定するのは結構大変。良い感じにコンポーネント内の設定で制御できるようになってくれると嬉しいなと思いました。

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