Reactで、検索欄に前回の検索語を候補として出す方法

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

検索した語を localStorage に新しい順で残しておくと、次に検索欄を開いたときに、前回までの検索語を候補として出せます。作るときに気をつけたいのは、一覧を閉じるタイミングとフォーカスの行き先です。一覧は、候補へフォーカスが移っても閉じず、検索フォームの外へ出たときに閉じます。候補を押したあとは、フォーカスを入力欄に戻します。こうすると、マウスでもキーボードでも候補を選べます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 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、スマートフォンのタッチ操作、日本語入力の変換中の操作、スクリーンリーダーでの実際の読み上げ、サーバーでの描画(SSR)は試していません。

空の検索欄をクリックすると「猫 爪とぎ」「キャットタワー」の候補が出る。「キャットタワー」を押すと検索され、入力欄を空にすると「キャットタワー」が先頭に移る。「猫 爪とぎ」の × を押すと候補から消える。変わった候補、検索結果の文、localStorage の中身がその瞬間だけピンクになる
候補を出す、候補で検索する、入力欄を空にして並びを見る、履歴から1件消す、の4回の操作(輪はマウスの位置で、押した瞬間は太くなる。輪と上下の表示は GIF 用に重ねたもの)

履歴は「先頭に足し、同じ語を外し、5件で切る」

履歴の扱いは、React に関係のない関数にまとめました。addTerm は、検索した語を配列の先頭に入れます。同じ語が前からあればその位置から外し、件数は5件までにします。前後の空白は取り、空白だけの語は足しません。

ts
export const STORAGE_KEY = 'search-history';
export const MAX_HISTORY = 5;

// 新しい語を先頭に入れる。同じ語は前の位置から外し、件数は MAX_HISTORY まで
export function addTerm(history: string[], term: string): string[] {
  const t = term.trim();
  if (t === '') return history;
  return [t, ...history.filter((h) => h !== t)].slice(0, MAX_HISTORY);
}

// 読めない値や壊れた値は、履歴なしとして扱う
export function loadHistory(): string[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw === null) return [];
    const value: unknown = JSON.parse(raw);
    if (!Array.isArray(value)) return [];
    return value.filter((v): v is string => typeof v === 'string').slice(0, MAX_HISTORY);
  } catch {
    return [];
  }
}

export function saveHistory(history: string[]): void {
  try {
    if (history.length === 0) localStorage.removeItem(STORAGE_KEY);
    else localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
  } catch {
    // 保存できなくても、検索そのものは止めない
  }
}

検証環境で「猫 ごはん」「猫 爪とぎ」「キャットタワー」の順に検索してから、前後に空白を付けた「 猫 ごはん 」で検索しました。「猫 ごはん」は2件にならず、先頭に移りました。空白だけで Enter キーを押したときは、履歴は変わりませんでした。

text
「  猫 ごはん  」(前後に空白)を入力して Enter
  → 入力欄「猫 ごはん」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 ごはん」で検索しました | 保存: ["猫 ごはん","キャットタワー","猫 爪とぎ"]
空白だけを入力して Enter
  → 入力欄「   」 | 候補[猫 ごはん / キャットタワー / 猫 爪とぎ] | フォーカス: 入力欄 | 「猫 ごはん」で検索しました | 保存: ["猫 ごはん","キャットタワー","猫 爪とぎ"]
「猫砂」「猫 おもちゃ」「猫 ベッド」を続けて検索し、入力欄を空にする
  → 入力欄「」 | 候補[猫 ベッド / 猫 おもちゃ / 猫砂 / 猫 ごはん / キャットタワー] | フォーカス: 入力欄 | 「猫 ベッド」で検索しました | 保存: ["猫 ベッド","猫 おもちゃ","猫砂","猫 ごはん","キャットタワー"]

このログは、テスト用のスクリプトで画面と localStorage を読んで書き出したものです。「候補[…]」は画面に出ている候補、「保存:」のあとは localStorage に入っている文字列です。6件目を足したところで、いちばん古い「猫 爪とぎ」が外れました。

localStorage の読み書きは、失敗しても検索を止めないように try〜catch で包んでいます。MDN によると、localStorage を読むときは、ブラウザの設定などで保存が許されていないと SecurityError が投げられます。setItem は、容量が足りないと QuotaExceededError を投げます。検証環境では、あらかじめ保存しておく値を、JSON でない文字列、オブジェクト、文字列以外がまじった配列の3通りにして、ページを開きました。どれもページのエラーは出ず、文字列の語だけが候補に出ました。

text
保存されていた値 not json で開いて入力欄にフォーカス(ページのエラー: 0件)
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: not json
保存されていた値 {"term":"猫"} で開いて入力欄にフォーカス(ページのエラー: 0件)
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: {"term":"猫"}
保存されていた値 [1,"猫 ごはん",null,"猫砂"] で開いて入力欄にフォーカス(ページのエラー: 0件)
  → 入力欄「」 | 候補[猫 ごはん / 猫砂] | フォーカス: 入力欄 | まだ検索していません | 保存: [1,"猫 ごはん",null,"猫砂"]

フックでは、書く直前に localStorage を読み直す

履歴を React の state として持つのが、useSearchHistory です。最初の値は、useState に loadHistory を関数のまま渡して読みます。公式リファレンスの初期値を毎回作り直さない書き方のとおり、こうすると読み込みは最初の描画のときだけになります。

ts
import { useState } from 'react';
import { addTerm, loadHistory, saveHistory } from './history';

export function useSearchHistory() {
  // 最初の描画のときに一度だけ localStorage から読む
  const [history, setHistory] = useState<string[]>(loadHistory);

  // 書くたびに localStorage から読み直してから変える。
  // ほかのタブが足した語を、古い state で上書きしないため
  function update(change: (current: string[]) => string[]) {
    const next = change(loadHistory());
    saveHistory(next);
    setHistory(next);
  }

  return {
    history,
    add: (term: string) => update((h) => addTerm(h, term)),
    remove: (term: string) => update((h) => h.filter((t) => t !== term)),
    clear: () => update(() => []),
  };
}

書くときに手元の state ではなく localStorage を読み直しているのは、同じサイトを2つのタブで開いている場合のためです。localStorage はオリジンごとに1つなので、タブ同士で同じ保存場所を使います。

比べるために、update の中の loadHistory() を history(手元の state)に変えた版も作りました。両方の版で、タブAとタブBを開いてから、タブAで「猫 ごはん」、タブBで「猫砂」、もう一度タブAで「猫 爪とぎ」を検索しました。state から計算する版では、タブBが書いたときにタブBが知らない「猫 ごはん」が消え、次にタブAが書いたときにタブAが知らない「猫砂」が消えました。

text
### 2つのタブで検索する:書く前に localStorage を読み直す版(useSearchHistory)
タブBで「猫砂」を検索
  → 入力欄「猫砂」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫砂」で検索しました | 保存: ["猫砂","猫 ごはん"]
タブAで「猫 爪とぎ」を検索し、入力欄を空にする
  → 入力欄「」 | 候補[猫 爪とぎ / 猫砂 / 猫 ごはん] | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫砂","猫 ごはん"]

### 2つのタブで検索する:state から計算して書く版
タブBで「猫砂」を検索
  → 入力欄「猫砂」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫砂」で検索しました | 保存: ["猫砂"]
タブAで「猫 爪とぎ」を検索し、入力欄を空にする
  → 入力欄「」 | 候補[猫 爪とぎ / 猫 ごはん] | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん"]

読み直す版でも、ほかのタブが書いた語が、開いているタブの候補にすぐ出るわけではありません。検証環境では、タブBが書いた「猫砂」は、タブAで次に検索したときに候補に入りました。すぐに出したい場合は、ほかのタブが localStorage を変えたときに届く storage イベントを受け取って state を読み直す方法がありますが、この記事では試していません。

一覧は、フォーカスが form の外へ出たときに閉じる

検索欄の部品が SearchBox です。入力欄にフォーカスすると候補の一覧を開き、Enter キーか候補のボタンで検索します。候補の横の × で1件ずつ、下のボタンで全部を消せます。

tsx
import { useRef, useState } from 'react';
import { useSearchHistory } from './useSearchHistory';

type Props = { onSearch: (term: string) => void };

export function SearchBox({ onSearch }: Props) {
  const { history, add, remove, clear } = useSearchHistory();
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  // 入力中の文字を含む履歴だけを出す。空欄なら全部
  const q = query.trim();
  const suggestions = history.filter((term) => term.includes(q) && term !== q);

  // 押したボタンは閉じた一覧と一緒に消えるので、フォーカスを入力欄に戻す
  function focusInput() {
    inputRef.current?.focus();
  }

  function search(term: string) {
    const t = term.trim();
    if (t === '') return;
    focusInput();
    add(t);
    setQuery(t);
    setOpen(false);
    onSearch(t);
  }

  function removeTerm(term: string) {
    remove(term);
    focusInput();
  }

  function clearAll() {
    clear();
    focusInput();
  }

  return (
    <form
      role="search"
      className="search"
      onSubmit={(e) => {
        e.preventDefault();
        search(query);
      }}
      // フォーカスが form の外へ出たときだけ閉じる。候補のボタンへ移るときは閉じない
      onFocus={() => setOpen(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false);
      }}
      onKeyDown={(e) => {
        if (e.key === 'Escape') setOpen(false);
      }}
    >
      <input
        ref={inputRef}
        type="search"
        aria-label="検索語"
        autoComplete="off"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setOpen(true);
        }}
      />
      <button type="submit">検索</button>
      {open && suggestions.length > 0 && (
        <section className="history" aria-label="最近の検索">
          <ul>
            {suggestions.map((term) => (
              <li key={term}>
                <button type="button" className="term" onClick={() => search(term)}>
                  {term}
                </button>
                <button
                  type="button"
                  className="remove"
                  aria-label={`「${term}」を履歴から消す`}
                  onClick={() => removeTerm(term)}
                >
                  ×
                </button>
              </li>
            ))}
          </ul>
          <button type="button" className="clear" onClick={clearAll}>
            履歴をすべて消す
          </button>
        </section>
      )}
    </form>
  );
}

onFocus と onBlur は、入力欄ではなく form に付けています。React の onFocus と onBlur は、ブラウザの focus・blur イベントと違って、子の要素で起きたものも親に伝わります(公式リファレンスの onBlur の説明)。onBlur の relatedTarget は、次にフォーカスを受け取る要素です(ない場合は null。MDN の relatedTarget の説明)。それが form の中にあれば、一覧は開いたままにします。公式リファレンスにも、currentTarget と relatedTarget で、フォーカスが親の要素の外から来たか・外へ出たかを見分ける例があります。

比べるために、入力欄の onBlur で、フォーカスが外れたらすぐ閉じる版も作りました。× や「履歴をすべて消す」、Esc キーの処理、フォーカスを戻す処理は省いた簡略版です。

tsx
      <input
        type="search"
        aria-label="検索語"
        autoComplete="off"
        value={query}
        onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)} // 入力欄からフォーカスが外れたら、すぐ閉じる
      />

両方の版で、候補「猫 爪とぎ」のある位置で、マウスのボタンを押してから離しました。入力欄の onBlur で閉じる版では、ボタンを押した時点で一覧が閉じ、離しても検索されませんでした。SearchBox では、押した時点でフォーカスが候補のボタンに移り、一覧は開いたままでした。

text
### マウスで候補を押す:form の onBlur で、フォーカスが form の外へ出たときだけ閉じる版(SearchBox)
候補「猫 爪とぎ」の位置でマウスのボタンを押す(まだ離さない)
  → 入力欄「」 | 候補[猫 ごはん / 猫 爪とぎ / キャットタワー] | フォーカス: 猫 爪とぎ | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
マウスのボタンを離す
  → 入力欄「猫 爪とぎ」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]

### マウスで候補を押す:入力欄の onBlur ですぐ閉じる版
候補「猫 爪とぎ」の位置でマウスのボタンを押す(まだ離さない)
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
マウスのボタンを離す
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]

キーボードでも差が出ました。入力欄から Tab キーを2回押すと、SearchBox では「検索」ボタンを経て最初の候補に移り、Enter キーでその語を検索できました。入力欄の onBlur で閉じる版では、1回目の Tab キーで一覧が閉じ、候補にはたどり着けませんでした(ログは抜粋)。

text
### キーボードで候補を選ぶ:form の onBlur で、フォーカスが form の外へ出たときだけ閉じる版(SearchBox)
Tab
  → 入力欄「」 | 候補[猫 爪とぎ / 猫 ごはん / キャットタワー] | フォーカス: 猫 爪とぎ | まだ検索していません | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]
Enter
  → 入力欄「猫 爪とぎ」 | 候補なし(閉じている) | フォーカス: 入力欄 | 「猫 爪とぎ」で検索しました | 保存: ["猫 爪とぎ","猫 ごはん","キャットタワー"]

### キーボードで候補を選ぶ:入力欄の onBlur ですぐ閉じる版
Tab
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: 検索 | まだ検索していません | 保存: ["猫 ごはん","猫 爪とぎ","キャットタワー"]
注意

MDN の button 要素の説明によると、ボタンをクリックしたときにフォーカスが移るかどうかはブラウザと OS によって違い、Safari では移りません。その場合、relatedTarget に候補のボタンが入らず、SearchBox でもクリックの前に一覧が閉じるおそれがあります。この記事の結果は Chromium だけのもので、Safari では試していません。

押したボタンが消えるときは、入力欄にフォーカスを戻す

候補を押して検索すると一覧は閉じ、押したボタンも画面から消えます。× で消した候補のボタンも同じです。SearchBox では、focusInput でフォーカスを入力欄に戻しています。

比べるために、focusInput の中の inputRef.current?.focus(); の1行を消した版も作りました。「猫 爪とぎ」の × にフォーカスを置いて Enter キーを押すと、フォーカスのある要素がなくなりました(document.activeElement が body)。マウスで候補を押して検索したときも同じでした。SearchBox では、どちらもフォーカスが入力欄に戻り、そのまま次の語を入力できます。

text
### キーボードで履歴を1件消す:押したあとに入力欄へフォーカスを戻す版(SearchBox)
Enter
  → 入力欄「」 | 候補[猫 ごはん / キャットタワー] | フォーカス: 入力欄 | まだ検索していません | 保存: ["猫 ごはん","キャットタワー"]

### キーボードで履歴を1件消す:フォーカスを戻さない版
Enter
  → 入力欄「」 | 候補[猫 ごはん / キャットタワー] | フォーカス: body(どこにもない) | まだ検索していません | 保存: ["猫 ごはん","キャットタワー"]

search の中では、setOpen(false) より前に focusInput() を呼んでいます。候補のボタンから入力欄へフォーカスが移ると、form の onFocus が setOpen(true) を呼びます。同じイベントの中の state の更新はまとめて処理され、最後に呼んだ setOpen(false) が残るので、一覧は閉じます。検証環境でも、マウスで候補を押したあとは、フォーカスが入力欄にあり、一覧は閉じていました。

入力中の文字で候補を絞り、ブラウザの入力履歴は出さない

候補は、入力中の文字を含む語だけにしています。入力欄が空なら、履歴を全部出します。入力欄の文字とまったく同じ語は、候補から外しました。選んでも入力欄の文字が変わらないからです。「猫 」と入力したときは、「キャットタワー」が候補から外れました。

text
「猫 」と入力
  → 入力欄「猫 」 | 候補[猫 ベッド / 猫 おもちゃ / 猫砂 / 猫 ごはん] | フォーカス: 入力欄 | 「猫 ベッド」で検索しました | 保存: ["猫 ベッド","猫 おもちゃ","猫砂","猫 ごはん","キャットタワー"]

入力欄には autoComplete="off" を付けて、自分で出す一覧とブラウザの一覧が重ならないようにしています。MDN の autocomplete 属性の説明によると、ブラウザが出す入力の候補は、多くの場合、利用者が以前に入力した値です。off は、ページが自分で入力の候補を出す場合などに使う値とされています。ブラウザの一覧が実際に出なくなるかは、検証環境(ヘッドレスの Chromium)では確かめていません。

Playwright で書き出したアクセシビリティツリーでは、form は search(検索のランドマーク)になり、候補の一覧は「最近の検索」という名前の領域に入っていました(抜粋)。role="search" は、MDN の search ロールの説明にある使い方です。

text
- main:
  - search:
    - searchbox "検索語"
    - button "検索"
    - region "最近の検索":
      - list:
        - listitem:
          - button "猫 ごはん"
          - button "「猫 ごはん」を履歴から消す": ×

この部品では、候補はふつうのボタンで、Tab キーで順に移ります。↑↓キーで候補を選ぶ形にしたい場合は、WAI-ARIA の作り方のガイド(APG)にあるコンボボックスのパターンに沿って作り直すことになります。

履歴は、ブラウザに残り続けます。MDN によると、localStorage のデータには有効期限がありません。共用の端末で使われる画面なら、「履歴をすべて消す」のようなボタンを用意しておくと、利用者が自分で履歴を消せます。この部品では、押すと localStorage から履歴のキーごと消えます。

text
「履歴をすべて消す」を押す
  → 入力欄「」 | 候補なし(閉じている) | フォーカス: 入力欄 | まだ検索していません | 保存: null

​Posted by 86