💭

【React】マウスドラッグで移動可能なモーダルウィンドウを実装する

2022/11/20に公開

モチベーション

移動できないモーダルウィンドウはよく見かけますが、マウスドラッグで移動できるものはあまり見かけないので自作してみました。
モーダルウィンドウのヘッダーをマウスドラッグで移動可能な仕様とします。
以下、モーダルウィンドウはモーダルを略します。

前提条件

  • react(=>16.8.0)
  • 状態管理ライブラリとしてjotaiを使用
  • cssはtailwindcssで実装
  • typescriptで記述

モーダルの基底コンポーネントの実装

ここでは、汎用性を考慮し、まず/Modalディレクトリの中に基底となるコンポーネントを実装します。

フック

/Modal/hook.tsに基底コンポーネント用のフックを実装します。
まず、使用するstateの型を定義します。

type ModalState = {
  left: number;     // モーダルの左端の位置
  top: number;      // モーダルの上部の位置
  moving: boolean;  // モーダルを移動中かどうか
  visible: boolean; // モーダルを表示中かどうか
};

フックを定義します。

export const useModal = ($container: RefObject<HTMLDivElement>, $modal: RefObject<HTMLDivElement>) => {
// ...ここを今から実装
}

引数$containerはウィンドウ全体を覆うdiv要素の参照、引数$modalはモーダル本体のdiv要素の参照です。
ここから、useModalの中身を実装します。
stateとモーダルの位置を決めるために使用するオブジェクトを定義します。

const [modalState, setModalState] = useState<ModalState>({
  left: 0,
  top: 0,
  moving: false,
  visible: false,
});

const moveConfig = useRef({
  left: 0,
  top: 0,
  minLeft: 0,
  minTop: 0,
  maxLeft: 0,
  maxTop: 0,
  scrollX: 0,
  scrollY: 0,
});

続いて、モーダル表示時の関数とモーダル非表示時の関数を定義します。

const onShowModal = useCallback(() => {
  if ($container.current && $modal.current) {
    moveConfig.current.maxLeft = $container.current.offsetWidth - $modal.current.offsetWidth;
    // $containerと$modalの幅の差
    moveConfig.current.maxTop = $container.current.offsetHeight - $modal.current?.offsetHeight;
    // $containerと$modalの高さの差
    setModalState((state) => ({
        ...state,
        left: moveConfig.current.maxLeft / 2,
        top: (state.top = moveConfig.current.maxTop / 2),
        visible: true,
      }));
    // 上で求めた幅と高さの差のそれぞれの1/2をstateのleftとtopにセット
  }
}, [setModalState]);

const onHideModal = useCallback(() => {
  setModalState((state) => ({ ...state, visible: false }));
}, [setModalState]);

ウィンドウ全体の幅と高さからモーダルの幅と高さのそれぞれの差をモーダルの左端と上部の位置に設定すれば、モーダルがウィンドウのちょうど真ん中になります。
次は、モーダル移動時の関数を定義します。

const moveStartModal = useCallback(
  (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
    moveConfig.current.scrollX = e.pageX - e.clientX;
    moveConfig.current.scrollY = e.pageY - e.clientY;
    moveConfig.current.left = e.pageX - modalState.left;
    moveConfig.current.top = e.pageY - modalState.top;

    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', moveEnd);

    setModalState((state) => ({ ...state, moving: true }));
  },
  [setModalState, modalState.left, modalState.top]
);

const move = useCallback(
  (e: MouseEvent) => {
    let currentLeft = e.pageX - moveConfig.current.left - moveConfig.current.scrollX;
    let currentTop = e.pageY - moveConfig.current.top - moveConfig.current.scrollY;

    if (currentLeft < moveConfig.current.minLeft) {
      currentLeft = moveConfig.current.minLeft;
    } else if (currentLeft > moveConfig.current.maxLeft) {
      currentLeft = moveConfig.current.maxLeft;
    }
    if (currentTop < moveConfig.current.minTop) {
      currentTop = moveConfig.current.minTop;
    } else if (currentTop > moveConfig.current.maxTop) {
      currentTop = moveConfig.current.maxTop;
    }

    if (currentLeft < 0) {
      currentLeft = 0;
    }
    if (currentTop < 0) {
      currentTop = 0;
    }

    setModalState((state) => ({ ...state, left: currentLeft, top: currentTop }));
  },
  [setModalState]
);

  const moveEnd = useCallback(() => {
    moveConfig.current.left = ($modal.current?.offsetLeft || 0) + moveConfig.current.scrollX;
    moveConfig.current.top = ($modal.current?.offsetTop || 0) + moveConfig.current.scrollY;

    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', moveEnd);

    setModalState((state) => ({ ...state, moving: false }));
  }, [setModalState]);

細かい計算の説明は割愛します。(余裕があれば後日追記するかも...。)

return { modalConfig: modalState, onShowModal, onHideModal, moveStartModal };

最後に、必要な変数と関数をreturnして完成です。

完成形
Modal/hook.ts
import { RefObject, useCallback, useRef, useState } from 'react';

type ModalState = {
  left: number;
  top: number;
  moving: boolean;
  visible: boolean;
};

export const useModal = ($container: RefObject<HTMLDivElement>, $modal: RefObject<HTMLDivElement>) => {
  const [modalState, setModalState] = useState<ModalState>({
    left: 0,
    top: 0,
    moving: false,
    visible: false,
  });

  const moveConfig = useRef({
    left: 0,
    top: 0,
    minLeft: 0,
    minTop: 0,
    maxLeft: 0,
    maxTop: 0,
    scrollX: 0,
    scrollY: 0,
  });

  const onShowModal = useCallback(() => {
    if ($container.current && $modal.current) {
      moveConfig.current.maxLeft = $container.current.offsetWidth - $modal.current.offsetWidth;
      moveConfig.current.maxTop = $container.current.offsetHeight - $modal.current?.offsetHeight;
      setModalState((state) => ({
        ...state,
        left: moveConfig.current.maxLeft / 2,
        top: (state.top = moveConfig.current.maxTop / 2),
        visible: true,
      }));
    }
  }, [setModalState]);

  const onHideModal = useCallback(() => {
    setModalState((state) => ({ ...state, visible: false }));
  }, [setModalState]);

  const moveStartModal = useCallback(
    (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
      moveConfig.current.scrollX = e.pageX - e.clientX;
      moveConfig.current.scrollY = e.pageY - e.clientY;
      moveConfig.current.left = e.pageX - modalState.left;
      moveConfig.current.top = e.pageY - modalState.top;

      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', moveEnd);

      setModalState((state) => ({ ...state, moving: true }));
    },
    [setModalState, modalState.left, modalState.top]
  );

  const move = useCallback(
    (e: MouseEvent) => {
      let currentLeft = e.pageX - moveConfig.current.left - moveConfig.current.scrollX;
      let currentTop = e.pageY - moveConfig.current.top - moveConfig.current.scrollY;

      if (currentLeft < moveConfig.current.minLeft) {
        currentLeft = moveConfig.current.minLeft;
      } else if (currentLeft > moveConfig.current.maxLeft) {
        currentLeft = moveConfig.current.maxLeft;
      }
      if (currentTop < moveConfig.current.minTop) {
        currentTop = moveConfig.current.minTop;
      } else if (currentTop > moveConfig.current.maxTop) {
        currentTop = moveConfig.current.maxTop;
      }

      if (currentLeft < 0) {
        currentLeft = 0;
      }
      if (currentTop < 0) {
        currentTop = 0;
      }

      setModalState((state) => ({ ...state, left: currentLeft, top: currentTop }));
    },
    [setModalState]
  );

  const moveEnd = useCallback(() => {
    moveConfig.current.left = ($modal.current?.offsetLeft || 0) + moveConfig.current.scrollX;
    moveConfig.current.top = ($modal.current?.offsetTop || 0) + moveConfig.current.scrollY;

    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', moveEnd);

    setModalState((state) => ({ ...state, moving: false }));
  }, [setModalState]);

  return { modalConfig: modalState, onShowModal, onHideModal, moveStartModal };
};

コンポーネント

/Modal/index.tsxにモーダルのコンポーネントを実装します。
最初に、propsの型を定義します。

type ModalProps = {
  width?: number;          // モーダルの幅
  height?: number;         // モーダルの高さ
  title?: string;          // ヘッダーに表示するタイトル
  maskClickable?: boolean; // モーダル外クリック時にモーダルを閉じるかどうか
  movable?: boolean;       // 移動可能かどうか
  active?: boolean;        // モーダルを表示中かどうか
  main: ReactNode;         // モーダルの中身
  footer?: ReactNode;      // モーダルのフッターの中身
  hide: () => void;        // モーダルを閉じるコールバック
};

続いて、tsxの中身を実装します。

export const Modal = ({
  title = '',
  width = 0,
  height = 0,
  maskClickable = false,
  movable = false,
  active = false,
  main,
  footer,
  hide,
}: ModalProps) => {
 // ...ここを今から実装
}

フックの引数として使用する要素への参照を定義し、フックを呼び出します。

const $container = useRef<HTMLDivElement>(null);
const $modal = useRef<HTMLDivElement>(null);

const { modalConfig, onShowModal, onHideModal, moveStartModal } = useModal($container, $modal);

モーダルの各要素に設定する動的なスタイルを定義します。

const style = useMemo(() => {
  return {
    width: width ? `${width}px` : 'auto',
    height: height ? `${height}px` : 'auto',
    left: `${modalConfig.left}px`,
    top: `${modalConfig.top}px`,
  };
}, [width, height, modalConfig.left, modalConfig.top]);
  
const contentDynamicClassName = useMemo(() => {
  return modalConfig.visible ? 'visible' : 'invisible';
}, [modalConfig.visible]);

const headerDynamicClassName = useMemo(() => {
  return movable ? 'cursor-pointer select-none' : '';
}, [movable]);

モーダル外(マスクと呼びます)クリック時と、モーダルのヘッダー上でマウスダウン時の処理をそれぞれ定義します。

const onClickMask = useCallback(() => {
  if (maskClickable) {
    hide();
  }
}, [maskClickable, hide]);

const onMousedownHeader = useCallback(
  (e: React.MouseEvent<HTMLElement>) => {
    if (!movable) return;

    moveStartModal(e);
  },
  [movable, moveStartModal]
);

propsのactiveが変化した時の副作用を定義します。

useEffect(() => {
  if (active) {
    onShowModal();
  } else {
    onHideModal();
  }
}, [active]);

最後に、jsx要素を返します。

return (
  <>
    {active && (
      <section className={'fixed left-0 top-0 z-[1000] h-[100vh] w-[100vw]'} ref={$container}>
        <div className={'absolute top-0 left-0 bottom-0 right-0'}>
          <div
            onClick={onClickMask}
            className={'absolute top-0 left-0 bottom-0 right-0 z-[1] bg-[rgba(255,255,255,0.1)]'}
          />
          <section
            style={style}
            className={`relative z-[2] inline-block overflow-hidden rounded-t bg-white ${contentDynamicClassName}`}
            ref={$modal}
          >
            <header className={`bg-black py-1 px-2 ${headerDynamicClassName}`} onMouseDown={onMousedownHeader}>
              <h3 className={text-white}>{title}</h3>
            </header>
            {main}
            {footer}
          </section>
        </div>
      </section>
    )}
  </>
);

これは別ファイルに分けてもいいですが、モーダルのメイン要素とフッター要素にも共通のスタイルを当てたいのでそれ用のコンポーネントを作成します。

export const ModalMain = ({ children }: { children: ReactNode }) => {
  return <main className={'m-0.5 p-1'}>{children}</main>;
};

export const ModalFooter = ({ children }: { children: ReactNode }) => {
  return <footer className={'m-0.5 flex justify-end'}>{children}</footer>;
};
完成形
Modal/index.tsx
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';

import { useModal } from './hook';

type ModalProps = {
  width?: number;
  height?: number;
  title?: string;
  maskClickable?: boolean;
  movable?: boolean;
  active?: boolean;
  main: ReactNode;
  footer?: ReactNode;
  hide: () => void;
};

export const Modal = ({
  title = '',
  width = 0,
  height = 0,
  maskClickable = false,
  movable = false,
  active = false,
  main,
  footer,
  hide,
}: ModalProps) => {
  const onClickMask = useCallback(() => {
    if (maskClickable) {
      hide();
    }
  }, [maskClickable, hide]);

  const $container = useRef<HTMLDivElement>(null);
  const $modal = useRef<HTMLDivElement>(null);

  const { modalConfig, onShowModal, onHideModal, moveStartModal } = useModal($container, $modal);

  const style = useMemo(() => {
    return {
      width: width ? `${width}px` : 'auto',
      height: height ? `${height}px` : 'auto',
      left: `${modalConfig.left}px`,
      top: `${modalConfig.top}px`,
    };
  }, [width, height, modalConfig.left, modalConfig.top]);

  const onMousedownHeader = useCallback(
    (e: React.MouseEvent<HTMLElement>) => {
      if (!movable) return;

      moveStartModal(e);
    },
    [movable, moveStartModal]
  );

  const contentDynamicStyle = useMemo(() => {
    return modalConfig.visible ? 'visible' : 'invisible';
  }, [modalConfig.visible]);

  const headerDynamicStyle = useMemo(() => {
    return movable ? 'cursor-pointer select-none' : '';
  }, [movable]);

  useEffect(() => {
    if (active) {
      onShowModal();
    } else {
      onHideModal();
    }
  }, [active]);

  return (
    <>
      {active && (
        <section className={'fixed left-0 top-0 z-[1000] h-[100vh] w-[100vw]'} ref={$container}>
          <div className={'absolute top-0 left-0 bottom-0 right-0'}>
            <div
              onClick={onClickMask}
              className={'absolute top-0 left-0 bottom-0 right-0 z-[1] bg-[rgba(255,255,255,0.1)]'}
            />
            <section
              style={style}
              className={`relative z-[2] inline-block overflow-hidden rounded-t bg-white ${contentDynamicStyle}`}
              ref={$modal}
            >
              <header className={`bg-black py-1 px-2 ${headerDynamicStyle}`} onMouseDown={onMousedownHeader}>
                <h3 className={'text-white'}>{title}</h3>
              </header>
              {main}
              {footer}
            </section>
          </div>
        </section>
      )}
    </>
  );
};

export const ModalMain = ({ children }: { children: ReactNode }) => {
  return <main className={'m-0.5 p-1'}>{children}</main>;
};

export const ModalFooter = ({ children }: { children: ReactNode }) => {
  return <footer className={'m-0.5 flex justify-end'}>{children}</footer>;
};

モーダル本体のコンポーネント実装

基底コンポーネントが完成したので、それをラップするような形でモーダル本体のコンポーネントを実装していきます。
ここでは、/SampleModalというディレクトリ内に作成します。

フック

/SampleModal/hook.tsにモーダル表示状態操作用のフックを実装します。

SampleModal/hook.ts
import { atom, useAtom } from 'jotai';

type SampleModalState = {
  active: boolean;
};

const sampleModalAtom = atom<SampleModalState>({
  active: false,
});

export const useSampleModal = () => {
  const [state, setState] = useAtom(sampleModalAtom);

  // モーダル表示
  const showSampleModal = () => setState((state) => ({ ...state, active: true }));

  // モーダル非表示
  const hideSampleModal = () => setState((state) => ({ ...state, active: false }));

  return { sampleModalActive: state.active, showSampleModal, hideSampleModal };
};

stateはオブジェクト型で定義していますが、表示非表示のみの機能であればboolean型で構いません。

コンポーネント

/SampleModal/index.tsxにモーダル本体を実装します。

SampleModal/index.tsx
import { Modal, ModalFooter, ModalMain } from '../Modal';
import { useSampleModal } from './hook';

export const SampleModal = () => {
  const { sampleModalActive, hideSampleModal } = useSampleModal();

  const main = (
    <ModalMain>
      <div>モーダルメイン</div>
    </ModalMain>
  );

  const footer = (
    <ModalFooter>
      <button className="bg-primary p-2 text-white" onClick={hideSampleModal}>
        閉じる
      </button>
    </ModalFooter>
  );

  return (
    <Modal
      active={sampleModalActive}
      title="サンプルモーダル"
      width={500}
      movable={true}
      maskClickable={true}
      hide={hideSampleModal}
      main={main}
      footer={footer}
    />
  );
};

先程作成したuseSampleModalフックを呼び出し、最初に作成した基底コンポーネントを返します。

モーダルの呼び出し

では、モーダルの実装が終わったので適当なボタンのonClickイベントにuseSampleModalフックのshowSampleModal()を登録してモーダルを表示させます。

const { showSampleModal } = useSampleModal();

return (
  <div>
    <button onClick={showSampleModal}>モーダル表示</button>
    <SampleModal />
  </div>
);

下の画像のようなモーダルが表示されればOKです。

ヘッダーをマウスドラッグして移動できるかどうかも試してください。

最後に

今回は、移動可能なモーダルウィンドウを実装しましたが、実際のUIではあまり使い所がないかもしれませんね。
モーダルに被ってしまう下の要素を見たい時には便利かも...。

Discussion