Headless UI のドロップダウンメニューで next/link を使うとキーボード操作での挙動がおかしくなる件を解決する

Headless UI のドロップダウンメニューにおいて、 Menu.Item コンポーネント内で next/link コンポーネントを使うとキーボード操作でメニュー内のリンクが動作しなくなる問題に遭遇しましたが、解決したという話。

Headless UI - Menu (Dropdown)

最近は個人的に Tailwind CSS を Next.js との組み合わせで好んで使っていて、その中で、Headless UI を組み合わせることもよくあるんですが、つい最近、たまたま Headless UI を使ってドロップダウンメニューを実装したときに、Next.js の next/link コンポーネントを併用するとキーボード操作でちょっと挙動がおかしいというか、正常に動作しなくなる現象にぶち当たりました。

で、ちょっと調べたら 1年くらい前の時点で Headless UI GitHub リポジトリの Issue (下記リンク) に同じ問題を報告した人がいて、どストライクな解決策も示されていました (ちなみに回答してくれている Robin Malfait 氏は Tailwind Labs の中の人です)。

今回は備忘録として、この問題を解決するために行った修正について、簡単に書いておこうと思います。

問題が発生したソースコード

わかりやすいようにソースコードは大幅に変更していますが、当初の実装としては下記のような感じにしていました。記事のカテゴリ一覧をドロップダウンメニューで表示するだけの簡単なコンポーネントです。

// 正常に動作しないソースコード
import Link from 'next/link'
import { Fragment } from 'react'
import { Menu, Transition } from '@headlessui/react'

const categories = [
  {
    name: 'カテゴリー01',
    url: '/category01/',
  },
  {
    name: 'カテゴリー02',
    url: '/category02/',
  },
  {
    name: 'カテゴリー03',
    url: '/category03/',
  },
  {
    name: 'カテゴリー04',
    url: '/category04/',
  },
  {
    name: 'カテゴリー05',
    url: '/category05/',
  },
]

export default function Category() {
  return (
    <Menu as="div">
      {({ open }) => (
        <>
          <div>
            <Menu.Button>
              記事カテゴリの一覧を表示
            </Menu.Button>
          </div>
          <Transition
            show={open}
            as={Fragment}
            enter="transition ease-out duration-100"
            enterFrom="transform opacity-0 scale-95"
            enterTo="transform opacity-100 scale-100"
            leave="transition ease-in duration-75"
            leaveFrom="transform opacity-100 scale-100"
            leaveTo="transform opacity-0 scale-95"
          >
            <Menu.Items as="ul">
              {categories.map((category, index) => (
                <Menu.Item key={index} as="li">
                  {({ active }) => (
                    <Link href={category.url}>
                      <a className={`${active && 'bg-blue-50'}`}>
                        {category.name}
                      </a>
                    </Link>
                  )}
                </Menu.Item>
              ))}
            </Menu.Items>
          </Transition>
        </>
      )}
    </Menu>
  )
}

これで、マウス操作に関しては問題なく動作するんですが、キーボードで操作したときに、展開したメニュー内のリンクを選択して Enter キーや Space キーを押しても、メニューが閉じてしまうだけでリンク先に移動してくれません。

原因は上で紹介したリンク先の回答にも書かれているんですが、<Menu.Item> 直下に a 要素がある場合には受け渡される props が、Link コンポーネントが間に挟まることでうまく受け渡されなくなると。<Menu.Item> 直下の a 要素には、クリックイベントが付与されるんですが、それがなくなってしまうことでキーボード操作ができなくなっていたというわけです。

で、この問題に当たったときに色々と試してみたんですが、<Menu.Item> に対して as="li" を付与している場合、Link コンポーネントを使わず、<Menu.Item> 直下に a 要素を置いてもやっぱりダメだったので、要するに <Menu.Items> が生成する包含ブロックの直下に a 要素っていう構造じゃないとそのままではうまく動作しないみたいですね。

問題を解決する

ということで、Issue に上がっていた解決策を少しアレンジして、前述した 「正常に動作しないソースコード」 を下記のように修正して問題を解決しました。

まず、別途 MenuLink.js を作成し、下記のように、MenuLink コンポーネントを作成します (ファイル名やコンポーネント名は任意)。

import Link from 'next/link'

export default function MenuLink(props) {
  const { href, children, ...rest } = props
  return (
    <li>
      <Link href={href}>
        <a {...rest}>{children}</a>
      </Link>
    </li>
  )
}

まぁあれです、細かいこというとこのコンポーネント、ul 要素の中で呼び出されないと HTML の文書構造的におかしくなっちゃう作りになっていて、あまりよろしくないんですが、今回は問題を解決することを優先する感じに。まずは動くようにしてから調整すればいいかなと思います。

これをドロップダウンメニュー用のコンポーネントで読み込みつつ、下記のようにソースコードを修正します。

// 修正版ソースコード
import MenuLink from '../components/MenuLink'
import { Fragment } from 'react'
import { Menu, Transition } from '@headlessui/react'

const categories = [
  {
    name: 'カテゴリー01',
    url: '/category01/',
  },
  ...略...
]

export default function Category() {
  return (
    <Menu as="div">
      {({ open }) => (
        <>
          <div>
            <Menu.Button>
              記事カテゴリの一覧を表示
            </Menu.Button>
          </div>
          <Transition
            show={open}
            as={Fragment}
            enter="transition ease-out duration-100"
            enterFrom="transform opacity-0 scale-95"
            enterTo="transform opacity-100 scale-100"
            leave="transition ease-in duration-75"
            leaveFrom="transform opacity-100 scale-100"
            leaveTo="transform opacity-0 scale-95"
          >
            <Menu.Items as="ul">
              {categories.map((category, index) => (
                <Menu.Item key={index}>
                  {({ active }) => (
                    <MenuLink href={category.url}>
                      <a className={`${active && 'bg-blue-50'}`}>
                        {category.name}
                      </a>
                    </MenuLink>
                  )}
                </Menu.Item>
              ))}
            </Menu.Items>
          </Transition>
        </>
      )}
    </Menu>
  )
}

Link コンポーネントをやめて、新しく作った MenuLink コンポーネントを使っているのと、<Menu.Item> から as="li" を削除するという 2 箇所の変更ですね。

この変更を加えたことで、キーボード操作でもドロップダウンメニューが正しく動作するようになりました。

関連エントリー

記事をここまで御覧頂きありがとうございます。
この記事が気に入ったらサポートしてみませんか?