Reactで高機能？だけどお手軽シンプルなModalWindowを作ってみた （おまけでSideMenuもあるよ）

第1回Shankouのやってみたシリーズ(第2回は未定)！
今回はReactで作るModalWindowです。

少しだけ汎用的に使えるようなWindowmaskコンポーネントと、それをベースとしたModalWindow＆SideMenuを実装していきます。

実装したのものはこんな感じ↓

![ModalWindow](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3639915/c8651ff8-a207-602d-f619-79256abc9fc2.png)

![SideMenu](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3639915/202ae912-1cd6-068a-fbdd-bc4651314990.png)

シンプルイズベスト！

## body-scroll-lock
少し前にReactでModalWindowを実装する機会がありまして、みんな思うことを私も思いました。

Modal系って意外と実装が面倒！
特にスクロールの制御！
スマホのタッチイベント対応も考えなきゃいけない！

面倒だからと簡易実装するとModalが出ているのに背景がスクロールして動いちゃう…
カッコ悪い… (要件的にもアウト

そんなときに見つけたのがこちらライブラリ！

[body-scroll-lock](https://github.com/willmcpo/body-scroll-lock)

https://github.com/willmcpo/body-scroll-lock

:::note warn
こちらのライブラリの更新は3年前で止まっておりますので、使用する際はその点を踏また上でご利用下さい。現状大きな問題はなさそうという認識です。
:::

今回はこちらのライブラリを使用していきます。
実装にはTailwindcssを利用していますのでご了承下さい。

### ライブラリのインストール
body-scroll-lockをnpmでインストールしていきます。
[@types/body-scroll-lock](https://www.npmjs.com/package/@types/body-scroll-lock)はTypescript用の型情報です。

```
npm i body-scroll-lock
npm i --save-dev @types/body-scroll-lock
```

## Windowmask.tsx
まずはスクロールを制御する`body-scroll-lock`をUIに落とし込んだWindowmaskコンポーネントです。
このコンポーネントを複数箇所で利用すると、ライフサイクルの影響により意図しない形でスクロールロックの解除(`clearAllBodyScrollLocks`)が暴発してしまうことがあったので、呼び出し管理で対応しました(もっといい方法があったら教えて下さい)。
`enableScrollTargetRef`は部分的にスクロールの許可を指定するものです。

このコンポーネントがModalWindowとSideMenuのベースとなります。

```react:Windowmask.tsx
'use client'

import React, { useEffect, ReactNode } from 'react'
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'

const BODY_SCROLL_LOCK_OPTIONS = {
  // スクロールロック時、body にスクロールバー分の padding-right を追加する
  // スクロールロック時に背面の要素が変に右に移動するといった事象が防げる
  reserveScrollBarGap: true,
}

const km = new (class KM {
  private keyList: Array<string>

  constructor() {
    this.keyList = new Array<string>()
  }

  get length(): number {
    return this.keyList.length
  }

  containsKey(key: string): boolean {
    return this.keyList.includes(key)
  }

  addKey(key: string) {
    if (!this.containsKey(key)) {
      this.keyList.push(key)
    }
  }

  removeKey(key: string) {
    this.keyList = this.keyList.filter((_) => _ !== key)
  }
})

type WindowmaskProps = {
  uniqueKey: string
  isShown: boolean
  children: ReactNode
  enableScrollTargetRef: React.RefObject<HTMLDivElement>
  close: Function
}

const Windowmask: React.FC<WindowmaskProps> = ({ uniqueKey, isShown, children, enableScrollTargetRef, close }) => {
  useEffect(() => {
    if (isShown) {
      // 呼び出し登録
      km.addKey(uniqueKey)
      // スクロールロックを掛ける
      disableBodyScroll(enableScrollTargetRef.current as HTMLElement, BODY_SCROLL_LOCK_OPTIONS)
    } else {
      // 呼び出し登録を削除
      if (km.containsKey(uniqueKey)) {
        km.removeKey(uniqueKey)
      }
      // 他からの呼び出しがなければスクロールロックを全解除
      if (km.length === 0) {
        clearAllBodyScrollLocks()
      }
    }
  }, [isShown, enableScrollTargetRef])

  return isShown ? (
    <div className={'fixed top-0 left-0 z-50 w-full h-full bg-black/40'} onClick={() => close()}>
      {children}
    </div>
  ) : null
}
export default Windowmask
```

## ModalWindow.tsx
こちらがModalWindowの本体、Windowmask上にポップアップウィンドウを表示させるコンポーネントとなります。
`enableScrollTargetRef`には、`Window body`部分を指定しています。このとき`overflow-auto`をかけるのがポイントです。
`Layer`部分で`max-h-full`を指定しているので、Windowの最大サイズは画面内に収まるように表示され、`enableScrollTargetRef`で指定された`Window body`部分がスクロールできる形で表示されます。

```react:ModalWindow.tsx
'use client'

import React, { useRef, ReactNode } from 'react'
import Windowmask from './Windowmask'

type ModalWindowProps = {
  isShown: boolean
  title?: string
  children?: ReactNode
  closeModal: Function
}

const ModalWindow: React.FC<ModalWindowProps> = ({ isShown, title, children, closeModal }) => {
  const enableScrollTargetRef = useRef(React.createRef<HTMLDivElement>()).current

  return (
    <Windowmask isShown={isShown} uniqueKey={'modal-window'} enableScrollTargetRef={enableScrollTargetRef} close={closeModal}>
      {/* Layer */}
      <article className='relative flex justify-center items-center w-full h-full max-h-full p-8'>
        {/* Window */}
        <div
          className='relative flex flex-col w-full max-w-4xl max-h-full overflow-hidden text-gray-400 bg-white rounded-lg shadow'
          onClick={(e) => e.stopPropagation()}
        >
          {/* Window header */}
          <div className='flex items-start justify-between p-4 pr-2 bg-white border-b rounded-t'>
            <h1 className='text-xl font-semibold'>{title}</h1>
            <button type='button'
              className='inline-flex justify-center items-center ml-auto w-8 h-8 min-w-[2rem] min-h-[2rem] text-sm bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-full'
              onClick={() => closeModal()}
            >✕<span className='sr-only'>Close modal</span></button>
          </div>

          {/* Window body */}
          <div className='p-4 overflow-auto' ref={enableScrollTargetRef}>
            {children}
          </div>
        </div>
      </article>
    </Windowmask>
  )
}
export default ModalWindow
```

使い方はこんな感じになります。

```react
'use client'

import { useState } from 'react'
import ModalWindow from '@/components/ModalWindow'

export default function Home() {
  const [isModalWindowShown, setIsModalWindowShown] = useState(false)
  
  const closeModalHandler = () => {
    setIsModalWindowShown(false)
  }

  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24 bg-white text-gray-400'>
      <div className='h-[2000px]'>
        <button className='m-2 p-2 border rounded' onClick={() => setIsModalWindowShown(true)}>Open ModalWindow</button>
      </div>

      <ModalWindow isShown={isModalWindowShown} title={'Modal Window'} closeModal={closeModalHandler}>
        <div className='h-[2000px]'>
          ModalWindow
        </div>
      </ModalWindow>
    </main>
  )
}
```

## SideMenu.tsx
続いてSideMenuの実装です。ModalWindow同様にWindowmask上にPanelを表示させるコンポーネントを作成していきます。
今回はLayerで。`justify-end`を指定して右端部に表示させています。
基本的な作りはModalWindowとあまり変わりません。

```react:SideMenu.tsx
'use client'

import React, { useRef, ReactNode } from 'react'
import Windowmask from './Windowmask'

type SideMenuProps = {
  isShown: boolean
  children?: ReactNode
  closeSideMenu: Function
}

const SideMenu: React.FC<SideMenuProps> = ({ isShown, children, closeSideMenu }) => {
  const enableScrollTargetRef = useRef(React.createRef<HTMLDivElement>()).current

  return (
    <Windowmask isShown={isShown} uniqueKey={'modal-window'} enableScrollTargetRef={enableScrollTargetRef} close={closeSideMenu}>
      {/* Layer */}
      <article className='relative flex justify-end w-full h-full max-h-full'>
        {/* Side Panel */}
        <div
          className='relative p-2 flex flex-col w-full max-w-96 max-h-full min-h-screen text-gray-400 bg-white animate-slide-left'
          onClick={(e) => e.stopPropagation()}
        >
          {/* Menu header */}
          <div className='flex justify-end p-2'>
            <button
              className='flex justify-center items-center p-1 w-8 h-8 border rounded-full hover:bg-gray-100 hover:text-gray-900 focus:ring-2 focus:outline-none focus:ring-blue-300'
              onClick={() => closeSideMenu()}
            >✕</button>
          </div>
          {/* Menu body */}
          <div className='p-4 overflow-auto' ref={enableScrollTargetRef}>
            {children}
          </div>
          {/* Menu header */}
        </div>
      </article>
    </Windowmask>
  )
}
export default SideMenu
```

スライドインのアニメーションは`tailwind.config.ts`で設定していきます。
必要なのは`animation`と`keyframes`です。`animation`で設定されたプロパティはclassName部に記述することで適用されます。今回は右端部に左へスライドさせながら表示させたいので、`slide-left`を使用しています。このときclassNameに記述するのはプレフィックスとして`animate-`を付けた`animate-slide-left`になります。

```typescript:tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      backgroundImage: {
        'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
        'gradient-conic':
          'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
      },
      animation: {
        'slide-right': 'slide-right 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slide-left': 'slide-left 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
      },
      keyframes: {
        'slide-right': {
          '0%': {
            transform: 'translateX(-8rem)',
          },
          to: {
            transform: 'translateX(0)',
          },
        },
        'slide-left': {
          '0%': {
            transform: 'translateX(8rem)',
          },
          to: {
            transform: 'translateX(0)',
          },
        },
      },
    },
  },
  plugins: [],
};
export default config;
```

使い方はこんな感じです。

```react
'use client'

import { useState } from 'react'
import SideMenu from '@/components/SideMenu'

export default function Home() {
  const [isSideMenuShown, setIsSideMenuShown] = useState(false)

  const closeSideMenuHandler = () => {
    setIsSideMenuShown(false)
  }

  return (
    <main className='flex min-h-screen flex-col items-center justify-between p-24 bg-white text-gray-400'>
      <div className='h-[2000px]'>
        <button className='m-2 p-2 border rounded' onClick={() => setIsSideMenuShown(true)}>Open SideMenu</button>
      </div>

      <SideMenu isShown={isSideMenuShown} closeSideMenu={closeSideMenuHandler}>
        <ul className='h-[2000px]'>
          <li>SideMenu1</li>
          <li>SideMenu2</li>
          <li>SideMenu3</li>
        </ul>
      </SideMenu>
    </main>
  )
}

```

## まとめ
Reactについてはまだまだ勉強中、試行錯誤の身です。
今回のコンポーネントはお手軽に実装できるということだけではなく、自前でデザインや動きを改造したいという人にとっても分かりやすいようなシンプル構造を心がけました。

デモをGithubに上げておりますので、ご参考までに！

https://github.com/shankou-ow/react-windowmask-demo