🛝

現場で使えるReactコンポーネント第4弾 Dropdown編

2023/09/05に公開

はじめに

本記事は、現場で使えるReactコンポーネント第4弾の記事です。

開発環境

version
node 18.17.1
react 18.2.0

第1弾のButton編の記事はこちら
第2弾のInputText編の記事はこちら
第3弾のTab編の記事はこちら


おひさしぶりです。最近触ったViteのコンパイルの速さに驚きを隠せない2年目のWebエンジニアです。
前回の記事から3ヶ月も経ってしまいました。。
社会人になってからというもの、時のながれがはやすぎてびっくりです。

今回も実装しようとなると少しめんどくさいコンポーネントを紹介していきます。
今回はDropdownコンポーネントです。

このコンポーネントの要件は以下の通りです。

  • ボタンを押下でドロップダウンを開閉
  • ドロップダウン内の要素を押下してもドロップダウンは閉じない
  • ドロップダウン外の要素を押下するとドロップダウンは閉じる
  • 画面内の表示位置によってドロップダウンの表示位置を上下に切り替える

早速できあがったものを見ていきましょう。

今回作成した、ドロップダウンコンポーネントの動作はこんな感じです。

今回作成したコンポーネントは以下のように使うことができます。

<Dropdown>
  <Dropdown.ToggleButton>toggle</Dropdown.ToggleButton>
  <Dropdown.Body>
    <ul className={styles.items}>
      <li>first item</li>
      <li>second item</li>
    </ul>
  </Dropdown.Body>
</Dropdown>

さてさて、ここからは実際にコンポーネントの中身を見ていきましょう

Dropdown/index.tsx
import classNames from "classnames";
import {
  Dispatch,
  FocusEvent,
  SetStateAction,
  createContext,
  useContext,
  useRef,
  useState,
  ComponentPropsWithoutRef,
} from "react";

import { useElementPosition } from "src/utils/useElementPosition";

import Button from "src/components/Button";

import styles from "./Dropdown.module.css";

// -1- Contextを用いて親子間で値を共有する
const DropdownContext = createContext<{
  isOpen: boolean; // ドロップダウンの開閉フラグ
  setIsOpen: Dispatch<SetStateAction<boolean>>; // ドロップダウンの開閉の状態の切り替え関数
  isInUpperHalf: boolean; // ドロップダウンが画面半分より上にあるかどうか
}>({
  isOpen: false,
  setIsOpen: () => {},
  isInUpperHalf: false,
});

const Dropdown = ({
  children,
  className,
  ...props
}: ComponentPropsWithoutRef<"div">) => {
  const [isOpen, setIsOpen] = useState(false);

  const dropdownRef = useRef<HTMLDivElement>(null);
  // ドロップダウンコンポーネントが画面の半分から上にあるかどうかを取得するhooks 
  const { isInUpperHalf } = useElementPosition<HTMLDivElement>(dropdownRef);

  // -2- 押下した要素がドロップダウン内か外を判定し、ドロップダウンの閉じる動作の制御を行う
  const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
    const relatedTarget = event.relatedTarget as HTMLElement;

    if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
      // 関連するターゲットがドロップダウン内の要素なら何もしない
      return;
    }
    setIsOpen(false);
  };

  return (
    // -1- Contextを用いて親子間で値を共有する
    <DropdownContext.Provider value={{ isOpen, setIsOpen, isInUpperHalf }}>
      <div
        ref={dropdownRef}
        className={classNames(styles.dropdown, className)}
        tabIndex={0}
        onBlur={handleBlur}
        {...props}
      >
        {children}
      </div>
    </DropdownContext.Provider>
  );
};

// ドロップダウンのトグルボタンを表現するコンポーネント。第一弾のButtonコンポーネントを利用している
const ToggleButton = ({
  children,
  className,
  ...props
}: ComponentPropsWithoutRef<typeof Button>) => {
  // -1- Contextを用いて親子間で値を共有する
  const { isOpen, setIsOpen } = useContext(DropdownContext);
  return (
    <Button
      className={classNames(styles.toggle, className)}
      {...props}
      onClick={() => setIsOpen(!isOpen)}
    >
      {children}
    </Button>
  );
};

// ドロップダウンの開閉する要素を表現するコンポーネント
const Body = ({
  children,
  className,
  ...props
}: ComponentPropsWithoutRef<"div">) => {
  // -1- Contextを用いて親子間で値を共有する
  const { isOpen, isInUpperHalf } = useContext(DropdownContext);
  return (
    <>
      {isOpen ? (
        <div
          className={classNames(
            styles.menu,
            isInUpperHalf ? styles.lower : styles.upper, // 表示位置の上下を切り替えている
            className
          )}
          {...props}
        >
          {children}
        </div>
      ) : null}
    </>
  );
};

// -3- Dropdownのオブジェクトにコンポーネントを渡すことで、複数のコンポーネントを1つにまとめることができる
Dropdown.ToggleButton = ToggleButton;
Dropdown.Body = Body;

export default Dropdown;

今回は与えられた要素が画面の上半分か下半分にあるかを判定するuseElementPositionというhooksも実装したので、それも共有します。このhooksの内容は単純なので説明は割愛します。

useElementPosition
useElementPosition.ts
import { useState, useEffect, useCallback, RefObject } from "react";

/**
 * 与えられた要素が画面の上半分か下半分にあるかを判定する
 *
 * @template T - DOM要素の型
 * @param {RefObject<T>} elementRef - トラックするDOM要素
 */
export const useElementPosition = <T extends HTMLElement>(
  elementRef: RefObject<T>
): { isInUpperHalf: boolean; isInLowerHalf: boolean } => {
  const [isInUpperHalf, setIsInUpperHalf] = useState(false);
  const [isInLowerHalf, setIsInLowerHalf] = useState(false);

  const handleScroll = useCallback(() => {
    if (!elementRef.current) return;

    const { top, bottom } = elementRef.current.getBoundingClientRect();
    const windowHeight = window.innerHeight;

    setIsInUpperHalf(top < windowHeight / 2);
    setIsInLowerHalf(bottom > windowHeight / 2);
  }, [elementRef]);

  useEffect(() => {
    handleScroll();
    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [handleScroll]);

  return { isInUpperHalf, isInLowerHalf };
};

1.Contextを用いて親子間で値を共有する

ToggleButtonでは、setIsOpenを受け取り、ボタン押下時にドロップダウンの開閉状態を切り替えるようにしています。
Bodyでは、isOpenisInUpperHalfを受け取り、isOpenを用いて開閉状態に応じたコンポーネントの描画とisInUpperHalfを用いてドロップダウンの表示位置の切り替えを行っています。

2.押下した要素がドロップダウン内か外を判定し、ドロップダウンの閉じる動作の制御を行う

onBlurの引数であるFocusEventには、relatedTargetという、フォーカスが外れた際にどの要素を押下したかを保持するパラメータがあります。
そのパラメータから要素を取得し、要素がドロップダウンの子要素かどうかを判定を行います。子要素ではなかった場合のみ、ドロップダウンを閉じるように実装しました。

3.Dropdownのオブジェクトにコンポーネントを渡すことで、複数のコンポーネントを1つにまとめることができる

// まとめることで
Dropdown.ToggleButton = ToggleButton;
Dropdown.Body = Body;
// ↓↓↓みたいに記述できる
<Dropdown.ToggleButton>toggle</Dropdown.ToggleButton>

このように書くメリットとして、以下の内容が挙げられると思います。

  • 1つのコンポーネントをimportするだけでいい(今回だとDropdownのみでよい)
  • コンポーネント同士の親子関係がわかりやすい
  • 子コンポーネントの名前を安直にできる(今回だとBody)

しかし、私の第3弾のTabコンポーネントでは、このようにまとめる方法は使っていません。この書き方は好き嫌い分かれると思っているので、あえて書き方を統一しませんでした。この記事と第3弾のTabコンポーネントを比べてどっちが使いやすいかを考えてみましょう。個人的には今回の書き方の方が好きです。
実装する際は、チームメンバーと相談して実装方針を定めてから実装しましょうね。

最後に

ここまで読んでいただきありがとうございました。
冒頭でも述べましたが、前回の記事から3ヶ月も経ってしまいました。
その間、AWSの資格の勉強をしたり、ESLintやPretter、Stylelintの設定を追加したりしていました。
日々の業務と勉強でやること・やりたいことが山積みで大変ですが、毎日楽しく生きています。
第5弾の記事を書くのはいつになるのやら。。。笑

のんびりほそぼそと更新していきますので、気が向いたら覗いてみてください。

実装したコンポーネントは記事に起こす前に社内の先輩方にレビューをいただいています。
毎回貴重なご意見とご指摘ありがとうございます。


今回の記事で作成したコードはGithubにて公開しています。
https://github.com/shuuuu10-01/useful-react-components

デプロイ済みですので、以下のURLからStorybookでの動作確認ができます。
(GithubPagesを使ってデプロイするように変更しました)
https://shuuuu10-01.github.io/useful-react-components/

Discussion