Reactで、+と−のボタンで数量を変える入力欄を作る方法

​​React​Playwright,TypeScript,アクセシビリティ,フォーム

数量を+と−のボタンで変える入力欄は、<input type="number"> の両側にボタンを置き、数量を親の state で持つと作れます。ボタンは押すたびに1ずつ変え、上限と下限で止めます。入力欄には直接打つこともできるようにし、打っている途中の文字列は別の state に分けて持ちます。

この記事では、在庫が10個の商品の数量を選ぶ QuantityStepper を作り、マウスとキーボードでの動きを確かめました。ボタンの止め方と、ボタンを Tab キーの順番から外す点は、WAI-ARIA Authoring Practices(APG)の、数量を選ぶスピンボタンの例に合わせています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、入力欄の値や小計の表示を調べました。Firefox と Safari、スマートフォンのブラウザ(タッチ操作や数字のキーボード)、スクリーンリーダーでの読み上げは試していません。

ねこ用ブラシの数量を、+で 2、3、4 に増やし、−で 3 に戻す。入力欄に 15 と打って Tab キーで離れると 10 になり、10 のときに+を押しても変わらない
7回の操作で、数量と小計が変わる様子。数字が変わった瞬間だけ、その表示がピンクになる(カーソル、押した瞬間の輪、操作の説明と回数の表示は GIF 用に重ねたもの)

コンポーネントの全体

数量は親から value で受け取り、変えるときは onChange で親に新しい数を渡します。上限と下限は max と min で受け取ります。

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

type Props = {
  label: string;
  value: number;
  onChange: (value: number) => void;
  min: number;
  max: number;
  name?: string;
};

export function QuantityStepper({ label, value, onChange, min, max, name }: Props) {
  const id = useId();
  // 入力欄に打っている途中の文字列。null のときは value をそのまま表示する
  const [draft, setDraft] = useState<string | null>(null);
  // ボタンで変えたときに、読み上げ用に output へ入れる文
  const [announce, setAnnounce] = useState('');

  const clamp = (n: number) => Math.min(max, Math.max(min, n));
  const atMin = value <= min;
  const atMax = value >= max;

  function step(delta: number) {
    const next = clamp(value + delta);
    setDraft(null);
    onChange(next);
    setAnnounce(`${label} ${next}`);
  }

  function handleInput(text: string) {
    setDraft(text);
    const n = Number(text);
    // 範囲内の整数になったときだけ、すぐに親へ伝える
    if (text.trim() !== '' && Number.isInteger(n) && n >= min && n <= max) onChange(n);
  }

  function commit() {
    if (draft === null) return;
    const n = Number(draft);
    // 範囲の外の整数は端にそろえる。空や整数でないときは、最後の正しい値に戻す
    if (draft.trim() !== '' && Number.isInteger(n)) onChange(clamp(n));
    setDraft(null);
  }

  return (
    <div className="stepper">
      <label htmlFor={id}>{label}</label>
      <div className="stepper-controls">
        <button
          type="button"
          aria-label={`${label}を1減らす`}
          aria-controls={id}
          aria-disabled={atMin}
          tabIndex={-1}
          onClick={() => !atMin && step(-1)}
        >
          −
        </button>
        <input
          id={id}
          type="number"
          inputMode="numeric"
          min={min}
          max={max}
          step={1}
          name={name}
          value={draft ?? String(value)}
          onChange={(e) => handleInput(e.target.value)}
          onBlur={commit}
          onKeyDown={(e) => {
            if (e.key === 'Enter') commit();
          }}
        />
        <button
          type="button"
          aria-label={`${label}を1増やす`}
          aria-controls={id}
          aria-disabled={atMax}
          tabIndex={-1}
          onClick={() => !atMax && step(1)}
        >
          +
        </button>
      </div>
      <output htmlFor={id} className="visually-hidden">
        {announce}
      </output>
    </div>
  );
}

使う側では、数量を useState で持ち、小計の計算にも使います。

tsx
import { useState } from 'react';
import { QuantityStepper } from './QuantityStepper';

const PRICE = 1200;
const STOCK = 10;

export function App() {
  const [quantity, setQuantity] = useState(1);
  const [sent, setSent] = useState('');

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        setSent(JSON.stringify(Object.fromEntries(new FormData(e.currentTarget))));
      }}
    >
      <p className="item">ねこ用ブラシ({PRICE.toLocaleString('ja-JP')}円)</p>
      <QuantityStepper label="数量" value={quantity} onChange={setQuantity} min={1} max={STOCK} name="quantity" />
      <p className="note">在庫は{STOCK}個です</p>
      <p className="total">
        小計 <span className="amount">{(PRICE * quantity).toLocaleString('ja-JP')}円</span>
      </p>
      <button type="submit">カートに入れる</button>
      {sent && <p className="sent">送信された値: {sent}</p>}
    </form>
  );
}

ここからは、この形にした理由を、確かめた結果と合わせて説明します。

上限・下限のボタンは disabled ではなく aria-disabled で止める

数量が下限の 1 のときは−を、上限の 10 のときは+を押せないようにします。このとき、ボタンに disabled を付けるのではなく、aria-disabled を付けて、onClick の中で何もしないようにしています。見た目は CSS の [aria-disabled='true'] で灰色にしています。

disabled にしない理由は、押したボタンが端で無効になると、そのボタンからフォーカスが外れるためです。MDN の disabled 属性の説明のとおり、disabled のボタンはフォーカスを受けられません。比べるために disabled で止める書き方も作り、+を9回クリックして 10 にしたところ、フォーカスはボタンから外れて body に移りました。

text
マウスで9回クリックしたあと 入力欄: 10 / フォーカスがある要素: body

aria-disabled の書き方では、10 のときに+を押してもフォーカスは+のボタンに残りました。

text
10 のときに+をクリック: 入力欄「10」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
フォーカスがある要素: 数量を1増やす のボタン

MDN の aria-disabled の説明にあるとおり、aria-disabled はフォーカスできるかどうかを変えず、クリックも止めません。そのため、onClick で !atMin && step(-1) のように、端では何もしないようにしておく必要があります。Playwright で読んだアクセシビリティツリーでは、1 のときの−のボタンは [disabled] として出ました。

text
- text: 数量
- button "数量を1減らす" [disabled]: −
- spinbutton "数量": "1"
- button "数量を1増やす": +
- status

Playwright でテストを書くときは、aria-disabled="true" のボタンに click() すると、押せるようになるのを待ち続けてタイムアウトします。Playwright のドキュメントにある、操作の前に要素が押せる状態かを確かめる仕組みで、aria-disabled="true" の要素は無効とみなされるためです。端で押しても変わらないことを確かめるには、click({ force: true }) で押します。

ボタンは Tab キーの順番から外し、矢印キーで増減する

2つのボタンには tabIndex={-1} を付けています。APG の例では、キーボードでは入力欄の矢印キーで同じことができるので、ボタンは Tab キーの順番から外す、と説明されています。一方で、タッチや音声で操作する支援技術から使えるように、ボタンはフォーカスできるまま残す、とも説明されています。検証環境では、tabIndex={-1} のボタンもマウスでクリックすれば押せました。Tab キーを押すと、フォーカスは入力欄の次に「カートに入れる」に移りました。

<input type="number"> は、上矢印キーと下矢印キーで値を step ずつ変えます。値はブラウザが min と max の間に抑えるので、onChange で受け取って親に渡すだけで済みます。

text
ArrowUp: 入力欄「2」 / 小計 2,400円 / 減らす aria-disabled=false / 増やす aria-disabled=false
ArrowUp: 入力欄「3」 / 小計 3,600円 / 減らす aria-disabled=false / 増やす aria-disabled=false
ArrowDown: 入力欄「2」 / 小計 2,400円 / 減らす aria-disabled=false / 増やす aria-disabled=false
ArrowUp を12回: 入力欄「10」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
ArrowDown を12回: 入力欄「1」 / 小計 1,200円 / 減らす aria-disabled=true / 増やす aria-disabled=false

APG のスピンボタンの説明では、Home キーで最小値に、End キーで最大値にすることも挙げられています。Chromium 153 の <input type="number"> では、値が 5 のときに PageUp・PageDown・End・Home を押しても、値は 5 のまま変わりませんでした。この記事のコンポーネントでは、これらのキーには対応していません。

なお、MDN の input type="number" の説明では、数を増やしたり減らしたりする操作が要らない入力欄(郵便番号など)には type="number" を使わず、type="text" に inputmode="numeric" を付けるなど、別の type を使うよう勧めています。数量は1ずつ増減する数なので、ここでは type="number" を使いました。そのうえで inputMode="numeric" も付けているのは、スマートフォンで数字のキーボードを出すように伝えるためです。スマートフォンでの表示は試していません。

打っている途中の文字列は、別の state で持つ

入力欄に直接打つ場合は、値をすぐに数に直すと困ります。全部消した瞬間や、範囲の外の数を打っている途中も、入力欄にはそのとおり表示したいからです。そこで、打っている途中の文字列を draft という state に持ち、入力欄には draft ?? String(value) を表示しています。

React の onChange は1文字打つごとに呼ばれます。打った文字列が範囲内の整数になったときは、その場で親の onChange に伝えます。入力欄から離れたとき(onBlur)や Enter キーを押したときは、範囲の外の整数を上限か下限にそろえ、空や小数なら最後の正しい値に戻します。どちらの場合も draft を null に戻すので、入力欄の表示は親の value にそろいます。検証環境で打った結果は次のとおりです。

text
「15」を打つ
  「1」を打つ: 入力欄「1」 / 小計 1,200円 / 減らす aria-disabled=true / 増やす aria-disabled=false
  「5」を打つ: 入力欄「15」 / 小計 1,200円 / 減らす aria-disabled=true / 増やす aria-disabled=false
Tab で離れる: 入力欄「10」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
全部消す
  Backspace: 入力欄「」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
Tab で離れる: 入力欄「10」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
「0」を打つ
  「0」を打つ: 入力欄「0」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
Tab で離れる: 入力欄「1」 / 小計 1,200円 / 減らす aria-disabled=true / 増やす aria-disabled=false

直前の数量は 5(小計 6,000円)でした。「15」と打つと、1文字目の「1」の時点で範囲内の整数になるので、小計がいったん 1,200円になります。そのあと「15」は範囲の外なので小計は変わらず、入力欄から離れたときに 10 にそろいました。打っている途中に小計が動くのを避けたいなら、親に伝えるのを onBlur と Enter のときだけにする書き方も考えられます。この書き方は試していませんが、コードから考えると、矢印キーで変えた値も入力欄から離れるまで小計に出なくなるはずです。

「3.5」と打ったときは、入力欄から離れると 3 に戻りました。「3」の時点で 3 が親に伝わっていて、3.5 は整数ではないので、最後の正しい値の 3 が残ったためです。Chromium 153 では、type="number" の入力欄に「e」だけを打てて、そのときの入力欄の value は空の文字列になりました。入力欄から離れると、最後の正しい値の 3 に戻りました。MDN の説明にあるとおり、数として正しくない文字を打てるかどうかはブラウザによって違います。

「15」と打った状態で Enter キーを押すと、フォームの送信も起きます。検証環境では、送信された値は 15 ではなく、そろえたあとの 10 でした。

text
「15」を打つ: 入力欄「15」 / 小計 1,200円 / 減らす aria-disabled=true / 増やす aria-disabled=false
Enter: 入力欄「10」 / 小計 12,000円 / 減らす aria-disabled=false / 増やす aria-disabled=true
送信された値: {"quantity":"10"}

「15」と打ったまま+のボタンをクリックした場合も、入力欄は 10 になり、11 にはなりませんでした。クリックで入力欄からフォーカスが外れたときに 10 にそろい、そのあとの+は上限で止まったためです。

ボタンで変えた数量を、output で読み上げに伝える

ボタンを押しても、フォーカスは入力欄に移りません。そのため、変わった数量がスクリーンリーダーに伝わるように、見た目には隠した <output> に「数量 3」のような文を入れています。APG の例では、ブラウザが ariaNotify() に対応していればそれを使い、対応していないときは見えない output に値を入れています。output は status のロールを持ち、中身が変わると読み上げられる、と説明されています。

検証環境では、+と−をクリックするたびに output の中身が変わり、入力欄に打ったときは変わりませんでした。実際に読み上げられるかは、スクリーンリーダーで試していないため確かめていません。

text
最初の output: ""
+をクリックしたあとの output: "数量 2"
+をクリックしたあとの output: "数量 3"
−をクリックしたあとの output: "数量 2"
入力欄に 7 を入れたあとの output: "数量 2"

APG の例では、値を入れてから 2,000ミリ秒後に output を空にしています。この記事のコンポーネントは output を空に戻していません(空に戻す書き方は試していません)。そのため、前と同じ文がもう一度入る場合があります。入力欄で 1 にしてから+を押すと、前の文と同じ「数量 2」が入り直すだけで、output の DOM は1回も変わりませんでした。

text
入力欄で 1 にしてから+をクリック(output は 数量 2 のまま): "数量 2" / output の変更の回数: 0
もう一度+をクリック: "数量 3" / output の変更の回数: 1

DOM が変わらない以上、この場合は変化として読み上げられないのではないか、と推測しています(スクリーンリーダーでは試していません)。

このコンポーネントのままでは、前にボタンで伝えたのと同じ数量になったとき、output の中身は変わりません。APG の例に合わせて、値を入れてから 2,000ミリ秒後に output を空にする処理を足すなら、次の順で確かめてください。上の検証では、output の変更の回数を MutationObserver で数えています。同じように数えながら、ページを開いた直後(数量 1)に+をクリックして「数量 2」を入れ、2,000ミリ秒以上待ってから入力欄で 1 にし、もう一度+をクリックします。2回目のクリックの直前と直後で回数を比べ、増えていれば、同じ文でも output の中身が一度入れ替わったことになります。そのうえで、実際に読み上げられるかをスクリーンリーダーで確かめてください。

​Posted by 86