RoadMovie

write down memos or something I found about tech things

Reactでドロップダウンメニューを実装する

react menu dropdown

Reactでドロップダウンメニューを作成する機会があったのですが、いくつかハマりポイントがあったので書いておきます。まずはじめに要件としては下記のことがあげられます。

  1. ボタンを押すとdropdownメニューが開く
  2. ドロップダウンメニュー以外の場所を押すとblurを発火しメニューを閉じる
  3. メニューボタンはトグルになっていて、open or closeできる
  4. 中のリンクももちろん押せる

なお、Reactはhooksを使用可能なversion以上とします。



実際に書き始めるとなぜこの記事が必要になるか気づかれるかと思うのですが、例えば簡単に下記のように書いてみたとします。

const Menu = () => {
  const [isOpen, setIsOpen] = useState(false)
  
  return (
    <div>
      <img src={Button} onClick={() => setIsOpen(!isOpen)} onBlur={() => setIsOpen(false)} />
      {isOpen &&
        <ul>
          <li>menu1</li>
          <li>menu2</li>
        </ul>
      }
    </div>
  )
}

export default Menu


一見特に問題なさそうに見えるかもしれませんが、これではonBlurが発火しません。メニューの方にonBlurを当てるのが正しいと思い、下記のように修正してみます。

const Menu = () => {
  const [isOpen, setIsOpen] = useState(false)
  
  return (
    <div>
      <img src={Button} onClick={() => setIsOpen(!isOpen)} />
      {isOpen &&
        <ul onBlur={() => setIsOpen(false)} >
          <li>menu1</li>
          <li>menu2</li>
        </ul>
      }
    </div>
  )
}

export default Menu


これでもblurの発火がうまくいかないはずです。これらはメニューにフォーカスが当たらないことが原因なので、そこをuseRefなどを使って修正してみます。

const Menu = () => {
  const [isOpen, setIsOpen] = useState(false)
  const menuRef: any = useRef()

  useEffect(() => {
    isOpen && menuRef.current.focus()
  }, [isOpen])

  return (
    <div>
      <img src={Button} onClick={() => setIsOpen(!isOpen)} />
      {isOpen &&
        <ul  
          onBlur={() => setIsOpen(false)} 
          ref={menuRef}
          tabIndex={1}
         >
          <li><a href="/somewhere">menu1</a></li>
          <li>menu2</li>
        </ul>
      }
    </div>
  )
}

export default Menu


ちょっと動く気配がするようになってきました。ここではrefを使ってfocus対象を決め、isOpenの値が変わったときにfocusを当てるようにしています。忘れがちなポイントとして、tabIndex={1} としておかないと、divなどの要素にfocusを当てれないので注意です。ただ、これだとまだ完璧にはうまく行きません。メニューの中の要素をクリックしてみると、リンクに飛べないことがわかります。これはReact特有の問題のようですが、子をクリックすると親のblurが先に発火してしまうので、子のイベントを発火できなくなるようです。これはちょっとイマイチな解決方法ですが、timeoutを使って回避しましょう。下記が完成形です。

const Menu = () => {
  const [isOpen, setIsOpen] = useState(false)
  const menuRef: any = useRef()

  useEffect(() => {
    isOpen && menuRef.current.focus()
  }, [isOpen])

  return (
    <div>
      <img src={Button} onClick={() => setIsOpen(isOpen ? false : true)} />
      {isOpen &&
        <ul  
          onBlur={() => setTimeout(() => setIsOpen(false), 100)} 
          ref={menuRef}
          tabIndex={1}
         >
          <li><a href="/somewhere">menu1</a></li>
          <li>menu2</li>
        </ul>
      }
    </div>
  )
}

export default Menu

これでうまく1~4の要件が満たせました。(しれっと上で変えましたが、imgにセットしたonClickのところもblurで変な挙動をしないためのポイントです。)