Add custom context menu to your website with React & TypeScript

Add custom context menu to your website with React & TypeScript

React, typescript & tailwindcss

ยท

8 min read

By the end of this post, you will have a new custom context menu component to drop into your web app. ๐Ÿ’ช

So what is a "custom context menu"? It is the menu that appears when you use your mouse to right click on a website, or two fingers on a mac track pad. You will notice some website disable this functionality, on the Firefox browser you can press cmd + shift + right mouse click to see the context menu.

โš ๏ธ Before adding a custom context menu to your website, remember you are removing what is standard expected behavior for the right mouse click event, so remember to consider accessibility and check your design is providing a real solution for the user with a custom context menu.


So what do we want to deliver here? ๐Ÿ’ฐ

  • A custom menu with specific options relevant to the current website.

  • When the user right clicks, the custom context menu should appear with the custom menu options.

  • The menu needs to always be visible, no matter where the user clicks on your website.

  • The context menu should disappear when the user scrolls or clicks outside the menu.

How do the deliverables translate into code? ๐Ÿคฒ

  1. We need event listeners to capture the user mouse events.

    • Considering there are 3 different types of events to listen for, an event listener hook will be useful.
  2. We need a component to handle all the logic for showing and hiding the context menu.

  3. We need a component for the menu options. (Not included here this is where you get creative)


The event listener hook ๐ŸŽง

Considering this is not the main focus of this post I am not going to go deep into this hook, but feel free to ask about it.


import { useEffect, useRef, RefObject } from 'react';

/*
  - "type" of listener.
  - "listenerCallback" to handle event.
  The keyof operator takes an object type
  and produces a string or numeric literal union of its keys

  - "target" for listener to be attached to,
  right now can be window or html element
  not hooked up to work with document.

  "options" generally goes unused can also be set to false by default,
  but unused is okay.
*/

const useEventListener = <
  KW extends keyof WindowEventMap,
  KH extends keyof HTMLElementEventMap,
  T extends HTMLElement
>(
  type: KW | KH,
  listenerCallback: (
    event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event
  ) => void,
  target?: RefObject<T>,
  options?: boolean | EventListenerOptions | undefined
): void => {
  // using ref so we dont have to add listenerCallback
  // to the dependency array
 // of the useEffect where the listener is added below.
  const savedHandler = useRef(listenerCallback);

  useEffect(() => {
    // add callback to ref
    savedHandler.current = listenerCallback;
  }, [listenerCallback]);

  useEffect(() => {
    // window or element
    const targetActual: T | Window = target?.current || window;

    // check event listener is available on targetElement,
    // TODO:: maybe add assert here?
    if (!targetActual?.addEventListener) {
      return undefined;
    }

    // Create event listener that calls handler function stored in ref
    const eventListenerCallback: typeof listenerCallback = (event) =>
      savedHandler.current(event);
    // on component mount listen for event
    targetActual.addEventListener(type, eventListenerCallback);
    // on component unmount remove listener
    return () =>
      targetActual.removeEventListener(type, eventListenerCallback);
  }, [target, type]);
};

export default useEventListener;

The context menu component ๐Ÿ™ˆ๐Ÿ™‰๐Ÿ™Š

This is where the logic for showing and hiding the context menu lives & the container of the menu itself.

import React, { FC, useEffect, useRef, useState } from 'react'
import ShareMenu from '../shareMenu'
import useEventListener from '../hooks/useEventListener'

const CustomContextMenu: FC = () => {
    const [visible, setVisible] = useState<boolean>(false)
    const [confirmCopy, setConfirmCopy] = useState<boolean>(false)
    const [clientSide, setClientSide] = useState<boolean>(false)

    useEffect(function onClientSideSetClientIsTrue(): void {
        // This approach for checking client side is preferred over
        // typeof window !== 'undefined' as window is not available server side.
        // useEffect method will only be executed on the client.
        setClientSide(true)
    }, [])

    // refs provide a way to access DOM nodes or React elements created in the render method.
    const rootMenuRef = useRef<HTMLDivElement>(null)
    const copyRef = useRef<HTMLDivElement>(null)

    const handleContextMenu = (event: Event | MouseEvent): void => {
        event.preventDefault()

        setVisible(true)

        const rootRef: HTMLDivElement | null = rootMenuRef?.current
        if (!rootRef) {
            return
        }

        const clickX: number = (event as MouseEvent).clientX
        const clickY: number = (event as MouseEvent).clientY
        const screenW: number = window.innerWidth
        const screenH: number = window.innerHeight
        // root width and height have to be hard coded,
        // this is because the element in not visible making innerHeight and innerWidth === 0
        const rootW: number = 256 // menu width you will see this matches the tailwindcss in the return
        const rootH: number = 320 // menu height you will see this matches the tailwindcss in the return


        const right: boolean = clickX > screenW / 2
        const left: boolean = !right

        const top: boolean = screenH - clickY > rootH
        const bottom: boolean = !top

        const refStyles: CSSStyleDeclaration | undefined = rootRef?.style
        if (refStyles === undefined) {
            return
        }

        if (refStyles !== undefined) {
            if (left) {
                refStyles.left = `${clickX + 5}px`
            }
            if (right) {
                // horizontal mouse location, minus the menu width - 5
                refStyles.left = `${clickX - rootW - 5}px`
            }
            if (top) {
                refStyles.top = `${clickY + 5}px`
            }
            if (bottom) {
                // vertical mouse location, minus the menu height - 5
                refStyles.top = `${clickY - rootH - 5}px`
            }
        }
    }

    const handleMouseAction = (event: Event): void => {
        // prevent event propagating to parent because
        // without this prevention this click will hide the menu
        event.stopPropagation()

        const rootRef = rootMenuRef?.current
        if (!rootRef) {
            return
        }

        const copRef = copyRef?.current
        if (!copRef) {
            return
        }
        /* if() logic in plain english */
        // If the context menu is not visible AND 
        // the click event target is from the menu itself OR
        // the click event is from the menu option to copy a url THEN
        // show the menu OTHERWISE
        // hide the menu.
        if (
            !visible &&
            ((rootRef && rootRef.contains(event.target as HTMLDivElement)) ||
                (copRef && copRef.contains(event.target as HTMLDivElement)))
        ) {
            setVisible(true)
        } else {
            setVisible(false)
        }
    }

    const handleScrollAction = (): void => {
        // if on scroll the menu is visible hide it OTHERWISE
        // do nothing
        if (visible) {
            setVisible(false)
        }
    }

    useEventListener('click', handleMouseAction)
    useEventListener('scroll', handleScrollAction)
    useEventListener('contextmenu', handleContextMenu)

    return !clientSide ? null : (
        <div
            ref={rootMenuRef}
            className={`${
                !visible
                    ? 'hidden'
                    : 'flex items-center justify-center w-64 h-80 fixed p-1 rounded bg-gray-100 text-center z-50 shadow-lg shadow-black'
            }`}
        >
            {visible && (
                <ShareMenu
                    ref={copyRef}
                    confirmCopy={confirmCopy}
                    setConfirmCopy={setConfirmCopy}
                />
            )}
        </div>
    )
}

export default CustomContextMenu

What does all this gibberish mean? Lets break it down.

First you will see we have some state that handles 3 different things. Which are all initially false.

  1. Menu visibility.

  2. A confirmation for if the copy menu option was clicked.

  3. Checking if the client is available.

const [visible, setVisible] = useState(false) const [confirmCopy, setConfirmCopy] = useState(false) const [clientSide, setClientSide] = useState(false)


#### Then you see we have two ```refs
  1. ref 1 holds the reference to the DOM node, which in this case is just a div.
  2. ref 2 is actually forwarded to a DOM node which is inside the ShareMenu react element.
// refs provide a way to access DOM nodes
// or React elements created in the render method
const rootMenuRef = useRef<HTMLDivElement>(null)
const copyRef = useRef<HTMLDivElement>(null)

Now we have the smarts of the component the handleContextMenu function.

There is a bit going on here so we can break this down also. This function handles if the context menu is visible & what the location is for the menu.

Show the menu

setVisible(true)


Check if the context menu ```
ref
``` is not null
> ```typescript   
const rootRef: HTMLDivElement | null = rootMenuRef?.current
if (!rootRef) {
   return
}

These are the coordinates of the mouse when the user clicked.

  • We need a Type Assertion for clickX/Y because in this case we know better than the compiler.

"as" is a Type Assertion in TypeScript which tells the compiler to consider the object as another type than the type the compiler infers the object to be.

const clickX: number = (event as MouseEvent).clientX // horizontal const clickY: number = (event as MouseEvent).clientY // vertical


Browser window width and height
> ```typescript
const screenW: number = window.innerWidth
const screenH: number = window.innerHeight

Root width and height have to be hard coded. This is because the DOM node in not visible making innerHeight and innerWidth both zero

const rootW: number = 256 // px // menu width const rootH: number = 320 // px // menu height


Here we are checking which quarter the mouse was click in.
*Imagine there is a line through the middle of the screen left to right & top to bottom.*
>```typescript
const right: boolean = clickX > screenW / 2
const left: boolean = !right
const top: boolean = screenH - clickY > rootH
const bottom: boolean = !top

Here is how we tell the context menu where to appear relative to the browser window and the mouse location.

if (refStyles !== undefined) { if (left) { refStyles.left = ${clickX + 5}px } if (right) { refStyles.left = ${clickX - rootW - 5}px } if (top) { refStyles.top = ${clickY + 5}px } if (bottom) { refStyles.top = ${clickY - rootH - 5}px } }



#### Next we have two functions ```handleMouseAction``` & ```handleScrollAction

These functions are callback functions for their respective event listeners, both handle showing and hiding the context menu.

Lastly we have our event listeners

useEventListener('click', handleMouseAction)
useEventListener('scroll', handleScrollAction)
useEventListener('contextmenu', handleContextMenu)

Finally we the return statement for our context menu component

You will see that if the client is not ready we return null, when it is we will have access to show and hide our context menu based on some react state and some tailwindcss styling.

return !clientSide ? null : (

... ```

That's it! You now your very own custom context menu to add to and react project.

Rory. โœŒ๏ธ

Did you find this article valuable?

Support Rory by becoming a sponsor. Any amount is appreciated!

ย