Appearance
format つきの DateField
2023-08-05
l11n に気軽に対応できて、なおかつ DateTimeField としても使えるようなコンポーネントを考えていた。
そこで、format prop を指定すると、入力された値を指定したフォーマットで表示できると良さそうだなと思い、実際に作った。
結果として MUI の DateField の劣化版が出来上がったのだが...。
作ったもの
発想としては、input[type="text"]
の value の中身を selectionStart
と selectionEnd
でそれっぽく管理しているように見せるという手法。
そのため、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
2
3
4
5
日本語にも入れることができる。例えば以下のように指定すると、日付が 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
2
3
4
5
同様に、YYYY-MM-DD
や YYYY/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 に全てが詰まっている。
基本的な処理のフローとしては以下のようになっている。
- 日付を props から受け取る
- 日付を section に分割する
- keydown イベントを受け取り、sections と value と date を更新する
1 つずつ見ていく。
日付を props から受け取る
useDateField
は date
、format
、onDateChange
を 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
2
3
4
5
6
7
section は以下のプロパティによって構成されている。
- start: その section の開始位置
- end: その section の終了位置
- value: その section の値
- editable: その section が編集可能かどうか
1 つずつ見ていく。
start と end
start と end を保持している理由は、section を編集する際に HTMLInputElement の setSelectionRange
を使うためである。
storybook を触ればわかるが、現在のフォーカス位置は selectionStart
と selectionEnd
によって管理されている。
value
value はそのまま表示するために使う。
editable
editable は、その section が編集可能かどうかを表す。例えば、2023-01-02
という日付が入力されたとき、-
は編集できないので editable
は false
になる。
編集可能とは上記の keydown イベントや上下キーによるインクリメント・デクリメントができるかどうかを表す。もちろん記号は編集できない。
補足
注目したいのは、ここで年月日等の状態は sections は保持していないということ。
ここを持とうとすると本格的なパーサが必要になるため書いていない。dayjs を使っているわけだし、onDateChange を呼ぶ際に dayjs を噛ませるため日付の変換は dayjs の仕組みに乗っかる方が楽と考えたためである。
keydown イベントを受け取り、sections と value と date を更新する
この hooks の中で 1 番泥臭い処理をしている部分がここになる。onKeyDown
によって受け取ったイベントをもとに sections を更新する。
全て書く気はないので詳細はコードを直接見てほしいが、例として上下キーで現在選択中の section の値をインクリメント・デクリメントする処理を書く。
ここでは以下の処理を踏んでいる。
onKeyDown
によって受け取ったイベントが上下キーであるかどうかを判定する- 上下キーであれば、現在選択中の section の値をインクリメント・デクリメントする
- sections を更新し、日付を setValue する
- 日付が有効であれば 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
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
まとめ
最近ちょっと手を動かした内容についてすごい雑にまとめた。
もっと詳しく知りたい人はソースコードを読んでほしいし、直接聞いてくれれば答える。
簡易的な実装になっているため、ブラッシュアップが必要になるとは思うが、とりあえずはこれで良しとする。