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

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

色を選ぶ入力欄は、<input type="color"> を state で制御すれば、ライブラリを使わずに作れます。気を付けるのは値の形です。検証した Chromium では、色の input の値は小文字の #rrggbb で読み出されます。state もその形で持つと、ほかの値と比べたときに食い違いが起きません。

ここでは、色の input に、カラーコードを直接打てる欄と、よく使う色のボタンを並べた ColorField を作りました。値の形、onChange が呼ばれる回数、打ちかけのカラーコードの扱いを、検証環境で確かめています。

補足

検証環境は 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 を操作して、input の値や画面の表示を調べました。ブラウザが開く色の選択画面は、ヘッドレスの Chromium では操作できないため試していません。色の input の値は、Playwright の fill か、スクリプトからイベントを送る方法で変えています。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

テーマの色の入力欄。「わかば」のボタンを押すと色が緑に変わり、カラーコード欄に「#d85878」と打ち終えた時点で色がピンクに変わる。「#12」と打ちかけて欄の外を押すと、欄は「#d85878」に戻る
色のボタンとカラーコード欄の操作で、表示が変わる様子。色が変わったときの「いまの色」と、欄の外を押して戻ったカラーコード欄の文字を、その瞬間だけピンクにしている(カーソル、押した瞬間の点線の輪、操作の説明と回数の表示は GIF 用に重ねたもの)

色の input、カラーコード欄、色のボタンを1つにまとめる

コンポーネントの全体は次のとおりです。色は小文字の #rrggbb の文字列として親から受け取り、変えるときは onChange で親に渡します。

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

export type Preset = { name: string; hex: string };

// "#FF8800" や "ff8800" を "#ff8800" にそろえる。6桁のカラーコードでなければ null
export function normalizeHex(text: string): string | null {
  const m = /^#?([0-9a-f]{6})$/i.exec(text.trim());
  return m ? `#${m[1].toLowerCase()}` : null;
}

type Props = {
  label: string;
  value: string; // 小文字の #rrggbb
  onChange: (hex: string) => void;
  presets?: Preset[];
  name?: string;
};

export function ColorField({ label, value, onChange, presets = [], name }: Props) {
  const id = useId();
  // 入力欄にフォーカスがある間だけ、打ちかけの文字を持つ
  const [draft, setDraft] = useState<string | null>(null);
  const invalid = draft !== null && normalizeHex(draft) === null;

  function handleTextChange(text: string) {
    setDraft(text);
    const hex = normalizeHex(text);
    if (hex) onChange(hex);
  }

  return (
    <fieldset className="color-field">
      <legend>{label}</legend>
      <div className="color-row">
        <input
          id={id}
          type="color"
          name={name}
          value={value}
          aria-label={`${label}を選ぶ`}
          onChange={(e) => onChange(e.target.value)}
        />
        <input
          type="text"
          className="color-code"
          value={draft ?? value}
          aria-label={`${label}のカラーコード`}
          aria-invalid={invalid}
          aria-describedby={`${id}-hint`}
          spellCheck={false}
          onFocus={() => setDraft(value)}
          onChange={(e) => handleTextChange(e.target.value)}
          onBlur={() => setDraft(null)}
        />
      </div>
      <p id={`${id}-hint`} className={invalid ? 'hint hint-invalid' : 'hint'}>
        {invalid ? '#と6桁の16進数で入力してください(例:#00c8e8)' : '#と6桁の16進数で入力できます'}
      </p>
      {presets.length > 0 && (
        <div className="presets">
          {presets.map((p) => {
            const hex = normalizeHex(p.hex) ?? p.hex;
            return (
              <button
                key={hex}
                type="button"
                className="preset"
                style={{ background: hex }}
                aria-label={p.name}
                aria-pressed={value === hex}
                title={`${p.name}(${hex})`}
                onClick={() => onChange(hex)}
              />
            );
          })}
        </div>
      )}
    </fieldset>
  );
}

色の input には value と onChange を渡しているので、controlled な input です。React の公式リファレンスにある、state で input を制御する説明のとおり、React は input の値を、渡した value に合わせ続けます。色の input、カラーコード欄、色のボタンのどれで色を変えても、同じ onChange を通ります。カラーコード欄で打ちかけている間を除けば、3つの表示は同じ色を指します。

3つの部品は <fieldset> でまとめ、<legend> の「テーマの色」を、まとまり全体の名前にしました。MDN の fieldset の説明にあるとおり、<legend> は <fieldset> の見出しになります。Playwright でアクセシビリティツリーを書き出すと、まとまりは「テーマの色」という名前の group になっていました。色の input とカラーコード欄には、aria-label で「テーマの色を選ぶ」「テーマの色のカラーコード」という別々の名前を付けました。

使う側では、色を state で持ち、いまの色を枠の色と文字で表示しました。よく使う色は、わざと大文字のカラーコードで書いています。

tsx
import { useState } from 'react';
import { ColorField, type Preset } from './ColorField';

const PRESETS: Preset[] = [
  { name: '水色', hex: '#00C8E8' },
  { name: 'さくら', hex: '#D85878' },
  { name: 'わかば', hex: '#2F9E44' },
  { name: 'すみ', hex: '#1F2A30' },
];

export function App() {
  const [color, setColor] = useState('#00c8e8');
  const [sent, setSent] = useState('');

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

  return (
    <form onSubmit={handleSubmit}>
      <ColorField label="テーマの色" value={color} onChange={setColor} presets={PRESETS} name="theme_color" />
      <div className="preview" style={{ borderColor: color }}>
        <span className="preview-badge" style={{ background: color }} />
        <span className="preview-text">いまの色:<b className="current">{color}</b></span>
      </div>
      <button type="submit">保存</button>
      <output className="sent">{sent}</output>
    </form>
  );
}

値は小文字の「#rrggbb」で読み出される

MDN の input type="color" の説明によると、今の仕様では、値に CSS の色の書き方をどれでも使えます。値がないときや、色として読めないときは #000000(黒)になります。

検証環境の Chromium で、React を使わずに色の input の value へいろいろな文字列を入れ、読み出した値を調べました。

text
何も入れないとき: #000000
value に "#FF8800" を入れる → #ff8800
value に "#f80" を入れる → #ff8800
value に "#00c8e" を入れる → #000000
value に "red" を入れる → #ff0000
value に "rgb(0 128 0)" を入れる → #008000
value に "#ff000080" を入れる → #ff0000
value に "oklch(70% 0.15 200)" を入れる → #00b9c3
value に "nonsense" を入れる → #000000
value に "" を入れる → #000000
'alpha' in input: false、'colorSpace' in input: false
alpha 属性を付けて "#ff000080" を入れる → #ff0000

色として読める書き方なら、読み出した値は小文字の #rrggbb でした。red や oklch() も、その色のカラーコードになります。5桁しかない #00c8e、色として読めない nonsense、空の文字列は、どれも #000000 でした。

MDN では、透明度を選べるようにする alpha 属性と、色空間を指定する colorspace 属性も、試験的な機能として紹介されています。ただ、検証に使った Chromium 153 では、input の要素に alpha や colorSpace のプロパティがありませんでした。alpha 属性を付けても、#ff000080 の透明度は捨てられて #ff0000 になりました。透明度まで選ばせたいときは、検証した Chromium では色の input だけでは足りず、別の入力欄が要ります。

React で困るのは、state に大文字のカラーコードを入れたときです。useState('#D85878') の値を色の input に渡して、state と input の値を比べました。

text
state: #D85878 / input の value 属性: #D85878 / input.value: #d85878

state は大文字のまま、input の値だけが小文字になっています。このまま、色の input の e.target.value(小文字)と、大文字で書いたよく使う色を === で比べると、同じ色なのに一致しません。そこで ColorField では、normalizeHex で値を小文字の #rrggbb にそろえてから比べたり、親に渡したりしています。

よく使う色を、押した状態が分かるボタンで並べる

よく使う色は、<button type="button"> で並べました。MDN の button の説明にあるとおり、フォームの中の button は、type を書かないと送信のボタンになります。検証環境でも、type="button" の色のボタンを押したあと、送信内容は空のままでした。いま選ばれている色のボタンには aria-pressed={true} を付けます。MDN の aria-pressed の説明にあるとおり、aria-pressed を付けたボタンは、押した状態と押していない状態を持つ切り替えボタンになります。ボタンには色しか描かれていないので、aria-label で「さくら」などの名前を付けました。見た目では、押されているボタンに濃い色の輪を付けています。

ボタンを押したときと、色の input の値が変わったときの表示を調べました。色の input の値は、Playwright の fill で変えています。

text
最初: 色の input #00c8e8 / カラーコード欄「#00c8e8」 / aria-invalid false / 押された色 水色 / いまの色 #00c8e8
「さくら」のボタンを押す: 色の input #d85878 / カラーコード欄「#d85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878
色の input に fill("#2F9E44"): 色の input #2f9e44 / カラーコード欄「#2f9e44」 / aria-invalid false / 押された色 わかば / いまの色 #2f9e44
色の input に fill("#123456"): 色の input #123456 / カラーコード欄「#123456」 / aria-invalid false / 押された色 なし / いまの色 #123456

「さくら」のボタンを押すと、App で大文字の #D85878 と書いた色が、小文字の #d85878 になって state に入りました。色の input に #2F9E44 を入れると、「わかば」のボタンが押された状態になります。

カラーコードを直接打てる欄を並べる

MDN の説明によると、色の選択画面の見た目や作りは、ブラウザやプラットフォームによって大きく違います。決まったカラーコードをどのブラウザでも同じように入れられるように、カラーコードを打てる <input type="text"> を隣に置きました。

この欄は、打ちかけの文字を、親の色とは別に持つ必要があります。たとえば #00c8e まで打った時点では、まだ色として読めません。この文字を親の色に入れてしまうと、前の節で見たとおり、色の input の値は #000000(黒)になります。

そこで ColorField では、欄にフォーカスがある間だけ、打った文字を draft という state に持たせました。onFocus でいまの色を draft に写し、文字が変わるたびに normalizeHex で読めるかどうかを調べます。# と6桁の16進数として読めたときだけ、親の onChange を呼びます。onBlur で draft を null に戻すと、欄の表示は親の色に戻ります。読めない文字のままフォーカスが外れても、最後に読めた色が残ります。

MDN の aria-invalid の説明にあるとおり、aria-invalid は、入力された値が期待される形に合っていないことを伝えます。読めない間はこれを true にして、欄の下の説明の文を、入力の例を添えた文に変えました。説明の文は aria-describedby で欄と結び付けています。

カラーコード欄で文字を打ったときの表示は次のとおりです。

text
カラーコード欄に「#00c8e」まで打つ: 色の input #123456 / カラーコード欄「#00c8e」 / aria-invalid true / 押された色 なし / いまの色 #123456
  説明の文: #と6桁の16進数で入力してください(例:#00c8e8)
続けて「8」を打つ: 色の input #00c8e8 / カラーコード欄「#00c8e8」 / aria-invalid false / 押された色 水色 / いまの色 #00c8e8
「D85878」(# なし、大文字)と打ち直す: 色の input #d85878 / カラーコード欄「D85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878
「#12」と打ちかけて: 色の input #d85878 / カラーコード欄「#12」 / aria-invalid true / 押された色 さくら / いまの色 #d85878
フォーカスを外す: 色の input #d85878 / カラーコード欄「#d85878」 / aria-invalid false / 押された色 さくら / いまの色 #d85878

#00c8e までは色が変わらず、8 を打った時点で #00c8e8 になりました。# なしの大文字で D85878 と打っても、色は #d85878 です。#12 と打ちかけてフォーカスを外すと、欄は #d85878 に戻りました。

normalizeHex が受け付けるのは、# があってもなくてもよい6桁の16進数だけです。#f80 のような3桁の書き方や、red のような色の名前は、色の input の value に入れれば変換されますが、このカラーコード欄では読めない文字として扱います。

onChange は、色を選んでいる途中にも呼ばれる

MDN の input type="color" の説明によると、ブラウザは、色の選択画面で色が変わるたびに input イベントを送り、選択画面を閉じたときに change イベントを送ります。一方、React のリファレンスにある、onChange の説明では、onChange はブラウザの input イベントと同じように動くと書かれています。ドキュメントのとおりなら、名前は change でも、React の onChange は選んでいる途中の色の変化ごとに呼ばれることになります。

検証環境では色の選択画面を操作できないため、スクリプトから、選択画面の操作中と同じ順番でイベントを送って数えました。値を変えて input イベントを送ることを5回くり返し、最後に change イベントを1回送っています。React の onChange の回数と、addEventListener で付けたブラウザの change イベントの回数を数えました。

text
最初: value=#000000 onChange=0 change=0
input イベント5回と change イベント1回のあと: value=#550000 onChange=5 change=1
Playwright の fill("#660000") のあと: value=#660000 onChange=6 change=2

React の onChange は input イベントの5回すべてで呼ばれ、ブラウザの change イベントは1回だけでした。Playwright の fill では、onChange と change イベントが1回ずつ呼ばれました。

色をプレビューに映すだけなら、途中で何回呼ばれても困りません。選び終えた色だけをサーバーに保存したいときは、ref で input を受け取り、ブラウザの change イベントを addEventListener で付けます。検証に使ったのは次のコードです。

tsx
// 検証用:React の onChange と、ブラウザの change イベントが呼ばれる回数を数える
import { useEffect, useRef, useState } from 'react';

export function Events() {
  const [color, setColor] = useState('#000000');
  const [reactCount, setReactCount] = useState(0);
  const [nativeCount, setNativeCount] = useState(0);
  const ref = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const el = ref.current!;
    const onNativeChange = () => setNativeCount((n) => n + 1);
    el.addEventListener('change', onNativeChange);
    return () => el.removeEventListener('change', onNativeChange);
  }, []);

  return (
    <div>
      <input
        ref={ref}
        type="color"
        aria-label="色"
        value={color}
        onChange={(e) => {
          setColor(e.target.value);
          setReactCount((n) => n + 1);
        }}
      />
      <p className="log">value={color} onChange={reactCount} change={nativeCount}</p>
    </div>
  );
}

フォームで送るときは name を付ける

色の input に name を付けておけば、ふつうの input と同じように、フォームの送信内容に入ります。App では name="theme_color" を渡し、送信時に FormData の中身を表示しました。

text
送信した内容: {"theme_color":"#d85878"}

送られたのは色の input の値で、小文字の #rrggbb でした。カラーコード欄には name を付けていないので、送信内容に入りません。

​Posted by 86