🐱

[React]クリックしたらメニューがすぐ側に出現するUXを汎用的なコンポーネントに落とし込んでみた

2024/06/16に公開

はじめに

しばしば見かける以下のようなUXを、誰でも扱えるコンポーネントとして実装してみたので解説します。

完成系

  • 以下にて、ソースコードを公開しております。

https://github.com/yu-ta-9/yuta9-storybook-v2/tree/main/src/components/tools/MenuModalPortal

解説

  • 以下、該当のコンポーネントを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の指定を意識する必要がなくなります。
  • ④: アクセシビリティ考慮のための指定となります。
    • 参考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を使用しております。(私の過去記事です。)

https://zenn.dev/yu_ta_9/articles/499074662c7c5b

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を再現するコンポーネントを、汎用的に扱える形に落とし込んでみました。
ご意見、ご提案などがございましたらお気軽にコメントいただけたら嬉しいです!

参考

https://qiita.com/yoruaki/items/b28ef11da42409d449a3

Discussion