Reactで、星の数で評価する入力欄を作る方法

​​React​CSS,HTML,Playwright,TypeScript,アクセシビリティ

星の数で評価する入力欄は、星1つごとに <input type="radio"> を置き、radio ボタンそのものは見えないように隠して、label の中の「★」を見せる形で作れます。矢印キーで選ぶ操作、フォームで送られる値、required での必須の確認はブラウザに任せられるので、React 側で持つのは「選んだ星の数」と「マウスを乗せている星」の2つの state だけです。

ライブラリを使わずに StarRating という部品を作り、マウス、キーボード、フォームの送信での動きを確かめました。

補足

検証環境は 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 は試していません。読み上げに使われる情報は、Playwright でアクセシビリティツリーを書き出して確かめています。

何も選んでいない状態で、星の上をマウスで左から右へなぞると星が順に色付き、4つ目をクリックすると「選んだ値:4」になり、左矢印キーを押すと3になる
マウスを乗せる、4つ目をクリックする、← キーを押す、の3つの操作。「選んだ値」の数字は、変わった瞬間だけピンクになる(数字のピンク、カーソル、クリックの輪、キー、操作の回数の表示は GIF 用に重ねたもの)

星1つを、radio ボタン1つにする

StarRating は、フォームで送るときの名前(name)、入力欄の見出し(label)、星の数(max)などを受け取ります。全体を fieldset で囲み、legend に見出しを書きます。星は1つずつ label で包み、その中に radio ボタンと「★」を入れます。

tsx
import { useState } from 'react';

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

export function StarRating({ name, label, max = 5, defaultValue = 0, required, onChange }: Props) {
  const [value, setValue] = useState(defaultValue);
  // マウスを乗せている星の番号(乗せていないときは 0)
  const [hover, setHover] = useState(0);
  const shown = hover || value;

  return (
    <fieldset className="star-rating" onMouseLeave={() => setHover(0)}>
      <legend>{label}</legend>
      {Array.from({ length: max }, (_, i) => {
        const n = i + 1;
        return (
          <label key={n} className={n <= shown ? 'star is-on' : 'star'} onMouseEnter={() => setHover(n)}>
            <input
              type="radio"
              className="visually-hidden"
              name={name}
              value={n}
              checked={value === n}
              required={required}
              onChange={() => {
                setValue(n);
                onChange?.(n);
              }}
            />
            <span aria-hidden="true">★</span>
            <span className="visually-hidden">星{n}つ</span>
          </label>
        );
      })}
    </fieldset>
  );
}

radio ボタンは、すべてに同じ name を付けると1つのグループになり、どれかを選ぶとほかの選択が外れます。この仕組みは MDN の radio ボタンの解説にあります。星の数を「どれか1つを選ぶ」入力として扱えるので、選んだ星までを点灯させる処理だけを React で書けば済みます。

checked に state との比較を渡しているので、この radio ボタンは React が値を管理する「制御されたコンポーネント」です。React の公式リファレンスにある input の説明のとおり、checked を渡すときは、state を更新する onChange も一緒に渡します。

「★」の文字は aria-hidden="true" で読み上げの対象から外し、代わりに見えない文字で「星3つ」のような名前を付けています。Playwright でアクセシビリティツリーを書き出すと、次のように「この記事の評価」というグループの中に、名前の付いた radio ボタンが5つ並びました。

text
- group "この記事の評価":
  - text: この記事の評価
  - radio "星1つ"
  - text: 星1つ
  - radio "星2つ"
  - text: 星2つ
  - radio "星3つ"
  - text: 星3つ
  - radio "星4つ"
  - text: 星4つ
  - radio "星5つ"
  - text: 星5つ

radio ボタンは display: none で隠さない

radio ボタンの丸を見せないためのクラス .visually-hidden は、大きさを1pxにして切り抜く書き方です。画面からは消えますが、要素は残るので、フォーカスを受け取れ、アクセシビリティツリーにも残ります。見た目を整える行の一部(body と legend の余白)は省いています。

css
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.star-rating {
  display: flex;
  gap: 4px;
  border: none;
  padding: 0;
  margin: 0;
}
.star {
  position: relative;
  font-size: 40px;
  line-height: 1;
  color: #c8d0d4;
  cursor: pointer;
}
.star.is-on {
  color: #f5b301;
}
.star:has(:focus-visible) {
  outline: 3px solid #067a98;
  outline-offset: 2px;
  border-radius: 4px;
}

比べるために、radio ボタンを display: none で隠した版も試しました。4つ目の星をクリックすると、値は4になり、マウスでは問題なく選べます。ところが、アクセシビリティツリーからは radio ボタンが消え、グループの中は文字だけになりました。Tab キーを押すと、星を飛ばして送信ボタンにフォーカスが移りました。

text
- group "この記事の評価": この記事の評価 星1つ 星2つ 星3つ 星4つ 星5つ

MDN の display の説明にも、display: none の要素はアクセシビリティツリーから取り除かれ、スクリーンリーダーで読み上げられなくなると書かれています。

注意

display: none で隠すと、マウスで試しているかぎりは普通に動くため、キーボードで選べないことに気づきにくくなります。radio ボタンを隠すときは、上の .visually-hidden のように、要素を残したまま見えなくする書き方にしてください。

マウスを乗せている間だけ、星の数を変えて見せる

点灯させる星の数は、hover || value で決めています。マウスを乗せている星があればその番号、なければ選んだ値です。label の onMouseEnter で番号を覚え、fieldset の onMouseLeave で 0 に戻します。

検証環境で試したところ、何も選んでいない状態で4つ目に乗せると星は4つ点灯し、マウスを外すと0に戻りました。3つ目をクリックしたあとに5つ目へ乗せると5つ点灯し、外すと選んだ3に戻りました。乗せている間も、選ばれている radio ボタンは3のままです。操作ごとの結果を表にまとめます。

操作 点灯している星の数 選ばれている星
何も選ばずに4つ目に乗せる 4 なし
マウスを外に出す 0 なし
3つ目をクリックする 3 3つ目
5つ目に乗せる 5 3つ目
マウスを外に出す 3 3つ目
3つ目をもう一度クリックする 3 3つ目

表の最後の行のとおり、選んだ星をもう一度クリックしても、選択は外れません。radio ボタンは、クリックしても選ぶものが切り替わるだけで、選択を外すことはできません。「評価しない」に戻せるようにしたいときは、別に取り消しのボタンを用意する必要があります(この記事では作っていません)。

キーボードでは、矢印キーで星の数が変わる

radio ボタンをそのまま使っているので、キーボードの操作はブラウザが受け持ちます。検証環境で、何も選んでいない状態から、表の上から順にキーを押した結果です。

押したキー フォーカスのある場所 選ばれている星
Tab 1つ目の星 なし
→ 2つ目の星 2つ目
→ 3つ目の星 3つ目
Shift+Tab のあと Tab 3つ目の星 3つ目
← 2つ目の星 2つ目
← を2回(1つ目からさらに左) 5つ目の星 5つ目
→(5つ目からさらに右) 1つ目の星 1つ目
Space 1つ目の星 1つ目
Tab 送信ボタン 1つ目

何も選んでいないときに Tab キーで入ると、フォーカスは1つ目の星に移りますが、まだ選ばれてはいません。ここで右矢印キーを押すと、2つ目に移って選ばれます。1つ目を選びたいときは、Space キーを押します。ページを読み込み直し、何も選んでいない状態から Tab キー、Space キーの順に押すと、1つ目が選ばれました。

なお、すでに選ばれている星で Space キーを押しても、何も変わりませんでした(上の表の Space の行)。一度選んだあとは、Tab キーで戻ってくると、選んだ星にフォーカスが移ります。星5つで Tab キーの止まる場所は1か所だけなので、星を1つずつ Tab キーで移る必要はありません。

1つ目でさらに左矢印キーを押すと5つ目に、5つ目でさらに右矢印キーを押すと1つ目に移りました。評価の入力としては少し意外ですが、W3C の ARIA Authoring Practices Guide にある radio グループの例でも、端の radio ボタンから反対の端へ移る動きになっています。

フォーカスの枠は、.star:has(:focus-visible) で label に付けています。radio ボタン自体は見えないので、中の radio ボタンにフォーカスがあるときに、外側の label に枠を出す書き方です。キーボードで操作したときは枠が出て(outline が solid)、マウスでクリックしたときは出ませんでした(none)。MDN の :focus-visible の説明にあるとおり、ブラウザが「フォーカスを見せたほうがよい」と判断したときだけ当てはまるためです。

フォームで送られる値と、必須にしたときの動き

フォームの中に置くと、選んだ星の radio ボタンの value が、name の名前で送られます。検証では、送信のときに FormData の中身を書き出す画面を用意しました。

tsx
import { useState, type FormEvent } from 'react';
import { StarRating } from './StarRating';

function App() {
  const [current, setCurrent] = useState(0);
  const [sent, setSent] = useState('');

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    setSent(JSON.stringify(Object.fromEntries(data)));
  };

  return (
    <form onSubmit={handleSubmit}>
      <StarRating name="rating" label="この記事の評価" required onChange={setCurrent} />
      <p id="current">選んだ値:{current}</p>
      <button type="submit">送信</button>
      <p id="sent">{sent}</p>
    </form>
  );
}

3つ目を選んで送信すると、{"rating":"3"} が送られました。値は数値ではなく文字列の "3" なので、数値として使うときは変換します。

required は、星5つの radio ボタンすべてに付けています。MDN の radio ボタンの解説によると、同じ name のグループのどれかに required があれば、グループのどれか1つが選ばれている必要があります。何も選ばずに送信ボタンを押すと、checkValidity() は false になり、送信の処理は呼ばれませんでした(書き出した値は空のまま)。

text
form.checkValidity(): false
validationMessage: Please select one of these options.
送信された値: ""

validationMessage は、検証環境の Chromium では英語でした。このとき、フォーカスは1つ目の星の radio ボタンに移っていました。MDN の radio ボタンの解説の、入力の検証の節によると、どれも選ばれていなければ、ブラウザが選ぶように求めます。選ばれているかどうかを確かめる処理は自分で書かずに、ブラウザに任せられます。

​Posted by 86