Reactで、親子のチェックボックスの「一部だけ選択」を表す方法

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

親子のチェックボックスで「一部だけ選択」を表すには、子が何個選ばれているかを数えて親の状態を決め、横線を出すときはチェックボックスの indeterminate プロパティを true にします。indeterminate には HTML の属性がないので、ref で受け取った DOM に入れます。このとき Effect の依存配列に [indeterminate] を書くと、親をクリックしたあとに横線が消えたままになることがありました。

作ったのは、親・子・孫と続く、商品の絞り込みに使うようなチェックボックスです(「すべて」「果物」「野菜」「葉もの」など)。選べない項目(在庫なし)も1つ混ぜています。

補足

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

「りんご」を押すと「果物」と「すべて」が横線になり、横線の「果物」を押すと「ぶどう」も選ばれて「果物」は横線のまま、「葉もの」を押すと子の2つが選ばれて「野菜」が横線になり、「すべて」を押すと選べる6件が全部選ばれる。変わった項目と件数がその瞬間だけピンクになる
親子のチェックボックスを4回クリックしたときの表示の変わり方(輪はマウスの位置で、押した瞬間は太くなる。輪と上の表示は GIF 用に重ねたもの)

state には、選んだ葉(いちばん下の項目)の id だけを持つ

親のチェックボックスの状態は state に持ちません。持つのは、いちばん下の項目(子を持たない項目。以下「葉」と呼びます)のうち、選んだものの id の Set だけです。親が「全部選択」「一部だけ選択」「何も選んでいない」のどれになるかは、描画のたびに数えて決めます。親の状態も state に持つと、子を1つ押すたびに祖先までさかのぼって書き換える処理が必要になるため、持たないことにしました。

木の形は、子を children に入れたオブジェクトで表しました。

ts
export type TreeNode = {
  id: string;
  label: string;
  disabled?: boolean;
  children?: TreeNode[];
};

export const tree: TreeNode = {
  id: 'all',
  label: 'すべて',
  children: [
    {
      id: 'fruit',
      label: '果物',
      children: [
        { id: 'apple', label: 'りんご' },
        { id: 'grape', label: 'ぶどう' },
        { id: 'peach', label: 'もも(在庫なし)', disabled: true },
      ],
    },
    // 野菜(葉もの・根菜)は省略
  ],
};

// node の下にある、子を持たない項目(葉)をすべて集める
export function leaves(node: TreeNode): TreeNode[] {
  return node.children ? node.children.flatMap(leaves) : [node];
}

1つの項目とその下を描くのが CheckTree です。自分の下の葉のうち、選ばれているものを数えて checked と indeterminate を決め、子があれば自分自身を呼んで下の階層を描きます。

tsx
import { TriCheckbox } from './TriCheckbox';
import { leaves } from './tree';
import type { TreeNode } from './tree';

type Props = {
  node: TreeNode;
  selected: ReadonlySet<string>;
  onToggle: (node: TreeNode) => void;
};

export function CheckTree({ node, selected, onToggle }: Props) {
  // 親の状態は持たずに、下の葉が何個選ばれているかから毎回計算する
  const all = leaves(node);
  const count = all.filter((leaf) => selected.has(leaf.id)).length;
  const checked = count > 0 && count === all.length;
  const indeterminate = count > 0 && count < all.length;

  return (
    <li>
      <TriCheckbox
        label={node.label}
        checked={checked}
        indeterminate={indeterminate}
        disabled={all.every((leaf) => leaf.disabled)}
        onChange={() => onToggle(node)}
      />
      {node.children && (
        <ul>
          {node.children.map((child) => (
            <CheckTree key={child.id} node={child} selected={selected} onToggle={onToggle} />
          ))}
        </ul>
      )}
    </li>
  );
}

数えるのは葉だけなので、孫の階層があっても考え方は変わりません。検証環境で「葉もの」を押すと、「葉もの」はチェック、「野菜」と「すべて」は横線になりました。続けて「根菜」を押すと、「野菜」もチェックに変わりました。

text
葉ものをクリック(押した直後: checked=true indeterminate=false)
  → すべてー 果物☐ 野菜ー 葉もの☑ 根菜☐ | 選んだもの:2件 / キャベツ、ほうれん草
根菜をクリック(押した直後: checked=true indeterminate=false)
  → すべてー 果物☐ 野菜☑ 葉もの☑ 根菜☑ | 選んだもの:4件 / キャベツ、ほうれん草、にんじん、だいこん

ログの ☑ はチェック、ー は横線、☐ は空のチェックボックスです。テスト用のスクリプトで、各チェックボックスの indeterminate と checked を読んで書き出しました。

横線は、描画のたびに DOM のプロパティへ入れる

indeterminate は、HTML の属性ではなく JavaScript から DOM のプロパティとして入れるものです(MDN のチェックボックスの説明)。そこで、indeterminate を props で受け取るチェックボックスの部品 TriCheckbox を作り、中で DOM に入れています。

tsx
import { useLayoutEffect, useRef } from 'react';

type Props = {
  label: string;
  checked: boolean;
  indeterminate: boolean;
  disabled?: boolean;
  onChange: () => void;
};

export function TriCheckbox({ label, checked, indeterminate, disabled, onChange }: Props) {
  const ref = useRef<HTMLInputElement>(null);

  // indeterminate は HTML の属性がないので、描画のたびに DOM のプロパティへ入れ直す
  useLayoutEffect(() => {
    ref.current!.indeterminate = indeterminate;
  });

  return (
    <label>
      <input ref={ref} type="checkbox" checked={checked} disabled={disabled} onChange={onChange} />
      {label}
    </label>
  );
}

横線のときは checked を false にしています。MDN の indeterminate プロパティの説明によると、indeterminate は見た目だけの状態で、フォームで送信されるかどうかは checked で決まります。

useEffect ではなく useLayoutEffect を使ったのは、ブラウザが画面を描き直す前に実行されると決まっているからです。useEffect との間で見た目に違いが出るかどうかは、検証環境では確かめていません。

依存配列に indeterminate を書くと、横線が消えたままになる

useLayoutEffect に依存配列を書いていないのには理由があります。useLayoutEffect のリファレンスの引数の説明にあるとおり、依存配列を省くと、コンポーネントが描画されるたびに実行されます。ここで [indeterminate] と書くと、indeterminate の値が変わったときにしか実行されません。

困るのは、横線のチェックボックスを押したあとも、横線のままにしたいときです。ブラウザは、チェックボックスがクリックされると checked を反転し、indeterminate を false にします。これは HTML の仕様で、チェックボックスがクリックされたときの処理として決まっています。検証環境でも、横線の「果物」を押した直後は checked=true indeterminate=false になっていました。

React は checked を props の値(ここでは false)に戻しますが、indeterminate には触れません。この例では、在庫なしの「もも」を選べないので、「りんご」だけを選んだ「果物」を押すと、「りんご」と「ぶどう」が選ばれて、「果物」は横線のままになるはずです。依存配列に [indeterminate] を書いた版では、indeterminate は押す前も後も true で変わらないため、DOM に入れ直されません。その結果、2件選んでいるのに、「果物」が空のチェックボックスに見えました。

text
りんごをクリック → すべてー 果物ー 野菜☐ 葉もの☐ 根菜☐ | 選んだもの:1件 / りんご
果物をクリック → すべてー 果物☐ 野菜☐ 葉もの☐ 根菜☐ | 選んだもの:2件 / りんご、ぶどう

依存配列を書かない版では、同じ操作のあとも「果物」は横線のままでした。

上は依存配列に [indeterminate] を書いた版で、りんごとぶどうにチェックが入っているのに果物が空のチェックボックスになっている。下は描画のたびに入れ直す版で、果物は横線になっている
「りんご」「果物」の順に押したあとの表示を、2つの版で比べたもの
注意

選べない子がある親は、横線のときに押しても、押したあと横線のままのことがあります。そのとき依存配列に [indeterminate] を書いていると、親のチェックボックスだけが空に見え、子の選択と食い違います。

選べない子があるとき、親を押したらどうするか

親を押したときの処理は App の toggle に書きました。押された項目の下にある、選べる葉(disabled でないもの)がすべて選ばれていれば外し、そうでなければすべて選びます。

tsx

function toggle(node: TreeNode) {
    // 選べない項目(disabled)は、親を押しても変えない
    const targets = leaves(node).filter((leaf) => !leaf.disabled);
    const allOn = targets.every((leaf) => selected.has(leaf.id));
    const next = new Set(selected);
    for (const leaf of targets) {
      if (allOn) next.delete(leaf.id);
      else next.add(leaf.id);
    }
    setSelected(next);
  }

押したあとに選ぶか外すかは、イベントの e.target.checked ではなく、state から決めています。選べない子がある親は、選べる子を全部選んでも横線のままです。そのため、ブラウザが反転した checked の値は、次にどうしたいかと合わないことがあります。検証環境では、「りんご」だけを選んだ状態から「果物」を2回続けて押すと、1回目で「ぶどう」も選ばれ、2回目で両方が外れました。どちらの回も、押した直後の checked は true でした。

text
果物をクリック(押した直後: checked=true indeterminate=false)
  → すべてー 果物ー 野菜☐ 葉もの☐ 根菜☐ | 選んだもの:2件 / りんご、ぶどう
果物をクリック(押した直後: checked=true indeterminate=false)
  → すべて☐ 果物☐ 野菜☐ 葉もの☐ 根菜☐ | 選んだもの:0件 / なし

同じ理由で、「すべて」も、押して選べる6件を全部選んだあと横線のままでした。もう一度押すと、全部外れました。親の表示を数えるときに選べない葉を数から除けば、親にチェックを付けることもできますが、その書き方は試していません。

キーボードでの操作と、アクセシビリティツリーでの状態

部品の中身はふつうの <input type="checkbox"> なので、キーボードでもそのまま操作できました。「りんご」だけを選んだ状態で、「すべて」から Tab キーで「果物」に移り、スペースキーを押すと、クリックと同じく「ぶどう」も選ばれました。もう一度押すと、両方が外れました。

「りんご」だけを選んだ状態で、Playwright で Chromium のアクセシビリティツリーを書き出すと、横線の「すべて」と「果物」は checked=mixed(一部だけ選択)になっていました(抜粋)。

text
    - checkbox "すべて" [checked=mixed]
    - text: すべて
    - list:
      - listitem:
        - checkbox "果物" [checked=mixed]
        - text: 果物
        - list:
          - listitem:
            - checkbox "りんご" [checked]

検証した Chromium では、aria-checked="mixed" を自分で書かなくても、この状態になりました。mixed は WAI-ARIA の Authoring Practices のチェックボックスのパターンで、一部だけ選ばれた状態として説明されているものです。同じページでは、全体のチェックボックスでグループの全部を選ぶ・外す使い方も紹介されています。

​Posted by 86