Reactで、2つの一覧の間で項目を移すトランスファーリストの作り方

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

トランスファーリスト(2つの一覧の間で項目を移す部品)は、左右の一覧をそれぞれ配列の state に持たず、「右にある項目の id の Set」だけを持つと作りやすくなります。左右の一覧は、元の配列から描画のたびに作ります。こうすると、項目を行き来させても並び順は元のままです。また、移す対象を画面に見えている項目に限ると、絞り込みで隠れている項目まで移してしまうこともありません。

作ったのは、表に出す列を選ぶ画面を想定した部品です。左が「表示しない列」、右が「表示する列」で、項目にチェックを入れて「>」「<」のボタンで移します。

補足

検証環境は 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、スマートフォンのタッチ操作、ドラッグ&ドロップでの移動、スクリーンリーダーでの実際の読み上げは試していません。

左の「メール」「部署」を選んで「>」を押すと右へ移り、続けて「名前」を移すと右の一覧の先頭に入る。右の「メール」を選んで「<」を押すと、左の「ふりがな」と「電話番号」の間に戻る。変わった項目と件数、下に出る文がその瞬間だけピンクになる
トランスファーリストを7回クリックしたときの表示の変わり方(輪はマウスの位置で、押した瞬間は太くなる。輪と上の表示は GIF 用に重ねたもの)

左右の一覧は、右にある項目の id から毎回作る

項目のデータは、元の並び順の配列を1つだけ用意します。

ts
export type Item = { id: string; label: string };

// 表に出す列の候補。この並び順を「元の順番」として使う
export const columns: Item[] = [
  { id: 'name', label: '名前' },
  { id: 'kana', label: 'ふりがな' },
  { id: 'email', label: 'メール' },
  { id: 'phone', label: '電話番号' },
  { id: 'dept', label: '部署' },
  { id: 'role', label: '役職' },
  { id: 'joined', label: '入社日' },
];

部品の本体が TransferList です。state に持つのは、右にある項目の id の Set、チェックが入っている項目の id の Set、左右の絞り込みの文字、移したあとに出す文の4つです。左の一覧は「右にない項目」、右の一覧は「右にある項目」を元の配列から filter で取り出して作ります。

tsx
import { useState } from 'react';
import { ListPanel } from './ListPanel';
import type { Item } from './items';

type Side = 'left' | 'right';
type Props = { items: Item[]; titles: Record<Side, string> };

export function TransferList({ items, titles }: Props) {
  // 右にある項目の id だけを持つ。左右の一覧は、元の配列から毎回作る
  const [rightIds, setRightIds] = useState<ReadonlySet<string>>(new Set());
  const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
  const [query, setQuery] = useState<Record<Side, string>>({ left: '', right: '' });
  const [message, setMessage] = useState('');

  const sideOf = (item: Item): Side => (rightIds.has(item.id) ? 'right' : 'left');
  const all = (side: Side) => items.filter((item) => sideOf(item) === side);
  const visible = (side: Side) => all(side).filter((item) => item.label.includes(query[side]));

  // 移すのは、移す元の一覧で「見えていて」チェックが入っている項目だけ
  const movable = (from: Side) => visible(from).filter((item) => checked.has(item.id));

  function move(from: Side, targets: Item[]) {
    const to: Side = from === 'left' ? 'right' : 'left';
    const nextRight = new Set(rightIds);
    const nextChecked = new Set(checked);
    for (const item of targets) {
      if (to === 'right') nextRight.add(item.id);
      else nextRight.delete(item.id);
      nextChecked.delete(item.id); // 移した項目のチェックは外す
    }
    setRightIds(nextRight);
    setChecked(nextChecked);
    setMessage(`${targets.length}件を「${titles[to]}」へ移しました`);
  }

  function toggle(id: string) {
    const next = new Set(checked);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    setChecked(next);
  }

  const panel = (side: Side) => (
    <ListPanel
      title={titles[side]}
      items={visible(side)}
      total={all(side).length}
      checked={checked}
      onToggle={toggle}
      query={query[side]}
      onQuery={(q) => setQuery({ ...query, [side]: q })}
    />
  );

  return (
    <div className="transfer">
      {panel('left')}
      {/* 検証した Chromium では、disabled 属性だと押して無効になったボタンからフォーカスが外れたので aria-disabled にする */}
      <div className="buttons">
        <button
          type="button"
          aria-label={`選んだ項目を「${titles.right}」へ移す`}
          aria-disabled={movable('left').length === 0}
          onClick={() => movable('left').length > 0 && move('left', movable('left'))}
        >
          &gt;
        </button>
        <button
          type="button"
          aria-label={`選んだ項目を「${titles.left}」へ戻す`}
          aria-disabled={movable('right').length === 0}
          onClick={() => movable('right').length > 0 && move('right', movable('right'))}
        >
          &lt;
        </button>
      </div>
      {panel('right')}
      <p role="status" className="status">{message}</p>
    </div>
  );
}

左右の配列を state に持たないのは、React の公式ドキュメントにある、state の構造を決めるときの原則に沿った形です。描画中に計算できる情報は state に入れず、同じデータを複数の state に重複させない、という考え方です。左の一覧は「元の配列」と「右にある id」から計算できるので、state にしていません。

チェックの Set は左右で1つにしました。チェックが入った項目がどちらの一覧にあるかは、rightIds に id があるかどうかで決まります。検証環境で、「名前」と「部署」を右へ移したあと、右の「部署」と左の「役職」にチェックを入れて「>」を押すと、移ったのは「役職」だけでした。右の「部署」のチェックはそのまま残りました。

text
右の「部署」と左の「役職」にチェック
  → 表示しない列[☐ふりがな ☐メール ☐電話番号 ☑役職 ☐入社日] 5件中 1件を選択 | > < | 表示する列[☐名前 ☑部署] 2件中 1件を選択 | 状態: 2件を「表示する列」へ移しました
> を押す
  → 表示しない列[☐ふりがな ☐メール ☐電話番号 ☐入社日] 4件中 0件を選択 | >(無効) < | 表示する列[☐名前 ☑部署 ☐役職] 3件中 1件を選択 | 状態: 1件を「表示する列」へ移しました

このログは、テスト用のスクリプトで左右の一覧とボタンの状態を読んで書き出したものです。☑ はチェックあり、☐ はチェックなし、「(無効)」は押せない状態のボタンです。「状態:」のあとは、role="status" の要素に出ている文です。

一覧1つ分を描くのは ListPanel です。絞り込みの入力欄、チェックボックスの一覧、選んだ件数を、fieldset と legend でまとめています。

tsx
import type { Item } from './items';

type Props = {
  title: string;
  items: Item[]; // 絞り込んだあとの、画面に出す項目
  total: number;
  checked: ReadonlySet<string>;
  onToggle: (id: string) => void;
  query: string;
  onQuery: (q: string) => void;
};

export function ListPanel({ title, items, total, checked, onToggle, query, onQuery }: Props) {
  const count = items.filter((item) => checked.has(item.id)).length;
  return (
    <fieldset className="panel">
      <legend>{title}</legend>
      <input
        type="search"
        aria-label={`${title}を絞り込む`}
        placeholder="絞り込み"
        value={query}
        onChange={(e) => onQuery(e.target.value)}
      />
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={checked.has(item.id)}
                onChange={() => onToggle(item.id)}
              />
              {item.label}
            </label>
          </li>
        ))}
      </ul>
      <p className="count">
        {total}件中 {count}件を選択
      </p>
    </fieldset>
  );
}

左右を別々の配列で持つと、戻した項目が末尾に並ぶ

比べるために、左右を別々の配列で持つ版も作りました。移すときは、移す元の配列から取り除き、移す先の配列の末尾に足します(state の一部と、移す処理の部分だけを載せます)。

tsx

const [lists, setLists] = useState<Record<Side, Item[]>>({ left: items, right: [] });

  function move(from: Side) {
    const to: Side = from === 'left' ? 'right' : 'left';
    const targets = lists[from].filter((item) => checked.has(item.id));
    setLists({
      [from]: lists[from].filter((item) => !checked.has(item.id)),
      [to]: [...lists[to], ...targets], // 移した先の末尾に足す
    } as Record<Side, Item[]>);
    setChecked(new Set());
  }

検証環境で、両方の版に同じ操作をしました。「メール」と「部署」を右へ移し、次に「名前」を右へ移し、最後に「メール」を左へ戻します。別々の配列で持つ版では、右の一覧が「メール、部署、名前」の順になり、左に戻した「メール」はいちばん下に入りました。

text
左の「名前」にチェックして > を押す
  → 表示しない列[☐ふりがな ☐電話番号 ☐役職 ☐入社日] 4件中 0件を選択 | > < | 表示する列[☐メール ☐部署 ☐名前] 3件中 0件を選択
右の「メール」にチェックして < を押す
  → 表示しない列[☐ふりがな ☐電話番号 ☐役職 ☐入社日 ☐メール] 5件中 0件を選択 | > < | 表示する列[☐部署 ☐名前] 2件中 0件を選択

右にある id だけを持つ版では、右の一覧は「名前、メール、部署」の順になりました。左に戻した「メール」も、元の位置(「ふりがな」と「電話番号」の間)に入りました。

text
左の「名前」にチェックして > を押す
  → 表示しない列[☐ふりがな ☐電話番号 ☐役職 ☐入社日] 4件中 0件を選択 | >(無効) <(無効) | 表示する列[☐名前 ☐メール ☐部署] 3件中 0件を選択 | 状態: 1件を「表示する列」へ移しました
右の「メール」にチェックして < を押す
  → 表示しない列[☐ふりがな ☐メール ☐電話番号 ☐役職 ☐入社日] 5件中 0件を選択 | >(無効) <(無効) | 表示する列[☐名前 ☐部署] 2件中 0件を選択 | 状態: 1件を「表示しない列」へ移しました
上は左右を別々の配列で持つ版で、左の一覧の最後に「メール」があり、右は「部署、名前」の順。下は右にある id だけを持つ版で、左は「ふりがな、メール、電話番号、役職、入社日」、右は「名前、部署」の順
同じ7回の操作のあとの表示を、2つの版で比べたもの

右の一覧を「移した順」に並べたい画面もあります。たとえば、表示する列の左からの順番を、利用者が移した順で決める場合です。そのときは、右側だけ id の配列で持つ形が考えられますが、この記事ではその書き方は試していません。

絞り込みで隠れている項目は移さない

一覧が長いと、絞り込みの入力欄で探してからチェックを入れることになります。このとき、絞り込む前に入れたチェックが、隠れたまま残っていることがあります。

別々の配列で持つ版は、チェックが入っている項目を全部移します。検証環境で、左の「名前」にチェックを入れてから「メ」で絞り込み、「メール」にチェックを入れて「>」を押すと、画面に出ていなかった「名前」まで右へ移りました。

text
左の「メール」にチェック
  → 表示しない列[☑メール] 7件中 1件を選択 | > < | 表示する列[] 0件中 0件を選択
> を押す
  → 表示しない列[] 5件中 0件を選択 | > < | 表示する列[☐名前 ☐メール] 2件中 0件を選択

TransferList では、移す対象を movable で「移す元の一覧で、絞り込みのあとも見えていて、チェックが入っている項目」に限っています。ListPanel の件数も、見えている項目だけを数えています。同じ操作をすると、移ったのは「メール」だけでした。絞り込みを空にすると、「名前」はチェックが入ったまま左に残っていました。

text
> を押す
  → 表示しない列[] 6件中 0件を選択 | >(無効) <(無効) | 表示する列[☐メール] 1件中 0件を選択 | 状態: 1件を「表示する列」へ移しました
左の絞り込みを空にする
  → 表示しない列[☑名前 ☐ふりがな ☐電話番号 ☐部署 ☐役職 ☐入社日] 6件中 1件を選択 | > <(無効) | 表示する列[☐メール] 1件中 0件を選択 | 状態: 1件を「表示する列」へ移しました

隠れた項目のチェックを残すか外すかは、画面の作り方しだいです。絞り込みを変えたときに、隠れた項目のチェックを外す書き方もありますが、この記事では試していません。

ポイント

絞り込みのある一覧では、移す対象を、画面に出ていてチェックが入っている項目に限ります。チェックが入っている項目を全部移すと、利用者に見えていない項目まで移ります。

移すボタンは、disabled ではなく aria-disabled で押せないことを伝える

移す元に選んだ項目がないときは、ボタンを押せないようにします。disabled 属性で無効にすると、キーボードで「>」を押して移したあと、移す元に選んだ項目が残らないので、押したボタン自身が無効になります。検証環境の Chromium では、押してから 500ms 後には、フォーカスのある要素がない状態(document.activeElement が body)になっていました。

MDN の disabled 属性の説明にあるとおり、disabled を付けたボタンはフォーカスを受け取れません。フォーカスがあるボタンが無効になったとき、Firefox と Safari でも同じようにフォーカスが外れるかは、試していません。TransferList では aria-disabled で「押せない」ことを伝え、押されたときの処理の中で、移せる項目がなければ何もしないようにしています。MDN の aria-disabled の説明にあるとおり、aria-disabled はフォーカスを受け取れるかどうかを変えません。そのぶん、押されたときに何もしない処理と、押せないことが分かる見た目は、自分で書く必要があります。この例では、CSS の button[aria-disabled=true] で半透明にしました。

両方の版で、キーボードだけで「ふりがな」を移しました。Tab キーで「ふりがな」に移ってスペースキーでチェックし、Tab キーで「>」に移って Enter キーを押し、500ms 待ってからフォーカスのある要素を調べました。そのあと、もう一度 Enter キーを押してから Tab キーを押しています(ログは抜粋)。

text
### ボタンを disabled 属性で無効にする版:キーボードだけで操作
Enter から 500ms 後のフォーカス: body(どこにもない)
続けて Tab を押したあとのフォーカス: 表示する列を絞り込む

### ボタンを aria-disabled で無効と伝える版:キーボードだけで操作
Enter から 500ms 後のフォーカス: 選んだ項目を「表示する列」へ移す
続けて Tab を押したあとのフォーカス: 選んだ項目を「表示しない列」へ戻す

aria-disabled の版では、押したあともフォーカスは「>」に残り、もう一度 Enter キーを押しても何も起きませんでした。disabled 属性の版でも、Tab キーを押すと右の絞り込みの入力欄に移ったので、キーボードで操作を続けることはできました。ただ、Enter キーを押してから 500ms 後には、フォーカスのある要素がありませんでした。

移したあとの文は、role="status" を付けた要素に出しています。MDN の status ロールの説明によると、status は aria-live="polite" のライブリージョンとして扱われ、中の文が変わると、スクリーンリーダーは利用者が操作していないときに読み上げます。Playwright で Chromium のアクセシビリティツリーを書き出すと、aria-disabled を付けたボタンは [disabled] になり、移したあとの文は status として入っていました(抜粋)。

text
- button "選んだ項目を「表示する列」へ移す" [disabled]: ">"
- button "選んだ項目を「表示しない列」へ戻す" [disabled]: <
- group "表示する列":
  - text: 表示する列
  - searchbox "表示する列を絞り込む"
  - list:
    - listitem:
      - checkbox "ふりがな"
      - text: ふりがな
  - paragraph: 1件中 0件を選択
- status: 1件を「表示する列」へ移しました

ボタンの文字は「>」「<」だけなので、aria-label で「選んだ項目を「表示する列」へ移す」のように、何が起きるかを言葉で付けています。アクセシビリティツリーでは、この文がボタンの名前になっていました。

​Posted by 86