Search code examples
javascriptreactjsreact-hooksreact-context

Flyout menu using react context re-opens when closing it with its button


I am coding a FlyoutManager from which I can toggle every flyout menus in react app. I am trying to set the property to close when clicking outside. I found on another stackoverflow question that my clicking-outside function is triggered when I click the button to close it so it re-opens the flyout menu because it toggles the menu state twice.

Have you got some ideas to fix my problem ?

Here is the Header code simplified to my problem :

export const Header = () => {
    const {toggleFlyout} = useFlyout();
    return (
        <header>
            <div>
                /*
                    ...
                    Other components
                */
                <nav>
                    /*
                        ...
                        Other buttons
                    */
                    <button id="UserFlyout" onClick={() => toggleFlyout("UserFlyout")}>
                        User-logo
                    </button>
                </nav>
            </div>
        </header>
    );
};

Here is the FlyoutContext Folder which contains the toggle function :

export const FlyoutContext = createContext();

export const FlyoutProvider = ({children}) => {
    const [flyout, setFlyout] = useState(null);
    const [isOpen, setIsOpen] = useState(false)

    const toggleFlyout = name => {
        setIsOpen(!isOpen);
        if (!isOpen) {
            setFlyout(name);
        } else {
            setFlyout(null);
        }
    }

    return (
        <FlyoutContext.Provider value={{flyout, toggleFlyout}}>
            {children}
        </FlyoutContext.Provider>
    );
};

export const useFlyout = () => useContext(FlyoutContext);

Here is the FlyoutManager Folder

const FlyoutList = {
    UserFlyout: UserFlyout
};

export const FlyoutManager = () => {
    const {flyout, toggleFlyout} = useFlyout();
    
    const flyoutRef = useRef(null);

    const Flyout = FlyoutList[flyout];

    useEffect(() => {
        const handler = event => {
            if (flyoutRef.current !== null && !flyoutRef.current.contains(event.target)) {
                toggleFlyout(flyout);
            }
        };

        document.addEventListener('mousedown', handler);
        return () => {
            document.removeEventListener('mousedown', handler);
        };
    }, [flyoutRef, flyout, toggleFlyout]);  

    if (!flyout) return null;

    return (
        <Flyout flyoutRef={flyoutRef}>
            <div ref={flyoutRef}>
                user flyout
            </div>
        </Flyout>
    );
};

Thanks a lot for your help

Some ideas might be to check if the mousedown event target is not the button. But because I use a context I can't access the id button in the FlyoutContext.


Solution

  • New answer

    You were rigth, splitting into two idempotent functions - openFlyout and closeFlyout - was not a complete answer.

    Instead, I've added a ref for the button to the context and checked if it's the button that has been clicked. Here's what the useEffect looks like now:

      useEffect(() => {
        const handler = (event) => {
          const isOutsideFlyout = !flyoutRef.current?.contains(event.target);
          const isOutsideButton = !flyoutButonRef.current?.contains(event.target);
          if (isOutsideFlyout && isOutsideButton) {
            closeFlyout();
          }
        };
    
        document.addEventListener("mousedown", handler);
        return () => {
          document.removeEventListener("mousedown", handler);
        };
      }, [flyoutRef, flyout, closeFlyout]);
    

    (the ? in flyoutRef.current?.contains is an Optional chaining operator, makes the condition shorter)

    And here's the button:

    import { useFlyout } from "./FlyoutContext";
    
    export default function FlyoutButton() {
      const { toggleFlyout, flyoutButonRef } = useFlyout();
    
      return (
        <button ref={flyoutButonRef} onClick={() => toggleFlyout("UserFlyout")}>
          UserFlyout
        </button>
      );
    }
    

    Your sandbox for some reason didn't work for me, so I created a new one from the scratch and copied the code, but anyway here's working sandbox example

    Former answer

    You've got two options, one is quite diffucult while the other is really simple:

    • either you somehow put the mousedown handler on something that covers the entire thing, including the button,
    • or you can just split the toggleFlyout into two idempotent functions.

    You can also entriely skip the open flag since it always comes together with the name, so if there is no name it means no flyout is open:

    function openFlyout(name) {
      setFlyout(name);
    }
    
    function closeFlyout() {
      setFlyout(null);
    }
    
    return (
      <FlyoutContext.Provider value={{flyout, openFlyout, closeFlyout}}>
        {children}
      </FlyoutContext.Provider>
    );