Skip to content
On this page

format つきの DateField

2023-08-05


l11n に気軽に対応できて、なおかつ DateTimeField としても使えるようなコンポーネントを考えていた。
そこで、format prop を指定すると、入力された値を指定したフォーマットで表示できると良さそうだなと思い、実際に作った。

結果として MUI の DateField の劣化版が出来上がったのだが...。

作ったもの

発想としては、input[type="text"] の value の中身を selectionStartselectionEnd でそれっぽく管理しているように見せるという手法。
そのため、input[type="text"] であればどこでも使えるし、value の管理も string で行う。(インターフェイスは dayjs である)

使い方

日付の管理は dayjs を使う。

特徴として、format prop に指定したフォーマットで日付が表示され、操作可能になる。
例えば以下のように指定すると、日付が MM-DD-YYYY の形式で表示される。

const Component = () => {
  const [date, setDate] = useState<Date | null>(dayjs());

  return <DateField value={date} onChange={setDate} format="MM-DD-YYYY" />;
};
1
2
3
4
5

datefield1

日本語にも入れることができる。例えば以下のように指定すると、日付が YYYY年MM月DD日 の形式で表示される。

const Component = () => {
  const [date, setDate] = useState<Date | null>(dayjs());

  return <DateField value={date} onChange={setDate} format="YYYY年MM月DD日" />;
};
1
2
3
4
5

datefield2

同様に、YYYY-MM-DDYYYY/MM/DD なども指定できるし、YYYY-MM-DD HH:mm:ss なんて打てば、何時何分何秒まで表示される。時間までしか指定したくなければ YYYY-MM-DD HH:00:00 とかにすればいい。

また、hooks だけでこれを再現しているので、React の HTMLInputElement であれば、どこでも使える。(一応ラッパーとして <DateField /> を作ってはいるが、useDateField さえあれば動く)

動作

基本的な動作は以下のようになっている。

  • format prop に指定したフォーマットで日付が表示される
  • 左右キーで年月日を移動できる
    • 時間まで指定していれば時間も
  • 上下キーでフォーカスしている部分のインクリメント・デクリメントができる
  • 日付を直接入力することができる
  • 日付をペーストすることができる
    • dayjs が認識できる format であれば format prop の形式に勝手に変換して入力してくれる
    • 例えば format prop が YYYY/MM/DD のとき、2021-05-07 と入力すると 2021/05/07 として入力される

実装

上にも書いてあるが、useDateField という hooks に全てが詰まっている。

基本的な処理のフローとしては以下のようになっている。

  1. 日付を props から受け取る
  2. 日付を section に分割する
  3. keydown イベントを受け取り、sections と value と date を更新する

1 つずつ見ていく。

日付を props から受け取る

useDateFielddateformatonDateChange を props から受け取る。

date は Dayjs で、日付を表す。これはこの hooks によって値が更新され、トップレベルのコンポーネントに渡される。
つまり、タイムゾーンを考慮した実装をしたい場合はユーザー側で考慮する必要がある。(大体そうだと思うが)

日付を section に分割する

date を受け取ったらそれをもとに section に分割する。
section とは、日付と記号の組み合わせをコードで保持するためのもので、string の日付を分割したものを保持する。

例えば、2023-01-02 という日付が入力されたとき、sections は以下のようになる。

[
  { "start": 0, "end": 3, "value": "2023", "editable": true },
  { "start": 4, "end": 4, "value": "-", "editable": false },
  { "start": 5, "end": 6, "value": "01", "editable": true },
  { "start": 7, "end": 7, "value": "-", "editable": false },
  { "start": 8, "end": 9, "value": "02", "editable": true }
]
1
2
3
4
5
6
7

section は以下のプロパティによって構成されている。

  • start: その section の開始位置
  • end: その section の終了位置
  • value: その section の値
  • editable: その section が編集可能かどうか

1 つずつ見ていく。

start と end

start と end を保持している理由は、section を編集する際に HTMLInputElement の setSelectionRange を使うためである。
storybook を触ればわかるが、現在のフォーカス位置は selectionStartselectionEnd によって管理されている。

value

value はそのまま表示するために使う。

editable

editable は、その section が編集可能かどうかを表す。例えば、2023-01-02 という日付が入力されたとき、- は編集できないので editablefalse になる。
編集可能とは上記の keydown イベントや上下キーによるインクリメント・デクリメントができるかどうかを表す。もちろん記号は編集できない。

補足

注目したいのは、ここで年月日等の状態は sections は保持していないということ。
ここを持とうとすると本格的なパーサが必要になるため書いていない。dayjs を使っているわけだし、onDateChange を呼ぶ際に dayjs を噛ませるため日付の変換は dayjs の仕組みに乗っかる方が楽と考えたためである。

keydown イベントを受け取り、sections と value と date を更新する

この hooks の中で 1 番泥臭い処理をしている部分がここになる。
onKeyDown によって受け取ったイベントをもとに sections を更新する。

全て書く気はないので詳細はコードを直接見てほしいが、例として上下キーで現在選択中の section の値をインクリメント・デクリメントする処理を書く。

ここでは以下の処理を踏んでいる。

  1. onKeyDown によって受け取ったイベントが上下キーであるかどうかを判定する
  2. 上下キーであれば、現在選択中の section の値をインクリメント・デクリメントする
  3. sections を更新し、日付を setValue する
  4. 日付が有効であれば onDateChange に渡す

やっていること自体はシンプルで想像がつくと思う。

export const useDateField = ({
  date,
  format = "YYYY-MM-DD",
  onDateChange,
}: Props) => {
  const [value, setValue] = useState(date.format(format)); // input の value になる
  const sectionsWithCharactor = useMemo(() => getSections(value), [value]); // フォーマット付きで日付を分割したもの
  const sections = sectionsWithCharactorToSections(sectionsWithCharactor); // 編集可能なセクションのみを抽出したもの
  // 現在選択中のセクションの位置を保持する
  const [placement, setPlacement] = useState({
    start: 0,
    end: sections.length - 1,
    current: 0,
  });

  // 他の処理
  const onKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLInputElement>) => {
      // 他の処理

      // 上下キーでインクリメント・デクリメントする
      if (
        event.key === AllowedKeys.ArrowUp ||
        event.key === AllowedKeys.ArrowDown
      ) {
        event.preventDefault();
        const i = event.key === AllowedKeys.ArrowUp ? 1 : -1;
        const newValueNumber = Number(sections[placement.current].value) + i;

        const newValue = String(newValueNumber).padStart(
          sections[placement.current].value.length,
          "0"
        );

        sections[placement.current].value = newValue;

        const v = sectionsWithCharactorToFormattedString(sectionsWithCharactor);
        const newDate = dayjs(v, format);

        setValue(newDate.format(format));

        if (newDate.isValid()) {
          onDateChange && onDateChange(newDate);
        }
      }

      // 他の処理
    }
  );

  return { ... }
};
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

まとめ

最近ちょっと手を動かした内容についてすごい雑にまとめた。
もっと詳しく知りたい人はソースコードを読んでほしいし、直接聞いてくれれば答える。

簡易的な実装になっているため、ブラッシュアップが必要になるとは思うが、とりあえずはこれで良しとする。