MUI Autocomplete で Google Calendar ライクな サジェスト機能のついた時間入力フォームを作る


こんにちは、フロントエンドエンジニアの茶木です。
Google Calendar の編集ページの時間入力がとても親切なので、近いものを作ろうと思います。

Google Calendar

まず、Google Calendar の編集ページの挙動を見てみます。

Google Calendar 入力フォームキャプチャ
  • 開始時間・終了時間共通
    • input を選択すると select が表示される
    • select は 15分、30分刻みで候補を表示し、候補を選択しての入力も可能
    • input に時間とみなせる内容を入力すると、セレクトに近い時間を表示する
    • input に時間とみなせない内容を入力して、input を離れるとエラーを表示する
    • input を空にして、input を離れると直前の入力に戻す
  • 開始時間
    • セレクトの表示は 午前・午後の開始時間
    • 午前12:00(午前0:00)から午後11:45までの、15分刻み
  • 終了時間
    • セレクトの表示は 午前・午後の開始時間と所要時間
    • セレクトの候補は 開始時間から30分刻みで、23.5時間後まで。ただし0分後の次に15分後の選択肢がある
    • 開始時間を変更すると、所要時間を保ったまま、終了時間が変更される
  • 細則
    • input は24時間表記、午前・午後(もしくは AM・PM)の12時間表記の入力が可能
    • 終了時間が翌日になる場合は、日付も変更する

今回は、開始時間の入力を作成したいと思います。また、細則に関しては実装をしません。

MUI Autocomplete

https://mui.com/material-ui/react-autocomplete/

実装に当たっては、Menu コンポーネントや、Popover コンポーネントも吟味しましたが、 input と select フィールドの切り替えまわりが、機能に含まれる Autocomplete が今回求めるものに近いのでこちらを使用します。Autocomplete は過去にブログで取り上げているのでそちらも参考にしてください。

参考

完成品

実装

呼び出し

// 呼び出し
export default function Index() {
  const [timestamp, setTimestamp] = useState(new Date("2024/02/02 10:05").getTime());
  return (
    <TimeInput timestamp={timestamp} setTimestamp={setTimestamp} />
  );
}

外部から timestamp を渡すようにします。今回は実装しませんが、日付の管理をすることや、終了時間の変更で開始時間を変更するケースが考えられるためです。

便利関数

import { format, setHours, setMinutes } from "date-fns";

// 秒、ミリ秒を丸める
function createTimestampAroundSecand(timestamp: number) {
  return timestamp - (timestamp % (1000 * 60));
}

// timestamp から "HH:mm" の形式を取得する
export function formatHHMM(timestamp: number) {
  return format(new Date(timestamp), "HH:mm");
}

// テキストから時間と分を取得する
export function separateHHMM(label: string) {
  const [hh = 0, mm = 0] = label.split(":").map((v) => Number(v.trim()));
  if (hh < 0 || hh >= 24) return null;
  if (mm < 0 || mm >= 60) return null;
  return { hh, mm };
}

// テキストを時間と分とみなして、新たな timestamp を作る
export function createTimestamp(timestamp: number, label: string): number {
  const sep = separateHHMM(label);
  if (!sep) return NaN;
  const { hh, mm } = sep;
  return setMinutes(
    setHours(new Date(createTimestampAroundSecand(timestamp)), hh),
    mm
  ).getTime();
}

変換やフォーマットを扱うメソッドです。date-fns を使って時間操作を行っています。

コンポーネント

import * as React from "react";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import { useEffect, useState } from "react";
import { Tooltip } from "@mui/material";
import { createTimestamp, formatHHMM, separateHHMM } from "./config";

const options = [...new Array(24 * 4)].map((_, i) => {
  const hh = Math.floor(i / 4);
  const mm = (i % 4) * 15;
  return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
});

interface Props {
  setTimestamp: (timestamp: number) => void;
  timestamp: number;
}

export function TimeInput({ setTimestamp, timestamp }: Props) {
  const label = formatHHMM(timestamp);
  const [text, setText] = useState(label);
  const [invalid, setInvalid] = useState(false);
  useEffect(() => {
    setText(formatHHMM(timestamp));
  }, [timestamp]);

  return (
    <Tooltip title="無効な値です" open={invalid}>
      <Autocomplete
        disablePortal
        placeholder="hh:mm"
        id="time-input"
        options={options}
        freeSolo={true}
        filterOptions={(options, _state) => options}
        value={text}
        isOptionEqualToValue={(option, value) => {
          const o = separateHHMM(option);
          const v = separateHHMM(value);
          return o.hh === v.hh && v.mm - o.mm >= 0 && v.mm - o.mm < 15;
        }}
        onInputChange={(_, label) => {
          setText(label);
          setInvalid(false);
        }}
        onChange={(_, v) => {
          setInvalid(false);
          setText(v || "");
        }}
        onBlur={() => {
          setInvalid(false);
          if (!text) {
            // 元の表記に戻す
            setText(formatHHMM(timestamp));
            return;
          }
          const newTimestamp = createTimestamp(timestamp, text);
          if (isNaN(newTimestamp)) {
            setInvalid(true);
            return;
          }
          setText(formatHHMM(newTimestamp));
          setTimestamp(newTimestamp);
        }}
        renderInput={(params) => <TextField {...params} />}
      />
    </Tooltip>
  );
}

今回の集大成です。コンポーネントです。TextFieldTooltip は MUI のものを使っています。

解説

通常の Autocompete の使い方から離れる箇所を説明します。

サジェストでの絞り込みをOFFにする

デフォルトでは、サジェストは options に設定したリストのアイテムと入力内容の部分一致があるものを表示しますが、入力に関わらず “00:00” から “23:45” まで 15分間隔のアイテムがすべて表示されるようにしたいため絞り込みを OFFにします。

`filterOptions` は inputの内容からリストに表示するものを絞り込むルールを設定するものです。

filterOptions={(options, _state) => options}

通常、第2引数で 第1引数 をフィルタリングしますが、inputに関わらず全通しします。

サジェスト以外の時間の入力も受け付けるようにする

デフォルトでは サジェスト以外の入力値とすることができません。
サジェストは “00:00” から “23:45” まで 15分間隔の値なので、 “00:03” のような入力を許容する必要があります。

freeSolo={true}

freeSolo を指定すると、サジェスト以外の入力もOKになります。

サジェストから入力に近い位置にスクロールする

Google Calendar では、たとえば、”23:” と入力した時点で、”23:00″ までスクロールします。

Autocomplete コンポーネントはデフォルトで、value プロパティに “一致” したものはリスト内でハイライトされ、その位置にスクロールするので、この挙動を入力途中のものに適用します。

“一致” の判定は isOptionEqualToValue プロパティで行えます。

value={text}
isOptionEqualToValue={(option, value) => {
  const o = separateHHMM(option);
  const v = separateHHMM(value);
  return o.hh === v.hh && v.mm - o.mm >= 0 && v.mm - o.mm < 15;
}}

上記のように、時間の一致および、14分までの差異のアイテムを一致したと判定するようにします。

そのほか

onInputChange はキーボードからの入力、 onChange はリストから選択したときに発生します。キーボードからの入力は、入力途中なのか完了なのか判断がつかないため、 onBlur をトリガーに、自由入力の完了とみなし、その時点で、妥当な時間入力かどうかを判別しています。

未実装&改良ポイント

入力値の時間としての解釈

未入力の分を”00″と解釈する機能までは入っているが、”23:1″ と入れたとき “23:01” と解釈するが、リストでスクロールされるべき位置は “23:15” が妥当。

また、Google Calendar では “AM” や “午後” “2355” といった入力も受け付ける賢い入力になっている。

ハイライトとスクロールはわけたい

ハイライト位置にスクロールされるのを利用して、候補のアイテムまでスクロールしているが、”23:16″ と入力したときに、”23:15″ がハイライトされるのは挙動が違う。スクロールだけで良いはず。

おわりに

Google Calendar はこれに加えて日付と終了時間の連動をしています。快適な入力のためによく考えられていると感じました。

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

タグ


投稿者 Chaki Hironori

webライターもやってるフロントエンドエンジニアです。Reactは自信があります。またデザイン畑の出身で、気持ちのいいアニメーションやインタラクティブな表現は丁寧に手掛けます。好きなものは中南米の遺跡で、スペイン語が少しできます。