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! ๐
Top comments (10)
a bit modified version using "react-outside-click-handler" and "without styled-components":
this snippet might be reused too
Ah cool, thanks for sharing!
This is a great guide, thanks. Still struggling with random stuff about poppers in my own app but love this as a reference as the react-popper hooks example is too bare bones....
Random note: your code sandbox has this
background-color: "#FFF";
But I think it should be with no quotes
background-color: #FFF;
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...
App.tsx
Making that arrow work requires some reverse-engineering on your part. Long story short:
Project deadline around the corner, and you just saved me. Thanks mate :-)
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/
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!
You're an absolute life saver! Thank you so much :')
It's funny how you clarified it way better than the official docs. Great work my man. I'm interested to know what happened to that PR! Would like to help!