DEV Community

loading...
Cover image for Using usePopper and styled-components to create a practical dropdown from scratch

Using usePopper and styled-components to create a practical dropdown from scratch

tannerhallman profile image Tanner Hallman ・3 min read

Backstory

So my team and I are trying to create our own reusable UI component library that's not based on any UI frameworks and everything was butter until we came to the dropdown component.

Dropdowns and modals are notoriously abstract because the elements in the DOM are not immediately nested. In order to have modals & dropdowns appear above all other elements (standard modal & dropdown behavior), you have to use reasonably advanced concepts. As I was looking for examples on the web, I ran into Popper.js. Great! A tooltip & popover positioning library. Just what we need.

Most of the popper docs are written in pure vanilla JS. They have a very small section with limited details on using the react-popper. I plan to PR some doc additions to the lib. In their docs, they explain that hooks are the way forward (yay, we all love hooks... right?). So I start trying to implement the hooks example:

Code Story

usePopper documentation example

borrowed straight from docs example

Code:

import React, { useState } from "react";
import { usePopper } from "react-popper";

const Example = () => {
  const [referenceElement, setReferenceElement] = useState(null);
  const [popperElement, setPopperElement] = useState(null);
  const [arrowElement, setArrowElement] = useState(null);
  const { styles, attributes } = usePopper(referenceElement, popperElement, {
    modifiers: [{ name: "arrow", options: { element: arrowElement } }]
  });

  return (
    <>
      <button type="button" ref={setReferenceElement}>
        Reference element
      </button>

      <div ref={setPopperElement} style={styles.popper} {...attributes.popper}>
        Popper element
        <div ref={setArrowElement} style={styles.arrow} />
      </div>
    </>
  );
};

export default Example;

Output:

Even though styles are missing, I understand that the default docs example should be as vanilla as possible. This example doesn't visually do anything. So I tried to implement this.

Docs converted to dropdown

Code:

import React, { useState } from "react";
import { usePopper } from "react-popper";

import DropdownContainer from "./components/DropdownContainer";
import DropdownItem from "./components/DropdownItem";

function Dropdown(props) {
  const [visible, setVisibility] = useState(false);

  const [referenceRef, setReferenceRef] = useState(null);
  const [popperRef, setPopperRef] = useState(null);

  const { styles, attributes } = usePopper(referenceRef, popperRef, {
    placement: "bottom",
    modifiers: [
      {
        name: "offset",
        enabled: true,
        options: {
          offset: [0, 10]
        }
      }
    ]
  });

  function handleDropdownClick(event) {
    setVisibility(!visible);
  }

  return (
    <React.Fragment>
      <button ref={setReferenceRef} onClick={handleDropdownClick}>
        Click Me
      </button>
      <div ref={setPopperRef} style={styles.popper} {...attributes.popper}>
        <DropdownContainer style={styles.offset} visible={visible}>
          <DropdownItem>Element</DropdownItem>
          <DropdownItem>Element</DropdownItem>
          <DropdownItem>Element</DropdownItem>
        </DropdownContainer>
      </div>
    </React.Fragment>
  );
}

export default Dropdown;

Output:

All is fine until you realize that the standard dropdown behavior is to close the dropdown on document click outside of your element. I could not find information in the popper docs ANYWHERE about this. I googled frantically for hours and all I could find were people using the old popper style (Manager, Provider, render props, etc). I was determined to get the hooks example to work. After all, hooks are the way forward.

As it turns out, the generally accepted way to handle closing a dropdown or modal on click outside your component was a document event listener where you check to see if the click target includes your element. After wrangling with React's refs and implementing a document body click listener, here's where I landed:

Final Result Code

Code:

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

function Dropdown(props) {
  const [visible, setVisibility] = useState(false);

  const referenceRef = useRef(null);
  const popperRef = useRef(null);

  const { styles, attributes } = usePopper(
    referenceRef.current,
    popperRef.current,
    {
      placement: "bottom",
      modifiers: [
        {
          name: "offset",
          enabled: true,
          options: {
            offset: [0, 10]
          }
        }
      ]
    }
  );
  useEffect(() => {
    // listen for clicks and close dropdown on body
    document.addEventListener("mousedown", handleDocumentClick);
    return () => {
      document.removeEventListener("mousedown", handleDocumentClick);
    };
  }, []);

  function handleDocumentClick(event) {
    if (referenceRef.current.contains(event.target)) {
      return;
    }
    setVisibility(false);
  }
  function handleDropdownClick(event) {
    setVisibility(!visible);
  }

  return (
    <React.Fragment>
      <button ref={referenceRef} onClick={handleDropdownClick}>
        Click Me
      </button>
      <div ref={popperRef} style={styles.popper} {...attributes.popper}>
        <DropdownContainer style={styles.offset} visible={visible}>
          <DropdownItem>Element</DropdownItem>
          <DropdownItem>Element</DropdownItem>
          <DropdownItem>Element</DropdownItem>
        </DropdownContainer>
      </div>
    </React.Fragment>
  );
}

const DropdownContainer = styled.div`
  display: ${props => (props.visible ? "flex" : "none")};
  width: "2px";
  flex-direction: column;
  background-color: "#FFF";
  border-radius: 4px;
  box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.14);
  padding: 5px;
`;

const DropdownItem = styled.div`
  justify-content: flex-start;
  height: 40px;
  padding-right: 10px;
  padding-left: 10px;
  align-items: center;

  &:hover {
    background-color: #00ffff;
  }
  &:active {
    font-weight: 700;
    color: #00ffff;
  }
`;

export default Dropdown;


The important thing worth mentioning is that I used useRef instead of useState when creating refs which caused the actual ref objects to be accessed from referenceRef.current and popperRef.current.

Hopefully, this saves you time, headaches, and by translation, money! πŸš€

Discussion

pic
Editor guide
Collapse
sergeyt profile image
Sergey Todyshev

a bit modified version using "react-outside-click-handler" and "without styled-components":

import React, { useState, useRef } from "react";
import { usePopper } from "react-popper";
import OutsideClickHandler from "react-outside-click-handler";

function DefaultButton(props) {
  return <span className="dots" {...props}>...</span>;
}

export default function Dropdown({ button: Button = DefaultButton, children }) {
  const [visible, setVisible] = useState(false);
  const referenceRef = useRef(null);
  const popperRef = useRef(null);

  const { styles, attributes } = usePopper(
    referenceRef.current,
    popperRef.current,
    {
      placement: "bottom",
      modifiers: [
        {
          name: "offset",
          enabled: true,
          options: {
            offset: [0, 10],
          },
        },
      ],
    }
  );

  const hide = () => setVisible(false);

  function handleDropdownClick(e: any) {
    e.preventDefault();
    setVisible(true);
  }

  const containerStyle: any = {
    ...styles.popper,
    display: visible ? "flex" : "none",
    zIndex: 999,
    flexDirection: "column",
    backgroundColor: "#FFF",
    borderRadius: "4px",
    boxShadow: "0 0 8px 0 rgba(0, 0, 0, 0.14)",
    padding: "10px",
  };

  return (
    <React.Fragment>
      <OutsideClickHandler onOutsideClick={hide}>
        <span ref={referenceRef} onClick={handleDropdownClick}>
          <Button />
        </span>
      </OutsideClickHandler>
      <div ref={popperRef} style={containerStyle} {...attributes.popper}>
        <OutsideClickHandler onOutsideClick={hide}>
          {children}
        </OutsideClickHandler>
      </div>
    </React.Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

this snippet might be reused too

Collapse
tannerhallman profile image
Tanner Hallman Author

Ah cool, thanks for sharing!

Collapse
nuesslerm profile image
MN

Thanks a lot for this nice codesandbox. it helped me a lot to get my DropDown component to work in quite a short amount of time.
I want to share my modified version as a sandbox link. I used TS and my own useHandleClickOutside hook, but you could also use Sergey's "react-outside-click-handler" dep. I don't think that makes any difference.

I modularised it to accept React.ReactNodes as dropdown items and some popper props.

codesandbox.io/s/1-react-popper-va...

import React, { FC, useState, useRef } from "react";
import styled from "styled-components";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import useHandleClickOutside from "./useHandleClickOutside";

interface DropdownContainerProps {
  open: boolean;
}

const DropdownContainer = styled.div<DropdownContainerProps>`
  display: ${({ open }) => (open ? "flex" : "none")};
  width: 100%;
  flex-direction: column;
  background-color: #fff;
  border-radius: 5px;
  box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.14);
  padding: 5px;
`;

const DropdownItem = styled.div`
  justify-content: flex-start;
  height: 40px;
  padding-right: 10px;
  padding-left: 10px;
  align-items: center;

  &:hover {
    background-color: #00ffff;
  }
  &:active {
    font-weight: 700;
    color: #00ffff;
  }
`;

const DropDownTrigger = styled.button`
  border: none;
  background: none;
  font-size: 16px;
  font-family: inherit;
`;

const DefaultTitle: FC = () => {
  return <div>What</div>;
};

interface DropDownProps {
  children: React.ReactNode;
  titleElement?: React.ReactElement;
  placement?: Placement;
  offset?: { horizontal: number; vertical: number };
}

const Dropdown: FC<DropDownProps> = ({
  titleElement: TitleElement = <DefaultTitle />,
  placement = "bottom",
  offset = { horizontal: 0, vertical: 0 },
  children
}) => {
  const [open, setOpen] = useState(false);
  const referenceRef = useRef(null);
  const popperRef = useRef(null);

  const toggle = () => setOpen(!open);

  const { ref: DropDownRef } = useHandleClickOutside(setOpen);

  const { horizontal, vertical } = offset;
  const { styles, attributes } = usePopper(
    referenceRef.current,
    popperRef.current,
    {
      placement,
      modifiers: [
        {
          name: "offset",
          enabled: true,
          options: {
            offset: [horizontal, vertical]
          }
        }
      ]
    }
  );

  function handleDropdownClick(e: any) {
    e.preventDefault();
    toggle();
  }

  return (
    <div ref={DropDownRef}>
      <DropDownTrigger
        type="button"
        ref={referenceRef}
        onClick={handleDropdownClick}
      >
        {TitleElement}
      </DropDownTrigger>
      <div ref={popperRef} style={styles.popper} {...attributes.popper}>
        <DropdownContainer style={styles.offset} open={open}>
          {children &&
            React.Children.map(children, (child) => {
              return <DropdownItem>{child}</DropdownItem>;
            })}
        </DropdownContainer>
      </div>
    </div>
  );
};

export default Dropdown;
Enter fullscreen mode Exit fullscreen mode

App.tsx

import React from "react";
import DropDown from "./DropDown";

const App = () => {
  return (
    <DropDown titleElement={<div>{"Click Me!"}</div>}>
      <div>1 asdf</div>
      <div>2 asdf</div>
      <div>3 asdf</div>
      <div>4 asdf</div>
    </DropDown>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode
Collapse
waifutech profile image
Lewd Technologies Inc.

Making that arrow work requires some reverse-engineering on your part. Long story short:

.dropdownPopper

...

  &[data-popper-placement^='top'] > [data-popper-arrow]
    bottom: -4px

  &[data-popper-placement^='right'] > [data-popper-arrow]
    left: -4px

  &[data-popper-placement^='bottom'] > [data-popper-arrow]
    top: -4px

  &[data-popper-placement^='left'] > [data-popper-arrow]
    right: -4px

  [data-popper-arrow]
    position: absolute
Enter fullscreen mode Exit fullscreen mode
Collapse
palashkaria profile image
Palash Karia

In the last example here, the popover closes even when an item inside the menu is clicked

You might want to use a useOutsideClick hook maybe? usehooks.com/useOnClickOutside/

Collapse
tannerhallman profile image
Tanner Hallman Author

That is the expected behavior of a dropdown component in my experience and the direction I was headed with this example. It is definitely a nice feature if you're looking to persist the visibility of the dropdown menu outside clicks. Thanks for the link!