Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

listboxをアクセシブルに実装したい

listbox というUIパターンを実装しました。ちょっと凝ったラジオボタンリストみたいなものです↓

仕様

クリック時の挙動はだいたいラジオボタンと同じです。

キーボード操作時がラジオボタンと違っています。ラジオボタンではフォーカス移動と同時に選択が行われますが、このリストボックスではフォーカスを移動させただけでは選択されず、スペースで決定することで初めて選択されます。

実装

実装はこんな感じです。azukiazusaさんの実装を大いに参考にしてます→ 【React】アクセシビリティに考慮したリストボックスを実装する (コンボボックスがない、ステートではなくブラウザのフォーカス機能でフォーカス管理をしているあたりが異なります)

const TinyListbox = ({
  options,
  selectedIndex,
  onChangeIndex,
}: {
  options: string[];
  selectedIndex: number;
  onChangeIndex: (index: number) => void;
}) => {
  const optionsRef = useRef<(HTMLDivElement | null)[]>([]);

  return (
    <div
      role="listbox"
      tabIndex={-1}
      onKeyDown={(e) => {
        const focusedIndex = optionsRef.current.findIndex(
          (ref) => document.activeElement === ref
        );
        if (focusedIndex === -1) return;

        // 上下キーでフォーカスを移動させる
        switch (e.key) {
          case "ArrowDown":
            e.preventDefault();
            optionsRef.current[(focusedIndex + 1) % options.length]?.focus();
            break;
          case "ArrowUp":
            e.preventDefault();
            optionsRef.current[
              (focusedIndex - 1 + options.length) % options.length
            ]?.focus();
            break;
          default:
            break;
        }
      }}
      style={{ display: "flex", gap: "8px", outline: "none" }}
    >
      {options.map((option, index) => (
        <div
          key={option}
          role="option"
          aria-selected={selectedIndex === index}
          tabIndex={selectedIndex === index ? 0 : -1}
          onKeyDown={(e) => {
            // フォーカスされてるときにスペースあるいはエンターされたら選択する
            switch (e.key) {
              case "Enter":
              case " ":
                e.preventDefault();
                onChangeIndex(index);
                break;
            }
          }}
          onClick={() => onChangeIndex(index)}
          style={{
            padding: "4px 8px",
            background: selectedIndex === index ? "#ddd" : "#fff",
            cursor: "pointer",
          }}
          ref={(el) => {
            optionsRef.current[index] = el;
            return () => (optionsRef.current[index] = null);
          }}
        >
          {option}
        </div>
      ))}
    </div>
  );
};

参考