Skip to content

input[type="date"] とよく似た何かを作ってみる

2022-12-18


最近、カレンダーの UI とその入力コンポーネントについて考えている。
そこで、input[type="date"] を使うのか、自分でそれっぽい振る舞いをするコンポーネントを作るのかを考えていた。
こんなもの、結局実現したい課題によって選択肢は変わるので、あくまで一例として考えていくのと、どちらも一度作ってみた方がいいのではということで手を動かしたというメモ。あくまでプロトタイプでありリファクタや仕様の詰めは必要。

今回のコード:takurinton/dateinput

メリットとデメリット

ざっくりとそれぞれのメリットとデメリットを考えると以下のような感じになる。
ある程度対応するブラウザ(というよりバージョンや OS)を絞ってしまえば現在はモダンブラウザでは input[type="date"] には対応しているので、そこまで問題にならないかもしれない。

参考:ブラウザーの互換性

  • input[type="date"] を使うパターン
    • メリット
      • ブラウザに生えている機能を使うので自作するより実装が楽になる
    • デメリット
      • 意外と痒いところに手が届かないかもしれない
      • どこまで対応するかによるが、対応してないブラウザを使ってるユーザーや IE についても考える必要がある
        • そんなもの考えたくないが...。
  • 自分で実装するパターン
    • メリット
      • カスタマイズがしやすい
      • input[type="text"] で実装を行うため、スマホや OS の差分を吸収する必要がなくなる
    • デメリット
      • 実装が多少複雑になる
        • 特に keyboard のイベントを扱うあたりが多少複雑になる
        • 先に答えがわかってる状態で言うと、当初思っていたよりは簡単だった

今回やりたいこと

  • 日付の管理は dayjs でおこなう
  • AllowUp/AllowDown で日付を変更、AllowRight/AllowLeft で移動する
  • 単一の日付を選択するものと、範囲を指定するものの 2 つを作る
  • 変なフォーマットだったり指定できない日付が入力されたらエラーを出す

input[type="date] を使うパターン

途中まで書いたけどあまり特別なことはしてなくてコードを見た方が早い気がしたので割愛。
takurinton/dateinput/src/Native

基本的には

  • styled-components による色付け
  • useInput という名前のカスタムフックの定義

の 2 つに分けて実装を行う。
また、日付の範囲指定を行う場合は開始と終了を keyDown で行き来したいというユースケースが発生するため、そこは ref を定義して onKeyDown を見て処理を行うようにしている。
年月日の間を移動する際は input[type="date"] が勝手にやってくれるので特に考えることはない。

自作パターン

自作パターンはフックが少々複雑になる。なぜならinput[type="date"] がやってくれていた AllowRight/AllowLeft によるフォーカスの切り替えや AllowUp/AllowDown による日付の切り替えを自分で実装する必要があるから。
コード: takurinton/dateinput/src/Input

大きくわけて以下の 5 つにわけて実装を行う。

  • 共通のコンポーネント
  • 共通の関数
  • 型定義
  • カスタムフック
  • 実際に使うコンポーネント

方針としては、共通のコンポーネントとカスタムフックで動作や状態管理は完結するように作る。
また、カスタムフックには処理を追加することができる。今回で言うと AllowRight/AllowLeft による日付コンポーネント間でのフォーカスの切り替えを親コンポーネントで管理して、流し込むようにしている。(そうでないと切り替えがうまくいかないので)

共通のコンポーネント

ここではもらった props を流すだけの共通のコンポーネントを定義する。
コンポーネントは input[type="text"] で実装し、年月日それぞれの input を横並びにする形にして表現する。

ここでもらう props は次のカスタムフックで定義する関数の戻り値をそのまま流すだけのシンプルなものになっている。
ここで注意する点は onKeyDown と onChange が渡されているが、onKeyDown は開発者のための状態管理に、onChange はユーザーの入力に対する状態管理に使うことを想定している。
onKeyDown と onChange に渡す handler は基本的には同じ処理を行うため、カリー化を使って共通化しておくと便利。

import { Dayjs } from "dayjs";
import { Typography } from "ingred-ui";
import { ChangeEvent, forwardRef, KeyboardEvent, memo, RefObject } from "react";
import { InputContainer, InputElement } from "./styled";
import { Selected, YMD } from "../types";

type Props = {
  date: Dayjs;
  focus: boolean;
  valid: boolean;
  selected: Selected;
  yearRef: RefObject<HTMLInputElement>;
  monthRef: RefObject<HTMLInputElement>;
  dayRef: RefObject<HTMLInputElement>;
  onFocus: () => void;
  onBlur: () => void;
  onKeyDown: (type: YMD) => (event: KeyboardEvent<HTMLInputElement>) => void; // for developer
  handleChange: (type: YMD) => (event: ChangeEvent<HTMLInputElement>) => void; // for user
};

const Input = forwardRef<HTMLDivElement, Props>(
  (
    {
      focus,
      valid,
      selected,
      yearRef,
      monthRef,
      dayRef,
      onFocus,
      onBlur,
      onKeyDown,
      handleChange,
    },
    ref
  ) => (
    <InputContainer ref={ref} focus={focus} valid={valid}>
      <InputElement
        placeholder="yyyy"
        ref={yearRef}
        count={4}
        value={selected.y}
        maxLength={4}
        pattern="[0-9]*"
        onFocus={onFocus}
        onBlur={onBlur}
        onChange={handleChange("y")}
        onKeyDown={onKeyDown("y")}
      />
      <Typography component="span" color="gray">
        /
      </Typography>
      <InputElement
        placeholder="mm"
        ref={monthRef}
        count={2}
        value={selected.m}
        maxLength={2}
        pattern="[0-9]*"
        onFocus={onFocus}
        onBlur={onBlur}
        onChange={handleChange("m")}
        onKeyDown={onKeyDown("m")}
      />
      <Typography component="span" color="gray">
        /
      </Typography>
      <InputElement
        placeholder="dd"
        ref={dayRef}
        count={2}
        value={selected.d}
        maxLength={2}
        pattern="[0-9]*"
        onFocus={onFocus}
        onBlur={onBlur}
        onChange={handleChange("d")}
        onKeyDown={onKeyDown("d")}
      />
    </InputContainer>
  )
);

export const CommonInput = memo(Input);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

共通の関数

共通の関数は正常な日付かどうかの確認と、日付のフォーマットを行う関数を定義する。

正常な日付かどうかの確認は、年月日それぞれの値が正しい範囲内かどうかを確認して、全ての値が正しい場合に true を返す isValidDate 関数を定義した。

import dayjs from "dayjs";
import { Day, Month, Selected, Year, YMD } from "./types";

// その月が何日あるかの確認
const daysInMonth = (year: Year, month: Month) =>
  new Date(Number(year), Number(month), 0).getDate();

// 年の確認
const isValidYear = (value: Selected) => {
  const year = Number(value.y);
  return year >= 1900 && year <= 2099;
};

// 月の確認
const isValidMonth = (value: Selected) => {
  const month = Number(value.m);
  return month >= 1 && month <= 12;
};

// 日の確認
const isValidDay = (value: Selected) => {
  const day = Number(value.d);
  const end = daysInMonth(value.y, value.m);
  return day >= 1 && day <= Number(end);
};

// 全ての値が正しいかどうかの確認
export const isValidDate = (selected: Selected) =>
  `${selected.y}-${("00" + selected.m).slice(-2)}-${("00" + selected.d).slice(
    -2
  )}`.match(/^\d{4}-\d{2}-\d{2}$/) !== null &&
  dayjs(`${selected.y}-${selected.m}-${selected.d}`).isValid() &&
  isValidYear(selected) &&
  isValidMonth(selected) &&
  isValidDay(selected);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

日付のフォーマットは主にカスタムフックの中での状態の更新をいい感じに行うためのヘルパー関数として定義した。
ここでは、例えば 12 月 31 日が入力されてる状態から、月を 2 月に変更したら 2 月 28 日にフォーマットするような処理を書いている。
isValidDate が false だったらそもそも処理を行わない。型アサーションをなんとかしたい。

export const transformSelected = (
  selected: Selected,
  type: YMD,
  value: string
): Selected => {
  switch (type) {
    case "y":
      return {
        ...selected,
        y: value as Year,
      };
    case "m":
      const endDate = daysInMonth(selected.y, value as Month);
      if (Number(selected.d) > Number(endDate)) {
        return {
          ...selected,
          m: value as Month,
          d: endDate.toString() as Day,
        };
      }
      if (value === "" || isValidMonth({ ...selected, m: value as Month })) {
        return {
          ...selected,
          m: value as Month,
        };
      }
      return selected;
    case "d":
      if (value === "" || isValidDate({ ...selected, d: value as Day })) {
        return {
          ...selected,
          d: value as Day,
        };
      }
      return selected;
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

型定義

型定義は型ガードによる入力値の抑制のために書く。
また、これを見ればどのような役目や入力値を想定しているかが自明になるため、それも兼ねている。

型定義は一体何を定義するのか。ここでは以下を想定している。

  • 年が 1900 年から 2100 年の間である
  • 月が 1 月から 12 月である
  • 日が 1 日から 31 日である

これらを表現するために、リテラルを定義していく。
数字は 1 から 9 までがほしいパターンと 0 から 9 までがほしいパターンが存在する。具体的には月や日などで 1 桁の数字(1 月から 9 月までだったり、1 日から 9 日までだったり)の場合は 1 の位に 0 が来ることはないので 1 から 9 までの型定義が欲しくなる。
逆にそれ以外のパターンは 0 から 9 までの数字が欲しいのでそれを定義しておく。

type oneToNine = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type zeroToNine = 0 | oneToNine;
1
2

上の型定義を使って年、月、日の型定義を行う。

import { Dayjs } from "dayjs";

export type Year =
  | `19${zeroToNine}${zeroToNine}`
  | `20${zeroToNine}${zeroToNine}`;
export type Month = `0${oneToNine}` | `1${0 | 1 | 2}`;
export type Day =
  | `0${oneToNine}`
  | `1${zeroToNine}`
  | `2${zeroToNine}`
  | `3${0 | 1}`;
1
2
3
4
5
6
7
8
9
10
11

カスタムフック

カスタムフックでは以下のことを担っている。

  • year/month/day のそれぞれの ref の管理
  • keyDown の処理
  • focus 状態の管理
  • 値の検証

引数は以下。 date 以外はオプショナル。

  • date: 初期値として渡される日付
  • onChange: 日付が変更されたときに呼ばれるコールバック関数
  • handleKeyDown: キー入力を差し込みたい時に使う、今回だと範囲選択の時に開始日付と終了日付を跨いだ処理を差し込みたい時に使う

中身で管理する値と関数は以下のようになる

  • ref: 入力フォームをまとめた div の ref
  • yearRef: 年の入力フォームの ref
  • monthRef: 月の入力フォームの ref
  • dayRef: 日の入力フォームの ref
  • focus: focus かどうかの真偽値
  • valid: 入力された日付が有効かどうかの真偽値
  • selected: 選択中の日付のオブジェクト、各 input の value に渡すためのオブジェクトで、date とは非同期で管理する
  • onFocus: 入力フォームにフォーカスが当たったときに呼ばれる関数
  • onBlur: 入力フォームからフォーカスが外れたときに呼ばれる関数
  • handleChange: 入力フォームの値が変更されたときに呼ばれる関数、正常な日時であれば selected の値を dayjs オブジェクトに変換して渡す
  • onKeyDown: 入力フォームでキー入力がされたときに呼ばれる関数、十字キーでの操作や日付の入力を管理している

上から見ていく。

ref, yearRef, monthRef, dayRef, focus, valid, selected

最初に使う値を定義する。用途は上で説明してるので割愛。

export const useInput = (
  date: Dayjs,
  onChange?: (date: Dayjs) => void,
  handleKeyDown?: (k: AllowedKeys) => void
) => {
  const ref = useRef<HTMLDivElement>(null);
  const yearRef = useRef<HTMLInputElement>(null);
  const monthRef = useRef<HTMLInputElement>(null);
  const dayRef = useRef<HTMLInputElement>(null);
  const [focus, setFocus] = useState(false);
  const [selected, setSelected] = useState<Selected>({
    y: date.format("YYYY") as Year,
    m: date.format("MM") as Month,
    d: date.format("DD") as Day,
  });
  const valid = useMemo(() => isValidDate(selected), [selected]);

  //...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

onFocus, onBlur

次に onFocus, onBlur を定義する。それぞれ focus 状態を変更するだけになっている。

export const useInput = (
  date: Dayjs,
  onChange?: (date: Dayjs) => void,
  handleKeyDown?: (k: AllowedKeys) => void
) => {
  //...

  const onFocus = useCallback(() => {
    setFocus(true);
  }, []);

  const onBlur = useCallback(() => {
    setFocus(false);
  }, []);

  //...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

handleChange

次に handleChange を定義する。これは入力フォームの値が変更されたときに呼ばれる関数で、正常な日時であれば selected の値を dayjs オブジェクトに変換して渡す。正常な値ではなくても selected の値は更新する。 理由はフォーム上の値では異常値を選択できるが handler には異常な値は渡さないという挙動を実現するため。

dateinput

const useInput = (
  date: Dayjs,
  onChange?: (date: Dayjs) => void,
  handleKeyDown?: (k: AllowedKeys) => void
) => {
  //...

  const handleChange = useCallback(
    (type: YMD) => (event: ChangeEvent<HTMLInputElement>) => {
      const { value } = event.target;
      const newValue = transformSelected(selected, type, value);
      setSelected(newValue);
      if (valid) {
        onChange &&
          onChange(dayjs(`${newValue.y}-${newValue.m}-${newValue.d}`));
      }
    },
    [selected, onChange]
  );

  //...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

onKeyDown

ここはキー入力がされたときに呼ばれる関数で、十字キーでの操作や日付の入力を管理している。
ここで満たすべき挙動は以下。

  • AllowUp で年月日のフォーカスしてる箇所のインクリメント
  • AllowDown で年月日のフォーカスしてる箇所のデクリメント
  • AllowRight で右に移動
  • AllowReft で左に移動
  • 入力できない値がきたら早期 return

たくさん条件分岐が発生するがやってることはほぼ同じなのでここでは一例を示す。
以下は AllowUp でインクリメントがされる例。それぞれのイベントが発生するたびに selected を更新する。値が正常なら onChange に渡す。

const useInput = (
  date: Dayjs,
  onChange?: (date: Dayjs) => void,
  handleKeyDown?: (k: AllowedKeys) => void
) => {
  //...

  const onKeyDown = useCallback(
    (focusType: YMD) => (event: KeyboardEvent<HTMLInputElement>) => {
      const k: AllowedKeys = event.key as AllowedKeys;

      //...

      if (k === AllowedKeys.ArrowUp) {
        event.preventDefault();
        const newValue = transformSelected(
          selected,
          focusType,
          String(Number(selected[focusType]) + 1)
        );
        setSelected(newValue);
        if (valid) {
          onChange &&
            onChange(dayjs(`${newValue.y}-${newValue.m}-${newValue.d}`));
        }
      } else if (k === AllowedKeys.ArrowDown) {
        // ...
      }
      //...
    },
    [selected]
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

実際に使うコンポーネント

ここまでくると、単一の日付を選択するコンポーネントはカスタムフックから受け取った値をそのまま共通のコンポーネントに渡すだけで実装できる。

import { Dayjs } from "dayjs";
import { FC } from "react";
import { useInput } from "./hooks";
import { CommonInput } from "./CommonInput";

type Props = {
  date: Dayjs;
  onChange?: (date: Dayjs) => void;
};

export const Input: FC<Props> = ({ date, onChange }) => {
  const {
    ref,
    yearRef,
    monthRef,
    dayRef,
    focus,
    selected,
    valid,
    handleChange,
    onFocus,
    onBlur,
    onKeyDown,
  } = useInput(date, onChange);

  return (
    <CommonInput
      ref={ref}
      date={date}
      focus={focus}
      valid={valid}
      selected={selected}
      yearRef={yearRef}
      monthRef={monthRef}
      dayRef={dayRef}
      handleChange={handleChange}
      onFocus={onFocus}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
    />
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

範囲を選択するコンポーネントは、単一の日付を選択するコンポーネントより少し複雑になる。
開始日付と終了日付のそれぞれのコンポーネントに渡す値をカスタムフックから取得するため。
また、handleStartKeyDown/handleEndKeyDown では、開始日付の日の部分にフォーカスしてる場合に AllowRight が押されたときには終了日付の年の部分にフォーカスするよう挙動を表現していて、それをカスタムフックに渡すことでそれを実現している。handleEndKeyDown はその逆。

import { Dayjs } from "dayjs";
import { Flex, Spacer, Typography } from "ingred-ui";
import { FC } from "react";
import { CommonInput } from "./CommonInput";
import { AllowedKeys } from "../constants";
import { useInput } from "./hooks";
import { Range } from "../types";

type Props = {
  date: Range;
  onChange: ({ startDate, endDate }: Range) => void;
};

export const InputRange: FC<Props> = ({ date, onChange }) => {
  const handleChangeStartDate = (newDate: Dayjs) => {
    onChange({ ...date, startDate: newDate });
  };

  const handleChangeEndDate = (newDate: Dayjs) => {
    onChange({ ...date, endDate: newDate });
  };

  const handleStartKeyDown = (k: AllowedKeys) => {
    if (k === AllowedKeys.ArrowRight) {
      endYearRef.current?.setSelectionRange(0, 0);
      setTimeout(() => {
        endYearRef.current?.focus();
      }, 0);
    }
  };

  const handleEndKeyDown = (k: AllowedKeys) => {
    if (k === AllowedKeys.ArrowLeft) {
      startDayRef.current?.setSelectionRange(
        startDayRef.current?.value.length,
        startDayRef.current?.value.length
      );
      setTimeout(() => {
        startDayRef.current?.focus();
      }, 0);
    }
  };

  // 開始日付用
  const {
    ref: startRef,
    yearRef: startYearRef,
    monthRef: startMonthRef,
    dayRef: startDayRef,
    focus: startFocus,
    selected: startSelected,
    valid: startValid,
    handleChange: handleChangeStart,
    onFocus: onFocusStart,
    onBlur: onBlurStart,
    onKeyDown: onKeyDownStart,
  } = useInput(date.startDate, handleChangeStartDate, handleStartKeyDown);

  // 終了日付用
  const {
    ref: endRef,
    yearRef: endYearRef,
    monthRef: endMonthRef,
    dayRef: endDayRef,
    focus: endFocus,
    selected: endSelected,
    valid: endValid,
    handleChange: handleChangeEnd,
    onFocus: onFocusEnd,
    onBlur: onBlurEnd,
    onKeyDown: onKeyDownEnd,
  } = useInput(date.endDate, handleChangeEndDate, handleEndKeyDown);

  return (
    <Flex display="flex">
      <CommonInput
        ref={startRef}
        date={date.startDate}
        focus={startFocus}
        valid={startValid}
        selected={startSelected}
        yearRef={startYearRef}
        monthRef={startMonthRef}
        dayRef={startDayRef}
        handleChange={handleChangeStart}
        onFocus={onFocusStart}
        onBlur={onBlurStart}
        onKeyDown={onKeyDownStart}
      />
      <Spacer pr={1} pl={1}>
        <Typography component="span" color="gray">
          -
        </Typography>
      </Spacer>
      <CommonInput
        ref={endRef}
        date={date.endDate}
        focus={endFocus}
        valid={endValid}
        selected={endSelected}
        yearRef={endYearRef}
        monthRef={endMonthRef}
        dayRef={endDayRef}
        handleChange={handleChangeEnd}
        onFocus={onFocusEnd}
        onBlur={onBlurEnd}
        onKeyDown={onKeyDownEnd}
      />
    </Flex>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

呼び出し側

単一の日付を選択する場合

import dayjs from "dayjs";
import { createTheme, ThemeProvider, Typography } from "ingred-ui";
import { useState } from "react";
import { Input } from "./Input";

function App() {
  const theme = createTheme();
  const [date, setDate] = useState(dayjs());

  return (
    <ThemeProvider theme={theme}>
      <Typography weight="bold">
        selected: {date.format("YYYY-MM-DD")}
      </Typography>
      <Input date={date} onChange={setDate} />
    </ThemeProvider>
  );
}

export default App;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

日付の範囲を選択する場合

import dayjs from "dayjs";
import { createTheme, ThemeProvider, Typography } from "ingred-ui";
import { useState } from "react";
import { InputRange } from "./Input";

function App() {
  const theme = createTheme();
  const [dateRange, setDateRange] = useState({
    startDate: dayjs(),
    endDate: dayjs().add(1, "week"),
  });

  return (
    <ThemeProvider theme={theme}>
      <Typography weight="bold">
        selected: {dateRange.startDate.format("YYYY-MM-DD")} -
        {dateRange.endDate.format("YYYY-MM-DD")}
      </Typography>
      <InputRange date={dateRange} onChange={setDateRange} />
    </ThemeProvider>
  );
}

export default App;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

demo

https://dateinput.vercel.app

まとめ

カスタムフックに処理を集めコンポーネントの責務を分離することでテストのしやすさだったり再利用性を高めることができる。
割とカスタムフックを作るという目的で始めた節があるので、大まかな設計とカスタムフックの定義を行えた点でいえば、日付コンポーネントに対する解像度が上がり、難易度の把握ができたのはよかった。
短時間ではありながら実際に手を動かしてみて難易度や温度感は把握したので、どういう実装にしようか(input[type="date"] を使うのか、自作で行くのか)はもう少し考える。