💊
Headless UIのComboboxに仮想スクロールを導入する
はじめに
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