第1回Shankouのやってみたシリーズ(第2回は未定)!
今回はReactで作るModalWindowです。
少しだけ汎用的に使えるようなWindowmaskコンポーネントと、それをベースとしたModalWindow&SideMenuを実装していきます。
実装したのものはこんな感じ↓
シンプルイズベスト!
body-scroll-lock
少し前にReactでModalWindowを実装する機会がありまして、みんな思うことを私も思いました。
Modal系って意外と実装が面倒!
特にスクロールの制御!
スマホのタッチイベント対応も考えなきゃいけない!
面倒だからと簡易実装するとModalが出ているのに背景がスクロールして動いちゃう…
カッコ悪い… (要件的にもアウト
そんなときに見つけたのがこちらライブラリ!
こちらのライブラリの更新は3年前で止まっておりますので、使用する際はその点を踏また上でご利用下さい。現状大きな問題はなさそうという認識です。
今回はこちらのライブラリを使用していきます。
実装にはTailwindcssを利用していますのでご了承下さい。
ライブラリのインストール
body-scroll-lockをnpmでインストールしていきます。
@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のベースとなります。
'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
部分がスクロールできる形で表示されます。
'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
使い方はこんな感じになります。
'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とあまり変わりません。
'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
になります。
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;
使い方はこんな感じです。
'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に上げておりますので、ご参考までに!