🙌

ReactでPopper.jsを使ってみる

7 min read

Popper.js

https://github.com/popperjs/popper-core

popper.jsはツールチップやドロップダウンといったポップアップ要素の表示位置をいい感じにしてくれるライブラリです。

Reactの多くのUIフレームワークがこれに依存していて、独自のラッパーを作っています。

公式のラッパーでも充分な感じです。

https://github.com/popperjs/react-popper/blob/363472459ceb4442a14813bbb801914c15bfa839/src/usePopper.js

popper.jsが提供しているのはあくまで表示位置に関連する機能であり、Toolitp等の具体的なUIに関してはtippy.jsというのが紹介されています。(もちろんpopper.jsに依存)

https://atomiks.github.io/tippyjs/

https://github.com/atomiks/tippyjs-react

Reactのラッパーもありますが割とスター数も少なくマイナーな感じですね。
多くの人は基本的にUIフレームワークを使うと思うので当然といえば当然ですね。


Cool

単に表示位置を計算するだけにしてはコード量が多いなと思って調べていたら、

スクロールやズーム等のUIの変化に追随してコンテンツの表示位置が更新されたり、スクロール要素の中でもいい感じにする等の配慮がされてました。ライフライクルやmodifilerによる拡張も備えていてシンプルながら結構Coolな感じです。

  • 下にスクロールすると反転する例Demo

https://popper.js.org/docs/v2/modifiers/flip/#demo
  • スクロールして矢印の位置が追随しているDemo

https://popper.js.org/docs/v2/modifiers/arrow/#demo

APIもシンプルでいい感じです。

import { createPopper } from '@popperjs/core';

const button = document.querySelector('#button');
const tooltip = document.querySelector('#tooltip');

// Pass the button, the tooltip, and some options, and Popper will do the
// magic positioning for you:
const instance = createPopper(button, tooltip, {
  placement: 'right',
});

// instance.destroy()

これを使うとtooltipの座標をbuttonの右側にいい感じにしてくれます。

また、createPopperはインスタンスを返します。インスタンスをdestroyするまで、popperは生き続けます。(内部でイベントリスナーが登録されており、スクロールやズーム等のUIに変更があっても座標をいい感じにしてくれる)

Reactで使う場合はuseEffectの中でインスタンスを生成したり戻り地の関数の中で破棄したりして使うんだなとなんとなく分かります。

Modifier

ライフサイクルもあるので、これを使ってカスタマイズができそうです。

https://popper.js.org/docs/v2/modifiers/#phase

chakra-uiをの実装も参考になりそうです。

https://github.com/chakra-ui/chakra-ui/blob/ae42755583e38fe04c2c19685e096d1800e22cca/packages/popper/src/modifiers.ts

Reactで使ってみる。

Reactで使えるようなhookがあるので、これを使います。

yarn add react-popper @popperjs/core

Simple Example

overlayしたい要素とそれの基準となる要素のrefをuserPopperに渡すと、popperが導出した
styleとattribute(data-popper-*)を返してくれます。

import React, { useRef, useState } from 'react';
import { usePopper } from 'react-popper';
import styled from 'styled-components';

export const Example = () => {
  const [show, setShow] = useState(false);
  const referenceRef = useRef<HTMLButtonElement | null>(null);
  const popperRef = useRef<HTMLDivElement | null>(null);
  const { styles, attributes } = usePopper(
    referenceRef.current,
    popperRef.current,
    {
      placement: 'bottom',
      modifiers: [
        {
          name: 'offset',
          options: {
            offset: [0, 16],
          },
        },
      ],
    }
  );

  return (
    <div>
      <button onClick={() => setShow(true)} ref={referenceRef}>
        open
      </button>
      <div ref={popperRef} style={styles.popper} {...attributes.popper}>
        {show && <OverlayContent>content</OverlayContent>}
      </div>
    </div>
  );
};

const OverlayContent = styled.div`
  width: 400px;
  height: 300px;
  padding: 16px;
  background: white;
  border: 1px solid #eee;
  border-radius: 6px;
  box-shadow: 0px 4px 7px rgba(0, 0, 0, 0.25);
`;

これは座標を計算しているだけなので、overlayコンテンツがReact.Portalであっても正しく動きます。

シンプルで使いやすいですね。

with custom hooks

custom hookを使ってクリックやescapeで閉じるようにした例。

import React, { useState, useEffect, useRef, RefObject } from 'react';
import { usePopper } from 'react-popper';
import styled from 'styled-components';

const defaultEvents = ['mousedown', 'touchstart'];

export const useClickAway = <E extends Event = Event>(
  ref: RefObject<HTMLElement | null>,
  onClickAway: (event: E) => void,
  events: string[] = defaultEvents
) => {
  const savedCallback = useRef(onClickAway);
  useEffect(() => {
    savedCallback.current = onClickAway;
  }, [onClickAway]);
  useEffect(() => {
    const handler = (event: any) => {
      const { current: el } = ref;
      if (el && !el.contains(event.target)) {
        savedCallback.current(event);
      }
    };
    events.forEach(eventName => document.addEventListener(eventName, handler));
    return () => {
      events.forEach(eventName =>
        document.removeEventListener(eventName, handler)
      );
    };
  }, [events, ref]);
};

const useDisclosure = (initialValue: boolean) => {
  const [isOpen, setIsOpen] = useState(initialValue);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  return {
    isOpen,
    open,
    close,
  };
};

export function useKeypress(key: string, handler: () => void) {
  const savedHandler = useRef(handler);
  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const onKeyup = (event: KeyboardEvent) => {
      if (event.key === key) {
        savedHandler.current();
      }
    };
    document.addEventListener('keyup', onKeyup);
    return () => document.removeEventListener('keyup', onKeyup);
  }, []);
}

export const Example = () => {
  const referenceRef = useRef<HTMLButtonElement | null>(null);
  const popperRef = useRef<HTMLDivElement | null>(null);
  const { styles, attributes } = usePopper(
    referenceRef.current,
    popperRef.current,
    {
      placement: 'bottom',
      modifiers: [
        {
          name: 'offset',
          options: {
            offset: [0, 16],
          },
        },
      ],
    }
  );

  const { isOpen, open, close } = useDisclosure(false);
  useClickAway(popperRef, close);
  useKeypress('Escape', close);

  return (
    <div>
      <button onClick={open} ref={referenceRef}>
        open
      </button>
      <div ref={popperRef} style={styles.popper} {...attributes.popper}>
        {isOpen && <OverlayContent>content</OverlayContent>}
      </div>
    </div>
  );
};

const OverlayContent = styled.div`
  width: 400px;
  height: 300px;
  padding: 16px;
  background: white;
  border: 1px solid #eee;
  border-radius: 6px;
  box-shadow: 0px 4px 7px rgba(0, 0, 0, 0.25);
`;


基本的にpopperを直接使うことはないと思いますが、UIライブラリを読んでるとPopperを使ってるので気になって触ってみました。シンプルなAPIで拡張性も高く、HeadlessなUtilltyなので多くの箇所で使われているんだなと納得しました。