1

To piggyback on a previous question, I'd like my menu to close whenever I click somewhere else other than the menu.

Currently, it will open/close when I click the "Hamburger Menu Button". It will close if I click a link on the menu or the menu itself but I'd also like to close when I "blur" away from it. This would be like if you got an alert message and you clicked somewhere else, the alert would close.

Here is the current CodeSandbox

The relevant code is in Header.tsx:

import React, { useState } from "react";
import { Link } from "react-router-dom";
import {
  AppBar,
  Container,
  createStyles,
  IconButton,
  makeStyles,
  Theme,
  Toolbar,
  Typography
} from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import clsx from "clsx";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      flexGrow: 1,
      "& a": {
        color: "white",
        textDecoration: "none"
      }
    },
    menuButton: {
      marginRight: theme.spacing(2),
      zIndex: 2
    },
    title: {
      flexGrow: 1,
      zIndex: 2
    },
    toolBar: {
      "& div": {
        transition: "left .1s"
      }
    },
    menu: {
      zIndex: 1,
      width: 200,
      height: "100%",
      position: "fixed",
      top: 48,
      transition: "left .1s",
      marginRight: theme.spacing(2),
      left: -200,
      background: "#3f51b5",
      "& div:first-element": {
        marginTop: 100
      }
    },
    menuOpen: {
      left: 0,
      transition: "left .1s"
    },
    menuClose: {
      left: -200,
      transition: "left .1s"
    },

    topMenu: {
      display: "flex",
      "& div": {
        marginLeft: theme.spacing(1)
      }
    }
  })
);
const UserMenu = () => {
  const classes = useStyles();
  const [menuOpen, setMenuOpen] = useState(false);
  const toggleMenu = () => setMenuOpen(!menuOpen);
  const handleMenuClick = () => toggleMenu();
  return (
    <>
      <IconButton
        edge="start"
        className={classes.menuButton}
        color="inherit"
        aria-label="menu"
        onClick={toggleMenu}
      >
        <MenuIcon />
      </IconButton>
      <div className={classes.toolBar}>
        <Container
          className={clsx(classes.menu, {
            [classes.menuOpen]: menuOpen,
            [classes.menuClose]: !menuOpen,
            [classes.toolBar]: true
          })}
          onClick={handleMenuClick}
        >
          <div>
            <Link to="#">My Profile</Link>
          </div>
          <div>
            <Link to="#">Account</Link>
          </div>
          <div>
            <Link to="#">Admin</Link>
          </div>
        </Container>
      </div>
    </>
  );
};

const Header: React.FC = ({ children }) => {
  const classes = useStyles();

  return (
    <AppBar position="static" className={classes.root}>
      <Toolbar variant="dense">
        <UserMenu />
        <Typography variant="h6" className={classes.title}>
          <Link to="#">Widgets, LLC</Link>
        </Typography>
        <div className={classes.topMenu}>
          <div>
            <Link to="#">Sign out</Link>
          </div>
          <div>
            <Link to="#">One more</Link>
          </div>
        </div>
      </Toolbar>
    </AppBar>
  );
};

export default Header;

**Edit: ** This is the current Sandbox after applying Ryan's changes and moving the UserMenu into its own file.

aarona
  • 35,986
  • 41
  • 138
  • 186

1 Answers1

1

You can use Material-UI's ClickAwayListener for this. The only tricky part is avoiding an immediate close of your menu after clicking on the button to open the menu (because of the click event being handled by the button opening the menu and then the same click event being handled by the ClickAwayListener closing the menu). Typically you would want to avoid rendering the ClickAwayListener until the menu is open, but I think that might break the transition on the menu unless you did further changes. My example addresses this problem by calling event.stopPropagation() in the click handler for the menu button (handleOpen).

Here's a modified version of your code/sandbox demonstrating this:

import React, { useState } from "react";
import { Link } from "react-router-dom";
import {
  AppBar,
  Container,
  ClickAwayListener,
  createStyles,
  IconButton,
  makeStyles,
  Theme,
  Toolbar,
  Typography
} from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import clsx from "clsx";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      flexGrow: 1,
      "& a": {
        color: "white",
        textDecoration: "none"
      }
    },
    menuButton: {
      marginRight: theme.spacing(2),
      zIndex: 2
    },
    title: {
      flexGrow: 1,
      zIndex: 2
    },
    toolBar: {
      "& div": {
        transition: "left .1s"
      }
    },
    menu: {
      zIndex: 1,
      width: 200,
      height: "100%",
      position: "fixed",
      top: 48,
      transition: "left .1s",
      marginRight: theme.spacing(2),
      left: -200,
      background: "#3f51b5",
      "& div:first-element": {
        marginTop: 100
      }
    },
    menuOpen: {
      left: 0,
      transition: "left .1s"
    },
    menuClose: {
      left: -200,
      transition: "left .1s"
    },

    topMenu: {
      display: "flex",
      "& div": {
        marginLeft: theme.spacing(1)
      }
    }
  })
);
const UserMenu = () => {
  const classes = useStyles();
  const [menuOpen, setMenuOpen] = useState(false);
  const handleOpen = (event: React.MouseEvent) => {
    if (!menuOpen) {
      event.stopPropagation();
      setMenuOpen(true);
    }
  };
  const handleClose = (event: React.MouseEvent<any, MouseEvent>) => {
    if (menuOpen) {
      setMenuOpen(false);
    }
  };
  return (
    <>
      <IconButton
        edge="start"
        className={classes.menuButton}
        color="inherit"
        aria-label="menu"
        onClick={handleOpen}
      >
        <MenuIcon />
      </IconButton>
      <div className={classes.toolBar}>
        <ClickAwayListener onClickAway={handleClose}>
          <Container
            className={clsx(classes.menu, {
              [classes.menuOpen]: menuOpen,
              [classes.menuClose]: !menuOpen,
              [classes.toolBar]: true
            })}
            onClick={handleClose}
          >
            <div>
              <Link to="#">My Profile</Link>
            </div>
            <div>
              <Link to="#">Account</Link>
            </div>
            <div>
              <Link to="#">Admin</Link>
            </div>
          </Container>
        </ClickAwayListener>
      </div>
    </>
  );
};

const Header: React.FC = ({ children }) => {
  const classes = useStyles();

  return (
    <AppBar position="static" className={classes.root}>
      <Toolbar variant="dense">
        <UserMenu />
        <Typography variant="h6" className={classes.title}>
          <Link to="#">Widgets, LLC</Link>
        </Typography>
        <div className={classes.topMenu}>
          <div>
            <Link to="#">Sign out</Link>
          </div>
          <div>
            <Link to="#">One more</Link>
          </div>
        </div>
      </Toolbar>
    </AppBar>
  );
};

export default Header;

Edit close menu on click away

Ryan Cogswell
  • 75,046
  • 9
  • 218
  • 198
  • I forked your sandbox and moved `UserMenu` into its own component (See question edit). This is working great except for one use case. When you click the menu and you close it, reopening the menu through the "Hamburger Menu", it takes two clicks outside of the menu instead of one to get it to close. I'm going to try and see if I can fix this. If I can I'll edit your answer and mark it as correct. If you want to take stab at this use case, I would appreciate it. – aarona Feb 10 '21 at 21:42
  • 1
    Removing the `event.stopPropagation()` call from the `handleClose` method fixed this. Edited your answer and marked as answered. Thanks for the help. – aarona Feb 12 '21 at 06:18
  • @aarona Great! I updated the sandbox to match. At some point I would like to dig into the ClickAwayListener code to understand why that `stopPropagation` call had that effect, but not sure when I'll have the time for that. – Ryan Cogswell Feb 12 '21 at 13:31
  • Yeah this is really interesting because, I'm applying these changes to my project and `onClickAway` is being called when I open the menu (despite never being moused over the menu itself yet) and so the result is it calls the `handleOpen` (good) but then called `handleClickAway` (the method name I'm using atm) which then closed the menu so its like it never opens. I'm going to see if I can recreate this in a sandbox and I might make another question and tag you to see if you can help me figure this out. Are you in any slack groups or any React communities where I can bend someone's ear there? – aarona Feb 12 '21 at 17:48
  • @aarona That's why `handleOpen` needs the call to `event.stopPropagation()`. That behavior is expected. The `ClickAwayListener` doesn't know anything about whether or not your menu has been opened, it just fires the function passed to `onClickAway` whenever a click occurs outside of the content rendered within the `ClickAwayListener`. – Ryan Cogswell Feb 12 '21 at 17:53
  • Ryan, check this out. I used the `mouseEvent` and `touchEvent` in conjunction with `onClickAway` and got a slicker solution to this problem. No `event.stopPropagation()` was needed. I forked your version of the sandbox here: https://codesandbox.io/s/close-menu-on-click-away-forked-mxsht?file=/src/Header.tsx – aarona Feb 12 '21 at 20:46
  • @aarona The main change you made that simplifies things a little was to include the menu button within the `ClickAwayListener`, so now the clicks on that no longer trigger `onClickAway`. You can remove the event overrides on the `ClickAwayListener` and it still works fine. – Ryan Cogswell Feb 12 '21 at 20:55
  • Hey you are right! I did add a `div` tag though to wrap everything nested in the `ClickAwayListener`. I got an error before I did this. – aarona Feb 12 '21 at 21:13
  • @aarona Yes, the `ClickAwayListener` requires a single child that it can get a `ref` to. I think that was one reason I didn't try that approach before -- I was trying to avoid altering your html structure. – Ryan Cogswell Feb 12 '21 at 21:16
  • Fair enough. Swapped out the `div` for a fragment and everything was good to go! – aarona Feb 12 '21 at 21:17