💊

Headless UIのComboboxに仮想スクロールを導入する

2024/04/21に公開

はじめに

Comboboxなどのプルダウンで、リスト内の件数が多い場合に動作が重くなってしまい、ユーザー体験が悪くなってしまう可能性があります。

そこで、リスト表示を仮想スクロール化することで高速に表示することができます。

普段はHeadless UIのComboboxを使用していますが、仮想スクロールを実現するためのオプションが用意されてないので、TanStack Virtualを使用して実装していきます。

使用技術

  • @headlessui/react(1.7.4)
  • @tanstack/react-virtual(3.0.0-beta.68)

実装

公式ドキュメントを参考に、Comboboxコンポーネント作成しています。
optionsは仮想スクロール化確認のため1万件用意しました。

Combobox.tsx
import { Combobox as HuiCombobox, Transition } from '@headlessui/react';
import clsx from 'clsx';
import { Fragment, useState } from 'react';
import { FaAngleDown } from 'react-icons/fa';
import VirtualizedComboboxOption from '../presentations/Input/Combobox/VirtualizedComboboxOption';

type Option = {
  value: string;
  label: string;
};

const options: Option[] = [...Array(10000)].map((_, i) => ({
  value: `value-${i + 1}`,
  label: `label-${i + 1}_label-${i + 1}_label-${i + 1}`,
}));

const Combobox = () => {
  const [value, setValue] = useState<string>(options[0].value ?? '');
  const [query, setQuery] = useState<string>('');

  /** 入力された文字列でフィルターしたoptions */
  const filterQueryOptions =
    query === ''
      ? options
      : options.filter((option) =>
          option.label
            .toLowerCase()
            .replace(/\s+/g, '')
            .includes(query.toLowerCase().replace(/\s+/g, '')),
        );

  return (
    <HuiCombobox value={value} onChange={(newValue: string) => setValue(newValue)}>
      {({ open }) => (
        <div className={clsx('w-[170px]', 'text-[12px]', 'relative')}>
          <HuiCombobox.Button
            as='div'
            className={clsx(
              'h-[100%] w-[100%] px-[10px] py-[5px]',
              'flex items-center justify-between',
              'bg-white',
              'group rounded-[6px] border border-gray-200 hover:border-gray-400',
              'focus-within:where:border-blue-400',
            )}
          >
            {
              <HuiCombobox.Input
                onChange={(event) => {
                  setQuery(event.target.value);
                }}
                displayValue={(optionValue: string) =>
                  options.find((option) => option.value === optionValue)?.label ?? ''
                }
                className={clsx('flex-1', 'focus:outline-none', 'cursor-pointer', 'truncate')}
                autoComplete='off'
              />
            }
            <FaAngleDown size={19} className={clsx(open && 'rotate-180')} />
          </HuiCombobox.Button>
          <Transition as={Fragment} afterLeave={() => setQuery('')}>
            <HuiCombobox.Options
              as='div'
              className={clsx(
                'absolute left-0 z-[1000]',
                'mt-[5px] w-[100%]',
                'text-[12px] text-gray-3',
                'bg-white',
                'rounded-[6px] shadow-lg',
                'py-[6px]',
              )}
            >
              {filterQueryOptions.length === 0 && query !== '' ? (
                <div className={clsx('w-[100%] py-[5px] px-[10px]')}>
                  {'検索結果はありません。'}
                </div>
              ) : (
                // 仮想スクロール用のコンポーネント
                <VirtualizedComboboxOption options={filterQueryOptions} />
              )}
            </HuiCombobox.Options>
          </Transition>
        </div>
      )}
    </HuiCombobox>
  );
};

export default Combobox;

ポイントとして、仮想スクロールの部分(VirtualizedComboboxOption)は別コンポーネントとして管理する必要があります。(optionを表示とrefの設定が同じタイミングでされる必要があるため)

VirtualizedComboboxOption.tsx
import { Combobox as HuiCombobox } from '@headlessui/react';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { useRef } from 'react';

type Option = {
  value: string;
  label: string;
};

type Props = {
  options: Option[];
};

const VirtualizedComboboxOption = ({ options }: Props) => {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: options.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 45,
    // スクロール領域の外側にレンダリングするアイテムの数を指定
    overscan: 5,
  });

  return (
    <div ref={parentRef} className={clsx('max-h-[150px]', 'overflow-y-auto')}>
      <div
        className={clsx('relative w-[100%]')}
        style={{
          // 要素全体の高さ指定
          height: `${virtualizer.getTotalSize()}px`,
        }}
      >
        <ul
          className={clsx('absolute left-0 w-[100%]')}
          style={{
            // 表示項目の開始高さをtopに指定することで、スクロール時に表示位置を変更している
            top: `${virtualizer.getVirtualItems()[0]?.start ?? 0}px`,
          }}
        >
          {virtualizer.getVirtualItems().map((virtualRow) => (
            <HuiCombobox.Option
              key={virtualRow.key}
              data-index={virtualRow.index}
              value={options?.[virtualRow.index].value}
              // NOTE: measureElementでオプション1つの高さを動的に取得するため、refを渡す
              ref={virtualizer.measureElement}
            >
              {({ selected, active }) => (
                <span
                  className={clsx(
                    'block',
                    'py-[5px] px-[10px]',
                    'break-words',
                    'cursor-pointer',
                    !selected && 'hover:bg-sky-200',
                    active && !selected && 'bg-sky-200',
                    selected && 'bg-sky-300',
                    // 空文字の場合は高さを調整
                    options?.[virtualRow.index].label === '' && 'h-[28px]',
                  )}
                >
                  {/* エラーハンドリング */}
                  {options?.[virtualRow.index].label ?? '表示エラー'}
                </span>
              )}
            </HuiCombobox.Option>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default VirtualizedComboboxOption;

Comboboxのメニュー部が仮想スクロール化された、確認できます。
また高さも自動で計算されるので、改行がある場合でも問題ないです。

終わりに

TanStack Virtualを使用することで簡単に仮想スクロールを実装できました。

HeadlessUIコンポーネントのListbox(Select)やMenu(Dropdown)でも同様に仮想スクロール化することができるので興味のある方はぜひ試してください。

Discussion