Skip to Content
Menu
All Posts

Article

Building Context Menus the Right Way (Using React Context)

Context: the interrelated conditions in which something exists or occurs — Merriam Webster. Although context has been defined differently in various software engineering paradigms, the simplest bas...

Toluwani Adewale 8 min read
Updated

Image for building Context Menus the right way

Context: the interrelated conditions in which something exists or occurs 

— Merriam Webster. 

Although context has been defined differently in various software engineering paradigms, the simplest baseline definition is; the actions (interrelated conditions) around which an event occurs or vice-versa. Both actions and events can take up any form, spanning different progressions; from the actions being incomplete data to the events being actions themselves, the principle still follows.

React Context doesn't deviate much from this principle, as it in simple terms provides an organized container in which actions (in the form of data) can be carried to components that require them, irrespective of the depths of these components in the virtual DOM tree so that some related event can occur.

Context Menus also relate to this principle in a way that in addition to the fact that they are contextually aware i.e. they respond logically to data based on the Operating System, program, or in general, the environment that they are in, they also exist in the form of events that occur due to an action.
In this article, we'll briefly dive through some steps to use React Context as the action for manipulating Context Menus being both the action and event.

I assume you've successfully set up your React project, so we'll jump into the specifics. We'll also skip styling, the complete implementation (styling and all) can be found here: react-context-menu. 
We'll build two main components, a parent called App and a child component called Tabs. We've split these to further demonstrate contexts within our demo application. Our directory structure placed in /src is presented below:

.
├── App.css
├── App.tsx
├── index.tsx
├── Components/
│   └── Layout/
        ├── AppHeaderContextMenu.tsx
        ├── GenericContextMenu.tsx
        ├── TabContextMenu.tsx
        ├── types.ts
│   └── Tab/
        ├── Tabs.tsx
├── Contexts/
        ├── ContextMenuToggleContext.ts
        ├── types.ts
├── Hooks/
        ├── useContextMenuToggle.ts
        ├── types.ts

We'll begin in our Layouts directory by writing logic around the GenericContextMenu component. We'll build using the bottom-up approach in order to have existing components ready to be used when we need them. GenericContextMenu relies on ContextMenuToggleContext so we'll build this out first:

// src/Contexts/ContextMenuToggleContext.ts

import { Context, createContext } from "react";
import { ContextMenuToggleProps } from "./types";

export const ContextMenuToggleContext: Context<ContextMenuToggleProps> =
  createContext<ContextMenuToggleProps>({
    close: () => {
      return;
    },
    isOpen: () => {
      return false;
    },
    name: "",
    open: () => {
      return;
    },
    positions: {
      xPosition: 0,
      yPosition: 0,
    },
  });

Here, we have simply created a context with the value of an object which provides data and methods that we can carry through different depths in our tree, this will be used by our context menu. We'll further create type annotations for ContextMenuTogglProps:

// src/Contexts/types.ts

import { OpenContextMenuToggle } from "../Hooks/types";

export interface ContextMenuToggleProps {
  close: () => void;
  isOpen: (name?: string) => boolean;
  name: string;
  open: OpenContextMenuToggle;
  positions: {
    xPosition: number;
    yPosition: number;
  };
}

Let's also create type annotations for OpenContextMenuToggle: 

// src/Hooks/types.ts

import { MouseEvent } from "react";

export interface OpenContextMenuToggle {
  (e: MouseEvent<HTMLElement>, name: string): void;
}

In order to wrap up our work with type annotations and shift our emphasis to logic, let's take a step forward and implement the last type annotation for our application. I'll explain in detail what each of these represents.

// src/Components/Layout/types.ts

export enum ContextMenuType {
  AppHeader = "app-header",
  Tab = "tab",
}

export interface ContextMenuProps {
  xPosition: number;
  yPosition: number;
  isOpen: boolean;
}

export interface GenericMenu {
  label: string;
  show: boolean;
  onClick: () => void;
}

export interface GenericMenuProps {
  menus: GenericMenu[];
  xPosition: number;
  yPosition: number;
  isOpen: boolean;
}

export interface OutsideClickContextState {
  show: boolean;
  name: string;
}

Next, we'll create a reusable function i.e. hook which will help us to manage the state of ContextMenuToggleContext. The snippet of the hook below is pretty self-explanatory, we've got a callback called open that sets the state of positions to an object of the values - the x and y coordinates of the cursor, it also sets the state of showContextMenu to an object of the values of show which controls the visibility of the context menu, and name which registers the name of the context menu. name is used to differentiate the different context menu types in our application, we return these states in our hook.

// src/Hooks/useContextMenuToggle.ts

import { MouseEvent, useCallback, useState } from "react";
import { OpenContextMenuToggle } from "./types";
import { OutsideClickContextState } from "../Components/Layout/types";
import { ContextMenuToggleProps } from "../Contexts/types";

export default function useContextMenuToggle(): ContextMenuToggleProps {
  const outsideClick: OutsideClickContextState = {
    show: false,
    name: "",
  };
  const [positions, setPositions] = useState({
    xPosition: 0,
    yPosition: 0,
  });
  const [showContextMenu, setShowContextMenu] =
    useState<OutsideClickContextState>(outsideClick);

  function close() {
    setShowContextMenu(outsideClick);
  }

  const open: OpenContextMenuToggle = useCallback(
    (e: MouseEvent<HTMLElement>, name: string) => {
      e.preventDefault();
      e.stopPropagation();

      setPositions({
        xPosition: e.clientX,
        yPosition: e.clientY,
      });

      setShowContextMenu({
        show: true,
        name,
      });
    },
    [setShowContextMenu]
  );

  function isOpen(name?: string): boolean {
    return showContextMenu.show && (!name || name === showContextMenu.name);
  }

  return {
    close,
    isOpen,
    name: showContextMenu.name,
    open,
    positions,
  };
}

We can now proceed to build the logic for the GenericContextMenu component. This component is simply a container wrapper that provides reusable context menu logic to the other context menu components which we'll discuss soon.

// src/Components/Layout/GenericContextMenu.tsx

import React, {
  Fragment,
  MouseEvent,
  memo,
  useContext,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { GenericMenuProps } from "./types";
import { ContextMenuToggleContext } from "../../Contexts/ContextMenuToggleContext";

export default memo(function GenericContextMenu({
  menus,
  xPosition,
  yPosition,
  isOpen,
}: GenericMenuProps) {
  const [position, setPosition] = useState({
    top: yPosition,
    left: xPosition,
  });
  // We filter out the menus that are not shown
  const shownMenus = menus.filter((menu) => menu.show === true);
  const ref = useRef<HTMLDivElement>(null);
  const contextMenuToggle = useContext(ContextMenuToggleContext);

  // We adjust the position of the context menu if it is going to be out of the screen
  // We use useLayoutEffect to avoid flickering, useLayoutEffect runs synchronously after all DOM mutations.
  useLayoutEffect(() => {
    function adjustPosition() {
      const { innerWidth, innerHeight } = window;
      const { width, height } = ref.current?.getBoundingClientRect() || {
        width: 0,
        height: 0,
      };

      const newPosition = {
        top: yPosition + height > innerHeight ? yPosition - height : yPosition,
        left: xPosition + width > innerWidth ? xPosition - width : xPosition,
      };

      setPosition(newPosition);
    }

    adjustPosition();
  }, [xPosition, yPosition]);

  return (
    <>
      {isOpen && (
        <div
          ref={ref}
          className="Generic-context-menu"
          style={{ top: `${position.top}px`, left: `${position.left}px` }}
        >
          {shownMenus.map((menu, index) => (
            <Fragment key={index}>
              <button
                onClick={(e: MouseEvent<HTMLButtonElement>) => {
                  e.stopPropagation();
                  menu.onClick();
                  contextMenuToggle.isOpen() && contextMenuToggle.close();
                }}
                className="Generic-context-button"
              >
                <span>{menu.label}</span>
              </button>

              {index !== shownMenus.length - 1 && (
                <hr className="Generic-context-hr" />
              )}
            </Fragment>
          ))}
        </div>
      )}
    </>
  );
});

We'll now briefly discuss the two context menus for each of our major components; AppHeaderContextMenu and TabContextMenu. Both accept xPosition and yPosition as parameters that are directly passed down to GenericContextMenu for positioning. isOpen is also another passed-down parameter that determines whether or not the context menu should be opened or closed. The last parameter that we pass to GenericContextMenu is menus, this is an array of objects of varying action items that we define in each of the two context menus based on the menu type.

// src/Components/Layout/AppHeaderContextMenu.tsx

import React, { memo } from "react";
import { ContextMenuProps, GenericMenu } from "./types";

import GenericContextMenu from "./GenericContextMenu";

export default memo(function AppHeaderContextMenu({
  isOpen,
  xPosition,
  yPosition,
}: ContextMenuProps) {
  const menus: GenericMenu[] = [
    {
      label: "Reload",
      show: true,
      onClick: () => {
        window.location.reload();
      },
    },
    {
      label: "Inspect",
      show: true,
      onClick: () => {
        window.location.reload();
      },
    },
    {
      label: "Console",
      show: true,
      onClick: () => {
        console.log("Console");
      },
    },
  ];

  return (
    <>
      <GenericContextMenu
        menus={menus}
        xPosition={xPosition}
        yPosition={yPosition}
        isOpen={isOpen}
      />
    </>
  );
});

 

// src/Components/Layout/TabContextMenu.tsx

import React, { memo } from "react";
import { ContextMenuProps, GenericMenu } from "./types";

import GenericContextMenu from "./GenericContextMenu";

export default memo(function TabContextMenu({
  isOpen,
  xPosition,
  yPosition,
}: ContextMenuProps) {
  const menus: GenericMenu[] = [
    {
      label: "Console",
      show: true,
      onClick: () => {
        console.log("Console");
      },
    },
    {
      label: "Reload",
      show: true,
      onClick: () => {
        window.location.reload();
      },
    },
  ];

  return (
    <>
      <GenericContextMenu
        menus={menus}
        xPosition={xPosition}
        yPosition={yPosition}
        isOpen={isOpen}
      />
    </>
  );
});

Next, we'll import AppHeaderContextMenu into App

// src/App.tsx

import React, { MouseEvent, useCallback } from "react";
import "./App.css";
import Tabs from "./Components/Tab/Tabs";
import AppHeaderContextMenu from "./Components/Layout/AppHeaderContextMenu";
import { ContextMenuType } from "./Components/Layout/types";
import useContextMenuToggle from "./Hooks/useContextMenuToggle";
import { ContextMenuToggleContext } from "./Contexts/ContextMenuToggleContext";

export default function App() {
  const contextMenuToggle = useContextMenuToggle();

  const handleOutsideClick = useCallback(() => {
    contextMenuToggle.isOpen() && contextMenuToggle.close();
  }, [contextMenuToggle]);

  function handleRightClick(e: MouseEvent<HTMLDivElement>) {
    e.preventDefault();
    e.stopPropagation();
  }

  return (
    <div
      className="App"
      onClick={handleOutsideClick}
      onContextMenu={handleRightClick}
    >
      <header
        className="App-header"
        onContextMenu={(e) => {
          contextMenuToggle.open(e, ContextMenuType.AppHeader);
        }}
      >
        <ContextMenuToggleContext.Provider value={contextMenuToggle}>
          <Tabs />
        </ContextMenuToggleContext.Provider>
      </header>

      <AppHeaderContextMenu
        {...contextMenuToggle.positions}
        isOpen={contextMenuToggle.isOpen(ContextMenuType.AppHeader)}
      />
    </div>
  );
}

We'll also import TabContextMenu in Tab

// src/Components/Tab/Tabs.tsx

import React, { useContext } from "react";
import { ContextMenuToggleContext } from "../../Contexts/ContextMenuToggleContext";
import { ContextMenuType } from "../Layout/types";
import TabContextMenu from "../Layout/TabContextMenu";

export default function Tabs() {
  const tabContextMenu = useContext(ContextMenuToggleContext);

  return (
    <>
      <ul onContextMenu={(e) => tabContextMenu.open(e, ContextMenuType.Tab)}>
        <li>
          <a href="#home">Home</a>
        </li>
        <li>
          <a href="#home">Products</a>
        </li>
        <li>
          <a href="#home">Services</a>
        </li>
        <li>
          <a href="#home">About</a>
        </li>
        <li>
          <a href="#home">Contact</a>
        </li>
      </ul>

      <TabContextMenu
        {...tabContextMenu.positions}
        isOpen={tabContextMenu.isOpen(ContextMenuType.Tab)}
      />
    </>
  );
}

You can proceed to test the results in your browser. This article provides a very basic implementation of the principle and you can build other context menus around this. 

Repository:  react-context-menu.

Responses (1)

Have a question, correction, or another approach? Add your response.

Your name and response will be public.

pjjonesnz

Very well written and explained. Thanks!