Open7

ReactでDropdownをつくる。

remonremon

すごい今更なんだけど、転職用にアウトプットをしていきたい。
来週くらいまでにに記事として投稿したい。

普通のドロップダウンを作る。

完成形

<Dropdown>
  <Dropdown.Toggle>Open</Dropdown.Toggle>
  <Dropdown.Menu>
     <Dropdown.MenuItem> A </Dropdown.MenuItem/>
        <Dropdown.MenuItem> B </Dropdown.MenuItem/>
     <Dropdown.MenuItem> C </Dropdown.MenuItem/>
     <Dropdown.MenuItem> D </Dropdown.MenuItem/>
  <Dropdown.Menu/>
</Dropdown>

目次的には

  • Overlay Hook
  • アクセシビリティ
  • CSS Dropdown
  • Portal
  • useMemo, useCallback等
  • テスト
remonremon

useOutsideClick

画面をクリックしてドロップダウンを閉じるようにするためには、
こうゆうHookをつくるのと思う。
実装自体もそんな難しくないし、ライブラリの実装も似たりよったりである。


import { RefObject, useEffect, useRef } from 'react';

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]);
};

補足があるとしたら、モバイル端末を意識しようくらいだろうか。
(mouseDownを指定する場合は、touchDownをしていするみたいな)

まぁ素直にclickリスナーを指定すればいいと思うが。

remonremon

作ったhookを使って最小限で作るとこんな感じだろうか。

export const Dropdown = () => {
  const [show, setShow] = useState(false);
  const ref = useRef(null);
  useClickAway(ref, () => {
    setShow(false);
  });
  return (
    <div>
      <div onClick={() => setShow(true)}>open</div>
      {show && <div ref={ref}>content</div>}
    </div>
  );
};

これで要件であろう

  • open をクリックしたら メニューが表示される。
  • 画面の外をクリックしたらメニューが非表示になる。

がクリアできる。

remonremon

スタイルを当てていく。

基本的にはWrapperにposition:relativeを指定し、メニューはposition:absoluteで座標を指定していけば良い。

あと、z-indexの指定も必要。

ただ、あくまでCSSなので、親要素でOverflow Hiddenとか指定されてる切れたりする。

ググって日本語の記事を読んでもいいし、少し信頼できそうなリンクを張っておく。
https://github.com/popperjs/popper-core#why-not-use-pure-css

ただ、基本的に上記の実装で事足りるはずである。

React.Portalを使ったやり方はめんどくさいのでやらない。

座標の計算とか面倒なので、実際に実装する場合はpopper.jsを使うのが良さそう。
(まぁそもそもDropdownなんて作らないと思うが)

remonremon

とりあえず、それっぽいDropdownを作ったのであとは完成形のインターフェースに持っていく。

まず、最初のインターフェースだが
これはこうするしかない。

export const Dropdown = () => {}

Dropdown.Menu = () => {}

そして、もう一つのインターフェースを実現するためには、contextを使う。


export const Dropdown = ({ children }: any) => {
  const [show, setShow] = useState(false);
  return (
    <div>
      <Context.Provider value={{ show, setShow }}>{children}</Context.Provider>
    </div>
  );
};

const Context = React.createContext({
  show: false,
  setShow: (v: boolean) => {},
});

const useDropdownContext = () => {
  return useContext(Context);
};

export const DropdownToggle = ({ children }: any) => {
  const { setShow } = useDropdownContext();
  console.log('toggle rendered');
  return (
    <div
      onClick={() => {
        setShow(true);
        console.log('ghoe');
      }}
    >
      {children}
    </div>
  );
};

export const DropdownMenu = ({ children }: any) => {
  const { show, setShow } = useDropdownContext();
  const ref = useRef(null);
  useClickAway(ref, () => {
    setShow(false);
  });
  return <>{show && <div ref={ref}>{children}</div>}</>;
};

export const DropdownMenuItem = ({ children }: any) => {
  return <MenuItemWrapper> {children}</MenuItemWrapper>;
};

contextを使うことによって、不要な描画が発生するので最低限の制御を行う。

remonremon

アクセシビリティへの配慮。

dropdownが表示中のときは、aria-labelを指定する。
roleも指定する。

https://getbootstrap.jp/docs/4.2/components/dropdowns/#accessibility

https://a11y-guidelines.orange.com/en/web/components-examples/dropdown-menu/

Button

  • aria-expanded
  • aria-controls
  • aria-haspopup

Menu

  • role
  • menu
  • aria-orientation

MenuItem

  • tabIndex
  • role=menuitem
  • dataindex

Chakra

実はドキュメントに書かれてたり。

キーボード

escで閉じるのみ実装。

キーイベントをいい感じに扱えるhookをつくる。


const useKeyboardEvent = () => {

}

上下左右で下のメニューを移動できたりする。