Reactで、時刻を選ぶ入力欄を作る方法

​​React​HTML,Playwright,TypeScript,フォーム

時刻を選ぶ入力欄は、<input type="time"> に value と onChange を渡し、"10:30" のような文字列を state に持てば作れます。何分単位で選ばせるかは step、選べる時間帯は min と max で決まり、MDN の「時刻を必須にする」の節にあるとおり、範囲の外の時刻はブラウザが送信を止めます。

ただし、検証環境の Chromium では、step で15分単位にしても、ピッカーの分の一覧には1分ずつの時刻が並びました。選べる時刻をはっきり決めたいときのために、select で選択肢を並べる部品も作って比べています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。ピッカーの中の時刻をクリックして選ぶ操作は試しておらず、ピッカーの見え方はスクリーンショットで確かめています。Firefox、Safari、スマートフォンのブラウザ、スクリーンリーダーでは試していません。

時の欄をクリックして「1007」と打つと、下の文が「15分単位で選んでください」になる。分の欄で ↑ を押すと 10:15 になり、「10:15 を選んでいます」に変わる。時計のアイコンをクリックすると、分の列に 15、16、17…と1分ずつの一覧が出る
時刻を打つ、分を ↑ で変える、ピッカーを開く、の3つの操作。下の文は、変わった瞬間だけ太字のピンクになる。エラーの文は、その後も細字のピンクで残る(上の帯、カーソル、クリックの輪は GIF 用に重ねたもの)

input type="time" の値を、文字列の state で持つ

TimeInput は、見出し(label)、フォームで送るときの名前(name)、選べる範囲(min、max)、何分単位か(stepMinutes)を受け取ります。入力欄の下には、選んでいる時刻か、直してほしい点を出します。

tsx
import { useId, useState } from 'react';

type Props = {
  label: string;
  name: string;
  min?: string; // "09:00" の形
  max?: string;
  stepMinutes?: number; // 何分単位で選ばせるか
  required?: boolean;
};

export function TimeInput({ label, name, min, max, stepMinutes = 1, required }: Props) {
  const id = useId();
  const [value, setValue] = useState('');
  const [error, setError] = useState('');

  function check(input: HTMLInputElement) {
    const v = input.validity;
    if (v.stepMismatch) return `${stepMinutes}分単位で選んでください`;
    if (v.rangeUnderflow || v.rangeOverflow) return `${min}〜${max}の間で選んでください`;
    return '';
  }

  return (
    <div className="time-field">
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        type="time"
        name={name}
        value={value}
        min={min}
        max={max}
        step={stepMinutes * 60}
        required={required}
        aria-describedby={`${id}-msg`}
        aria-invalid={error ? true : undefined}
        onChange={(e) => {
          setValue(e.target.value);
          setError(check(e.target));
        }}
      />
      <p id={`${id}-msg`} className={error ? 'error' : undefined}>
        {error || (value ? `${value} を選んでいます` : '未選択')}
      </p>
    </div>
  );
}

state の初期値は空文字列にします。React の input のリファレンスにあるとおり、値を管理する入力欄に渡す value は、undefined や null にしてはいけません。

value には、画面の表示の形にかかわらず、24時間制の "HH:mm" が入ります。MDN の input type="time" のページにも、そう書かれています。検証でも、日本語の設定で起動した Chromium で「1430」と打った場合と、英語の設定で起動した Chromium で「0230P」と打った場合(画面は「02:30 PM」)のどちらも、value は "14:30" でした。

上は日本語の設定で起動した Chromium で「14:30」と表示され、下は英語の設定で起動した Chromium で「02:30 PM」と表示されている。どちらも入力欄の下の文は「14:30 を選んでいます」
同じ入力欄に 14:30 を入れたときの見え方。表示は違っても、value はどちらも "14:30"

MDN の「時刻値の書式」の節によると、画面の表示の形は、利用者のロケールかブラウザによって選ばれます。Chromium の起動オプションに --lang=ja-JP を渡し、Playwright の locale も ja-JP にしても(navigator.language は ja-JP)、入力欄に午前・午後を選ぶ欄が残り、12時間制のままでした。Chromium を起動するときに環境変数 LANG も ja_JP.UTF-8 にすると、時と分だけの24時間制になりました。ブラウザの言語の設定だけでは表示の形が決まらなかったので、どちらの形で表示されても困らないように、処理には value の "HH:mm" だけを使います。

step に秒の数を渡して、15分単位にする

step は秒で数えます。stepMinutes * 60 にしているのはそのためで、15分単位なら step={900} になります。MDN の step の説明によると、何も付けないときは60(1分)です。

検証に使った画面では、TimeInput を次のように置きました。

tsx
<TimeInput label="来店の時刻" name="visit" min="09:00" max="18:00" stepMinutes={15} required />

この入力欄で、時刻を打ったときの validity は次のとおりです。

打った時刻 入力欄の状態 下に出る文
10:30 問題なし 10:30 を選んでいます
10:07 stepMismatch が true 15分単位で選んでください
08:30 rangeUnderflow が true 09:00〜18:00の間で選んでください
18:30 rangeOverflow が true 09:00〜18:00の間で選んでください

キーボードでは15分単位でない時刻も打てて、value には "10:07" が入りました。検証の画面では、フォームの onSubmit で送られる値を表示するようにしています。"10:07" が入った状態で送信ボタンを押すと、onSubmit は呼ばれず、フォーカスが入力欄に移りました。10:30 なら、{"visit":"10:30",...} のように送られます。

分の欄で ↑ と ↓ を押したときは、step どおりに15分ずつ動きました。

text
10:00 から: ArrowUp→10:15 ArrowUp→10:30 ArrowUp→10:45 ArrowUp→10:00 ArrowDown→10:45

10:45 で ↑ を押すと、11:00 ではなく 10:00 に戻ります。分の欄の中だけで回り、時の欄は変わりません。

一方、時計のアイコンをクリックして開くピッカーは、step を付けていても、分の列に1分ずつの一覧を出しました(上の GIF の3回目の操作)。10:15 を選んでいる状態で開くと、分の列は 15、16、17…と続きます。ピッカーの一覧に 10:16 のような時刻も並ぶので、15分単位で選ばせたい画面では、入力欄の下の文で知らせることが欠かせません。

日付をまたぐ時間帯は、min を max より後にする

min に max より後の時刻を渡すと、日付をまたぐ時間帯になります。MDN の「最小値と最大値が真夜中をまたぐようにする」の節にあるとおり、これは time の入力欄だけにある決まりです。

tsx
<TimeInput label="夜間の配達" name="night" min="22:00" max="06:00" stepMinutes={30} />

検証環境の Chromium は、22:00 から翌朝 6:00 までを正しい時刻として扱いました。日本語の設定で起動していても、validationMessage は英語でした。

打った時刻 入力欄の状態 validationMessage
23:00 問題なし Value must be 6:00 or earlier.
05:00 問題なし Value must be 22:00 or later.
12:00 rangeUnderflow と rangeOverflow が両方 true Value must be between 22:00 and 6:00.
23:15 stepMismatch が true Value must be 6:00 or earlier.

validity は期待どおりですが、validationMessage は合っていません。問題のない 23:00 にも「Value must be 6:00 or earlier.」(6:00 以前にしてください)という文が入り、30分単位でない 23:15 にも、範囲についての文が入っています。TimeInput で validationMessage を使わず、validity の項目を見て自分で文を作っているのはこのためです。

23:15 が stepMismatch になるのは、MDN の step の説明にあるとおり、min が段階の基準になるからです。22:00 から30分ずつの 22:30、23:00…が正しい時刻になります。

注意

validationMessage は、日付をまたぐ範囲では、問題のない時刻にも文が入っていました。「validationMessage が空でなければエラー」という判定にすると、23:00 のような正しい時刻までエラーとして表示されます。正しいかどうかは validity.valid か、validity の個々の項目で判定してください。

MDN の「step 属性の使用」の節によると、step を60で割り切れない数にすると、秒の欄が出ます。step="1" の入力欄で「103000」と打つと、value は "10:30" ではなく "10:30:00" になりました。時刻どうしを比べるときは、文字列のままではなく、分に直してから比べると、秒の有無に左右されません。

ts
// "HH:mm" の文字列を、0時からの分に直す
export function toMinutes(time: string): number {
  const [h, m] = time.split(':').map(Number);
  return h * 60 + m;
}

toMinutes('10:30') と toMinutes('10:30:00') は、どちらも 630 になりました。

選べる時刻を決めたいときは、select で並べる

15分単位の時刻から選ばせたいときは、時刻の入力欄をやめて、select に選べる時刻だけを並べる方法があります。10:07 のような時刻は、選択肢に出てきません。

まず、min から max まで、決まった分ごとの時刻の一覧を作る関数です。

ts
const pad = (n: number) => String(n).padStart(2, '0');

// min から max まで、stepMinutes ごとの時刻の一覧を作る。
// max が min より前なら、日付をまたぐ範囲(22:00〜06:00 など)として扱う
export function timeSlots(min: string, max: string, stepMinutes: number): string[] {
  const start = toMinutes(min);
  let end = toMinutes(max);
  if (end < start) end += 24 * 60;
  const slots: string[] = [];
  for (let t = start; t <= end; t += stepMinutes) {
    const m = t % (24 * 60);
    slots.push(`${pad(Math.floor(m / 60))}:${pad(m % 60)}`);
  }
  return slots;
}

max が min より前のときは24時間を足して、time の入力欄と同じく日付をまたぐ範囲として扱います。Node.js で実行した結果です。

text
timeSlots('09:00', '18:00', 15): 37 個 09:00 09:15 09:30 … 17:45 18:00
timeSlots('22:00', '06:00', 30): 22:00 22:30 23:00 23:30 00:00 00:30 01:00 01:30 02:00 02:30 03:00 03:30 04:00 04:30 05:00 05:30 06:00

この一覧を option にして並べます。先頭には、まだ選んでいない状態を表す、value が空の「選んでください」を置きます。

tsx
import { useId, useMemo, useState } from 'react';
import { timeSlots } from './timeSlots';

type Props = {
  label: string;
  name: string;
  min: string;
  max: string;
  stepMinutes: number;
  required?: boolean;
};

export function TimeSelect({ label, name, min, max, stepMinutes, required }: Props) {
  const id = useId();
  const [value, setValue] = useState('');
  const slots = useMemo(() => timeSlots(min, max, stepMinutes), [min, max, stepMinutes]);

  return (
    <div className="time-field">
      <label htmlFor={id}>{label}</label>
      <select id={id} name={name} value={value} required={required} onChange={(e) => setValue(e.target.value)}>
        <option value="">選んでください</option>
        {slots.map((t) => (
          <option key={t} value={t}>
            {t}
          </option>
        ))}
      </select>
    </div>
  );
}

<TimeSelect label="受け取りの時刻" name="pickup" min="09:00" max="18:00" stepMinutes={15} /> と置くと、選択肢は「選んでください」と 09:00 から 18:00 までの37個、合わせて38個になりました。10:45 を選んで送信すると、"pickup":"10:45" が送られます。value の形は time の入力欄と同じ "HH:mm" なので、受け取る側の処理はどちらでも変わりません。

time の入力欄は、キーボードで数字を打てるので、時刻をすばやく入れたい画面に向いています。予約の枠のように、選べる時刻が決まっている画面では、select で並べるほうが、選べない時刻をそもそも見せずに済みます。

​Posted by 86