How to implement a Collapsible Overflow Menu in React

How to implement a Collapsible Overflow Menu in React

Using Intersection observer to implement Collapsible menu

The most common use cases of Intersection Observer are Lazy Loading Images and Infinite Scroll. However it highly powerful and can be used for a wide range of interactions.

Most of us have either implemented or read about implementing a Responsive Navbar through CSS in which we move elements to dropdown when there is not enough space. But what if there is another way to handle such an interaction which can be more flexible.

In this post we are going to implement a Collapsible Overflow menu, in which only the items that can take the available space will be shown upfront and rest will go inside the overflow menu.

Why Intersection Observer?

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport. — developer.mozilla.org

For our design, IntersectionObserver can be a good fit because it can provide information about menu items that are overflowing the container (i.e. not visible within the container).

This is how our final interaction is going to look like:

Collapsible menuCollapsible menu

Let’s start!

Let us analyse what we need based on the interaction:

  • We need to know all elements that are present inside the container.

  • We also need information of which one’s are visible and which one’s are overflowing the container on resizing.

  • Once we have both the information we can just control the visibility of elements with CSS and render overflowing items inside the overflow menu.

First we start with an initial block of code that renders menu items as children in a container.

For the purpose of this post, I am using Material-UI with static content but the same concept can be extended to any component library or custom components and dynamic content too.

import React from "react";
import Button from "@material-ui/core/Button";
import { makeStyles } from "@material-ui/core/styles";
import DeleteIcon from "@material-ui/icons/Delete";
import CloudUploadIcon from "@material-ui/icons/CloudUpload";
import KeyboardVoiceIcon from "@material-ui/icons/KeyboardVoice";
import Icon from "@material-ui/core/Icon";
import ArchiveIcon from "@material-ui/icons/Archive";
import StarIcon from "@material-ui/icons/Star";
import SaveIcon from "@material-ui/icons/Save";
import DownloadIcon from "@material-ui/icons/ArrowDownward";
import IntersectionObserverWrapper from "./intersection-observer-wrapper";
const useStyles = makeStyles((theme) => ({
  button: {
    margin: theme.spacing(1),
    display: "flex",
    flex: "0 0 auto"
  }
}));

export default function IconLabelButtons() {
  const classes = useStyles();
  return (
    <IntersectionObserverWrapper>
      <Button
        color="primary"
        data-targetid="save"
        className={classes.button}
        startIcon={<SaveIcon />}
      >
        Save
      </Button>
      <Button
        color="primary"
        data-targetid="upload"
        className={classes.button}
        startIcon={<CloudUploadIcon />}
      >
        Upload
      </Button>
      <Button
        color="primary"
        data-targetid="download"
        className={classes.button}
        startIcon={<DownloadIcon />}
      >
        Download
      </Button>
      <Button
        color="primary"
        data-targetid="send"
        className={classes.button}
        startIcon={<Icon>send</Icon>}
      >
        Send
      </Button>
      <Button
        color="primary"
        data-targetid="star"
        className={classes.button}
        startIcon={<StarIcon />}
      >
        Star
      </Button>
      <Button
        color="primary"
        data-targetid="archive"
        className={classes.button}
        startIcon={<ArchiveIcon />}
      >
        Archive
      </Button>
      <Button
        color="primary"
        data-targetid="delete"
        className={classes.button}
        startIcon={<DeleteIcon />}
      >
        Delete
      </Button>
    </IntersectionObserverWrapper>
  );
}

Notice that we are passing a unique prop data-targetid to each child of IntersectionObserverWrapper, which will be used later on.

Now let’s dive into the core logic where we will implement the IntersectionObserverWrapper.

  • Create a new instance of IntersectionObserver.

  • Pass root element as the container of the child elements. (navRef.current in our example)

  • Set the threshold to 1. This means that, at any change in 100% of the target visibility our Observers callback will be executed.

  • In the observer callback we will update a state map to track visibility of elements.

  • After creating the Observer, we need to observe our targets i.e. menu items. For this, we get all the children of ancestor node which in our case is referenced with navRef and add as target only if it has a data-targetid property.

  • Both the logic of creating observer and adding targets can be added inside useEffect which runs post initial render.

  • Remember to disconnect the observer on unmount of the component.


export default function IntersectionObserverWrapper({ children }) {
  const classes = useIntersectionStyles();
  const navRef = useRef(null);
  const [visibilityMap, setVisibilityMap] = useState({});
  const handleIntesection = () => {
    // our logic of maintaining visibility state goes here 
  }
  useEffect(() => {
    const observer = new IntersectionObserver(
      handleIntersection,
      {
        root: navRef.current,
        threshold: 1
      }
    );
    // We are addting observers to child elements of the container div
    // with ref as navRef. Notice that we are adding observers
    // only if we have the data attribute targetid on the child element
    Array.from(navRef.current.children).forEach((item) => {
      if (item.dataset.targetid) {
        observer.observe(item);
      }
    });
    return () = {
       observer.disconnect();
    }
  }, []);
  return (
    <div className={classes.toolbarWrapper} ref={navRef}>
       {children}
    </div>
  );
}

Now we are ready to handle the logic for maintaining visibility state (visibilityMap).

const handleIntersection = (entries) => {
    const updatedEntries = {};
    entries.forEach((entry) => {
      const targetid = entry.target.dataset.targetid;
      // Check if element is visibile within container 
      if (entry.isIntersecting) {
        updatedEntries[targetid] = true;
      } else {
        updatedEntries[targetid] = false;
      }
    });
    // Overwrite previous state values with current state
    setVisibilityMap((prev) => ({
      ...prev,
      ...updatedEntries
    }));
};

Now that we have visibility state of each menu item, we can control their visibility during rendering by adding additional classNames using React.cloneElement(Yes, have to use React.cloneElement, can’t do it any other way for static children).The visibility can either be controlled by setting CSS property visibility or opacity.

return (
    <div className={classes.toolbarWrapper} ref={navRef}>
      {React.Children.map(children, (child) => {
        return React.cloneElement(child, {
          className: classnames(child.props.className, {
            [classes.visible]: !!visibilityMap[child.props["data-targetid"]],
            [classes.inVisible]: !visibilityMap[child.props["data-targetid"]]
          })
        });
      })}
    </div>
  );

Our Implementation up till this point will look like this:

Collapsible Items

Hold on. Its not finished yet…

Let’s implement Overflow Menu

For overflow menu we need to pass down all menu items and their visibility state as props.

Note: We can choose to render the Overflow menu outside of our container in all conditions or inside of it so that it is right beside the last visible element instead of being at the end of container always. For this demo, I will add it inside the container since it has higher complexity than it being rendered outside of container.

return (
    <div className={classes.toolbarWrapper} ref={navRef}>
      {React.Children.map(children, (child) => {
        return React.cloneElement(child, {
          className: classnames(child.props.className, {
            [classes.visible]: !!visibilityMap[child.props["data-targetid"]],
            [classes.inVisible]: !visibilityMap[child.props["data-targetid"]]
          })
        });
      })}
      <OverflowMenu
        visibilityMap={visibilityMap}
        className={classes.overflowStyle}
      >
        {children}
      </OverflowMenu>
    </div>
  );

Note that we haven’t passed the prop data-targetid to OverflowMenu component as we do not want our IntersectionObserver to observe on it.

The rendering logic of Overflow menu is simple, we filter and render only elements that are visible inside the menu

<Menu
        id="long-menu"
        anchorEl={anchorEl}
        keepMounted
        open={open}
        onClose={handleClose}
      >
        {React.Children.map(children, (child) => {
          if (!props.visibilityMap[child.props["data-targetid"]]) {
            return (
              <MenuItem key={child} onClick={handleClose}>
                {React.cloneElement(child, {
                  className: classnames(child.className, classes.inOverflowMenu)
                })}
              </MenuItem>
            );
          }
          return null;
        })}
</Menu>

Also the key to aligning the overflow menu to the right of last visible element is using flexbox with order property. The visible elements will have an order value less than that of overflow menu and the invisible elements will have the order value higher than it.

const useIntersectionStyles = makeStyles(() => ({
  visible: {
    order: 0,
    opacity: 1
  },
  inVisible: {
    order: 100,
    opacity: 0,
    pointerEvents: "none"
  },
  toolbarWrapper: {
    display: "flex",
    overflow: "hidden",
    padding: "0 20px"
  },
  overflowStyle: {
    order: 99,
    position: "sticky",
    right: "0",
    backgroundColor: "white"
  }
}));

And that is everything you need to build a collapsible menu!

Here is a full CodeSandbox Demo

Conclusion

We successfully made a collapsible menu in react. Our application only renders items that are visible within the container and the overflowing elements are rendered inside the overflow menu.

You can find the Github repository here for the full code.

Thank you for reading...

Do let me know your feedback about the posts in the comments. You can also reach out to me via DM on Twitter and follow me for more such content.

Good luck!

Did you find this article valuable?

Support Frontend Delight by becoming a sponsor. Any amount is appreciated!