🐱
[React]クリックしたらメニューがすぐ側に出現するUXを汎用的なコンポーネントに落とし込んでみた
はじめに
しばしば見かける以下のようなUXを、誰でも扱えるコンポーネントとして実装してみたので解説します。
完成系
- 以下にて、ソースコードを公開しております。
解説
- 以下、該当のコンポーネントを
MenuModalPortal
と呼称します。
Props設計
<MenuModalPortal
buttonElement={
<button type='button' onClick={onClickMock}>
Click me
</button>
}
menuElement={
<ul>
<li>test1</li>
<li>test2</li>
</ul>
}
verticalPosition='top'
horizontalPosition='left'
verticalOffset={8}
horizontalOffset={8}
area-label='Menu Test'
/>
-
buttonElement
: トリガーとなるボタン要素 -
menuElement
: 表示するメニュー要素 -
verticalPosition
: 縦方向のmenu表示位置-
top
:menu要素の上辺が、ボタン要素の下辺に沿う位置 -
bottom
:menu要素の下辺が、ボタン要素の上辺に沿う位置
-
-
horizontalPosition
: 横方向のmenu表示位置-
left
:menu要素の左辺が、ボタン要素の左辺に沿う位置 -
bottom
:menu要素の右辺が、ボタン要素の右辺に沿う位置
-
-
verticalOffset
: 縦方向の位置調整、px単位 -
horizontalOffset
: 横方向の位置調整、px単位 -
area-label
(Optional): アクセシビリティ用の要素
ポイント
- ボタンやメニューといった見た目に関わる部分を、ロジックから分離している
- 要件に合わせて柔軟な指定が可能となる
ロジック設計
Presentational Component
// ※styles, useMenuModal は内製モジュール故import文は割愛しています
import type { FC, ReactElement, ReactNode } from 'react';
type Props = {
buttonElement: ReactElement;
menuElement: ReactNode;
verticalPosition: 'top' | 'bottom';
horizontalPosition: 'left' | 'right';
verticalOffset?: number;
horizontalOffset?: number;
'area-label'?: string;
};
export const MenuModalPortal: FC<Props> = ({
buttonElement,
menuElement,
verticalPosition,
horizontalPosition,
verticalOffset,
horizontalOffset,
'area-label': areaLabel,
}) => {
const { modalRef, modalId, position, cloneButtonElement, isOpen, handleClose } = useMenuModal({
buttonElement,
verticalPosition,
horizontalPosition,
verticalOffset,
horizontalOffset,
});
return (
<>
{cloneButtonElement}
{isOpen && (
<>
<div className={styles.overlay} onClick={handleClose} onKeyUp={handleClose} />
<div
ref={modalRef} // ①
className={styles.container}
role='dialog' // ④
style={{
top: position.top,
right: position.right,
bottom: position.bottom,
left: position.left,
}}
aria-label={areaLabel} // ④
aria-hidden={!isOpen} // ④
aria-modal='true' // ④
id={modalId} // ④
>
{menuElement}
</div>
</>
)}
</>
);
};
Container Component
import { useRef, useState, useCallback, useId, cloneElement, useEffect, useMemo } from 'react';
import type { ReactElement } from 'react';
type Args = {
buttonElement: ReactElement;
verticalPosition: 'top' | 'bottom';
horizontalPosition: 'left' | 'right';
verticalOffset?: number | undefined;
horizontalOffset?: number | undefined;
};
type Position = {
top?: string;
right?: string;
bottom?: string;
left?: string;
};
export const useMenuModal = ({
buttonElement,
verticalPosition,
horizontalPosition,
verticalOffset,
horizontalOffset,
}: Args) => {
const modalRef = useRef<HTMLDivElement>(null); // ①
const basisRef = useRef<HTMLElement>(null); // ②
const [basisRect, setBasisRect] = useState<DOMRect>(); // ②
const [isOpen, setIsOpen] = useState(false);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
const modalId = useId(); // ④
// ③
const cloneButtonElement = cloneElement(buttonElement, {
ref: basisRef, // ②
'aria-controls': modalId, // ④
onClick: handleOpen,
});
useEffect(() => {
if (!isOpen) {
return;
}
if (basisRef.current === null) {
return;
}
setBasisRect(basisRef.current.getBoundingClientRect());
}, [isOpen]);
// ⑤
const top = useMemo(
() => (basisRect?.top || 0) + (basisRect?.height || 0) + (verticalOffset || 0),
[basisRect?.top, basisRect?.height, verticalOffset],
);
const right = useMemo(
() => document.documentElement.clientWidth - (basisRect?.right || 0) + (horizontalOffset || 0),
[basisRect?.right, horizontalOffset],
);
const bottom = useMemo(
() => document.documentElement.clientHeight - (basisRect?.top || 0) + (verticalOffset || 0),
[basisRect?.top, verticalOffset],
);
const left = useMemo(() => (basisRect?.left || 0) + (horizontalOffset || 0), [basisRect?.left, horizontalOffset]);
const position = useMemo<Position>(() => {
const position: Position = {};
if (modalRef.current === null) {
return position;
}
// ⑥
switch (verticalPosition) {
case 'top':
if (top + modalRef.current.getBoundingClientRect().height > document.documentElement.clientHeight) {
position.bottom = `${bottom}px`;
break;
}
position.top = `${top}px`;
break;
case 'bottom':
if (bottom - modalRef.current.getBoundingClientRect().height < 0) {
position.top = `${top}px`;
break;
}
position.bottom = `${bottom}px`;
break;
}
switch (horizontalPosition) {
case 'left':
position.left = `${left}px`;
break;
case 'right':
position.right = `${right}px`;
break;
}
return position;
}, [verticalPosition, horizontalPosition, top, right, bottom, left]);
return {
modalRef,
modalId,
position,
cloneButtonElement,
isOpen,
handleOpen,
handleClose,
};
};
- ①:
useRef
を使用してmodal
となる要素を保持します。保持した要素は主に位置計算に使用します。 - ②:
useRef
を使用してbutton
となる要素を保持します。保持した要素は主に位置計算に使用します。 - ③:
cloneElement
関数を使用して、Propsとして受け取ったボタン要素に追加のPropsを付与して返却します。このようにすることで、MenuModalPortal
内のみで使う固有値を意識させずに、外から要素を指定することができます。- 例えば、Propsで指定する際は
ref
の指定を意識する必要がなくなります。
- 例えば、Propsで指定する際は
- ④: アクセシビリティ考慮のための指定となります。
- 参考1 を主に参考にしております。
- ⑤: メニュー要素の表示位置の計算処理となります。
position: fixed;
が指定された要素への適用を前提としております。- top: <ボタン要素の上基準縦位置> + <ボタン要素の高さ> + <verticalOffset>
- right: <ブラウザ領域の横幅> - <ボタン要素の右基準横位置> + <horizontalOffset>
- bottom: <ブラウザ領域の縦幅> - <ボタン要素の上基準縦位置> + <verticalOffset>
- left: <ボタン要素の左基準横位置> + <horizontalOffset>
- ⑥: メニューの表示位置がブラウザ領域を超えてしまう場合に、表示位置の基準値を反転させる処理となります。
- ※現時点では縦方向のみの対応となりますが、同様のロジックで横方向も対応可能かと思います。
CSS設計
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-index-tooltip-overlay);
width: 100vw;
height: 100vh;
}
.container {
position: fixed;
z-index: var(--z-index-tooltip);
background-color: var(--color-light);
filter: drop-shadow(0 0 20px rgb(0 0 0 / 15%));
animation: fade-in 250ms cubic-bezier(0.17, 0.67, 0.79, 0.76);
}
- animationとz-indexは各環境やお好みに合わせてカスタマイズできます。
- なお、z-indexについては overlay < tooltip な大小関係で指定してください。
-
position: fixed;
を使用しております。- 相対位置の計算であれば
position absolute;
の方が簡単ではありますが、以下記事の背景からfixedを使用しております。(私の過去記事です。)
- 相対位置の計算であれば
RSC環境への対応について
-
Client Component
としてご使用いただければ問題ありません。- クライアントロジックが主なので当然
Server Component
としては扱えません・・
- クライアントロジックが主なので当然
- 例えば、Next.js App Router 環境であれば、
MenuModalPortal
コンポーネントにuse client;
を付与いただければOKです。- 但し、記載コードのままで実行すると
document is not defined
エラーがサーバ側で出てしまうので、以下の方法などで回避する必要があります。-
typeof window === 'undefined'
でdocument関数の実行判定を行う -
{ssr: false}
を指定したnext/dynamic
経由で呼び出す
-
- 但し、記載コードのままで実行すると
回避策
const top = useMemo(
() =>
typeof window === 'undefined' ? 0 : (basisRect?.top || 0) + (basisRect?.height || 0) + (verticalOffset || 0),
[basisRect?.top, basisRect?.height, verticalOffset],
);
const right = useMemo(
() =>
typeof window === 'undefined'
? 0
: document.documentElement.clientWidth - (basisRect?.right || 0) + (horizontalOffset || 0),
[basisRect?.right, horizontalOffset],
);
const bottom = useMemo(
() =>
typeof window === 'undefined'
? 0
: document.documentElement.clientHeight - (basisRect?.top || 0) + (verticalOffset || 0),
[basisRect?.top, verticalOffset],
);
const left = useMemo(
() => (typeof window === 'undefined' ? 0 : (basisRect?.left || 0) + (horizontalOffset || 0)),
[basisRect?.left, horizontalOffset],
);
まとめ
メニュー表示のUXを再現するコンポーネントを、汎用的に扱える形に落とし込んでみました。
ご意見、ご提案などがございましたらお気軽にコメントいただけたら嬉しいです!
参考
Discussion