A Dropdown component that, when clicked, displays a dropdown menu, and when clicking outside the dropdown area, closes the dropdown.
Now there is an issue that I have spent a long time trying to solve:
Using only one Dropdown component, clicking in a blank area successfully closes the dropdown.
However, if there are two or three Dropdown components, clicking in a blank area does not close the dropdown.
My expected outcome: Multiple Dropdown components, where clicking on the blank area should close the dropdown menus.
The current issue is: For a single Dropdown component, clicking on the blank area closes it; however, for multiple Dropdown components, clicking on the blank area does not close them.
How to solve this problem?
The Live Demo:
https://stackblitz.com/edit/vitejs-vite-cizh35?file=src%2FApp.tsx
import { useEffect, useRef, useState } from 'react';
type DropdownProps = {
className?: string;
handleSelectLanguage?: (language: string) => void;
text: string;
items: string[];
};
export const Dropdown: React.FC<DropdownProps> = (props) => {
const [open, setOpen] = useState(false);
const [text, setText] = useState(props.text);
const ref = useRef<HTMLDivElement>(null);
const handleClick = () => {
setOpen(!open);
};
useEffect(() => {
setText(props.text);
}, [props.text]);
useEffect(() => {
console.log('open1: ', open);
function handleOutsideClick(e: MouseEvent) {
e.stopImmediatePropagation();
console.log('open2: ', open);
const [target] = e.composedPath();
console.log('target:', !ref.current?.contains(target as Node), open);
if (!ref.current?.contains(target as Node) && open) {
console.log('2');
setOpen(false);
}
}
document.body.addEventListener('click', handleOutsideClick, false);
return () => {
document.body.removeEventListener('click', handleOutsideClick, false);
};
}, [open]);
return (
<>
<div className={`dropdown relative ${props.className}`} ref={ref}>
<button
className="w-full flex justify-center items-center rounded text-sm px-2 py-1 hover:bg-[#e3e3e3] border"
style={{ display: 'flex' }}
onClick={handleClick}
>
<span>{text}</span>
<span className="test relative top-[1px] ml-1">
<svg
// stroke="currentColor"
viewBox="0 0 12 12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="w-3 h-3"
>
<path d="M8.77814 4.00916C8.58288 3.8139 8.26629 3.8139 8.07103 4.00916L5.89096 6.18923L3.77338 4.07164C3.57811 3.87638 3.26153 3.87638 3.06627 4.07164L2.71272 4.42519C2.51745 4.62046 2.51745 4.93704 2.71272 5.1323L5.54114 7.96073C5.7364 8.15599 6.05299 8.15599 6.24825 7.96073L6.6018 7.60718C6.61932 7.58966 6.63527 7.57116 6.64965 7.55186L9.13169 5.06982C9.32695 4.87456 9.32695 4.55797 9.13169 4.36271L8.77814 4.00916Z" />
</svg>
</span>
</button>
{open && (
<ul
className={`dropdown-ul z-2 absolute mt-1 w-full rounded bg-[#efefef] ring-1 ring-gray-300 max-h-52 overflow-y-auto`}
>
{props.items.map((item, index) => {
return (
<li
key={index}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none hover:bg-[#e3e3e3]"
onClick={() => {
setOpen(false);
setText(item);
console.log('item: ', item);
props.handleSelectLanguage &&
props.handleSelectLanguage(item);
}}
>
{item}
</li>
);
})}
</ul>
)}
</div>
</>
);
};
Unable to resolve this issue, I used a third-party library.
https://tailwindui.com/components/application-ui/elements/dropdowns
2
Answers
You can make these changes in the second
useEffect
hook call. This closes the modal when clicked outside.See if that fits your requirement.
When your component loads, you add three event listeners to
document.body
for each dropdown (one, two, three) in this order:Now if you decide to open dropdown one for example by clicking on it, you update its
open
state fromtrue
tofalse
, causing your component to rerender. After your component is done rerendering, it:useEffect()
for dropdown one, removing the event-listener added todocument.body
forone
useEffect()
callback sinceopen
, state changed, adding the click event listener back todocument.body
for dropdown one.Now the order of the click events added to
document.body
has changed since we removed dropdown one’s click event listener (one
) and added it back (one'
), and so instead it looks like this:This means that when you try and click outside of dropdown one to "close" it, the callback for dropdown two (
two
) will be invoked first since it’s now the first callback added todocument.body
. Dropdowntwo
s callback only knows about itsopen
state (currentlyfalse
), which is why you seefalse
being logged. The important part here is that the callbacks forthree
andone'
don’t get invoked aftertwo
is invoked as you’re callinge.stopImmediatePropagation()
whentwo
is called, which prevents these subsequent event handlers from being invoked. Instead, to fix this, remove youre.stopImmediatePropagation()
call as you do want these callbacks to be invoked so that they can properly update their associated dropdownsopen
state to close the dropdown if needed.You’ll notice that if you click on one dropdown and then another, the previous one will close. If you don’t want that behaviour, you can call
stopPropagation();
in yourhandleClick()
which stops any of callbacks added todocument.body
from being invoked (and thus potentially closing one of the dropdowns) as the click event won’t bubble up tobody
: