skip to Main Content

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


  1. You can make these changes in the second useEffect hook call. This closes the modal when clicked outside.

    See if that fits your requirement.

    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);
          }
        }
    
    // added a condition if the dropdown is open then only add the event listener
        if (open) {
          document.body.addEventListener('click', handleOutsideClick, false);
        }
    
        return () => {
    
    // added a condition if the dropdown is open then only remove the event listener before the component gets unmounted
          if (open) {
            document.body.removeEventListener('click', handleOutsideClick, false);
          }
        };
      }, [open]);
    
    Login or Signup to reply.
  2. When your component loads, you add three event listeners to document.body for each dropdown (one, two, three) in this order:

    - one (`handleOutsideClick` added by dropdown one)
    - two (`handleOutsideClick` added by dropdown two)
    - three (`handleOutsideClick` added by dropdown three)
    

    Now if you decide to open dropdown one for example by clicking on it, you update its open state from true to false, causing your component to rerender. After your component is done rerendering, it:

    • Calls the cleanup function from your useEffect() for dropdown one, removing the event-listener added to document.body for one
    • Invokes the useEffect() callback since open, state changed, adding the click event listener back to document.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:

    - two
    - three
    - one'
    

    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 to document.body. Dropdown twos callback only knows about its open state (currently false), which is why you see false being logged. The important part here is that the callbacks for three and one' don’t get invoked after two is invoked as you’re calling e.stopImmediatePropagation() when two is called, which prevents these subsequent event handlers from being invoked. Instead, to fix this, remove your e.stopImmediatePropagation() call as you do want these callbacks to be invoked so that they can properly update their associated dropdowns open state to close the dropdown if needed.

    function handleOutsideClick(e: MouseEvent) {
      e.stopImmediatePropagation();
    

    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 your handleClick() which stops any of callbacks added to document.body from being invoked (and thus potentially closing one of the dropdowns) as the click event won’t bubble up to body:

    const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
      e.stopPropagation();
      setOpen(open => !open);
    };
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search