Skip to content

Instantly share code, notes, and snippets.

@Ravenstine
Last active January 13, 2020 15:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Ravenstine/ab78d2b13d6531e073aadbf7f2c22557 to your computer and use it in GitHub Desktop.
Save Ravenstine/ab78d2b13d6531e073aadbf7f2c22557 to your computer and use it in GitHub Desktop.

Building a Context Menu with Custom Elements

A custom context menu is a classic UI pattern that effectively expose features in your app. Today, we're going to talk about how you can create a custom context menu using HTML and JavaScript that is both flexible and accessible.

If only it were native

Your first thought might be to try to customize the native context menus as implemented by browsers. There is, in fact, an HTML element called <menu> <menu> can make this possible.

That said, I must disappoint you, because the <menu> element is deprecated and poorly supported across browsers. In fact, it's really only Firefox that supports it.

At first, this can seem like a show stopper, but it's really for the best. HTML should define the structure of a document, and defining context menus that break outside of that concern by defining a UI component that isn't a part of the document flow. Any differences in the UI toolkits between different operating systems could also introduce compatibility issues, especially if you want to implement unique features.

Using HTML and JavaScript, you can create highly capable context menus that match the look and feel of your web application. Today, we're going to learn how to make our own using web components.

The Structure of an HTML Context Menu

The structure of a context menu isn't complicated. At a minimum, you have a list of items in a containing element. Optionally, this list will have a title. For something more complicated, a context menu may have nested menus that reveal themselves on hover. It might also contain other elements such as icons. Here is an example of this structure looks like using custom HTML elements:

<context-menu>
  <context-menu-title>
    Menu
  </context-menu-title>
  <context-menu-option>
    Alert
  </context-menu-option>
  <context-menu-option>
    Confirm
  </context-menu-option>
  <context-menu-option>
    Prompt
  </context-menu-option>
  <context-menu-option>
    <span>More</span>
    <context-menu>
      <context-menu-title>
        Child Menu
      </context-menu-title>
      <context-menu-option>foo</context-menu-option>
      <context-menu-option>bar</context-menu-option>
      <context-menu-option>baz</context-menu-option>
    </context-menu>
  </context-menu-option>
</context-menu>

With a little CSS:

context-menu {
  display: flex;
  flex-direction: column;
  font-family: sans-serif;
  left: var(--pos-x);
  position: fixed;
  top: var(--pos-y);
  width: 120px;
}

context-menu-option > context-menu {
  display: none;
  position: fixed;
  left: initial;
  transform: translate(100%, -2rem);
  top: initial;
}

context-menu-title {
  color: darkgray;
  font-weight: bold;
}

context-menu-title, context-menu-option {
  background: whitesmoke;
  box-shadow: 0px 0px 1px 0px rgba(0,0,0,0.75);
  display: block;
  height: 2rem;
  line-height: 2rem;
}

context-menu-title:before,
context-menu-option:before {
  content: '';
  display: inline;
  padding-right: 10px;
}

context-menu-title:after,
context-menu-option:after {
  content: '';
  display: inline;
  padding-left: 10px;
}

context-menu-option:hover {
  background: lightgray;
  cursor: pointer;
}

context-menu-option:hover > context-menu {
  display: block;
}

context-menu > *:first-child {
  border-radius: 5px 5px 0px 0px;
}

context-menu > *:last-child {
  border-bottom: 0;
  border-radius: 0px 0px 5px 5px;
}

We get a menu that looks like this:

[SCREENSHOT HERE]

Although there's no JavaScript at play, we already have the bare minimum of what a context menu should be.

Wiring Up Our Menu

Let's assume that our web app has a container element:

<div id="app-container">
  <h2>Context Menu Demo</h2>
</div>

For the sake of development, we'll add some CSS to make our app container span the screen:

body {
  margin: 0;
  padding: 0;
}

#app-container {
  height: calc(100vh - 2rem);
  padding: 1rem;
  width: calc(100vw - 2rem);
}

We can add a template for our context menus using the <template> tag.

<template id="demo-menu-template">
  <context-menu>
    <context-menu-title>
      Menu
    </context-menu-title>
    <context-menu-option>
      Alert
    </context-menu-option>
    <context-menu-option>
      Confirm
    </context-menu-option>
    <context-menu-option>
      Prompt
    </context-menu-option>
    <context-menu-option>
      <span>More</span>
      <context-menu>
        <context-menu-title>
          Child Menu
        </context-menu-title>
        <context-menu-option>foo</context-menu-option>
        <context-menu-option>bar</context-menu-option>
        <context-menu-option>baz</context-menu-option>
      </context-menu>
    </context-menu-option>
  </context-menu>
</template>

This DOM element won't be rendered, but we can use its contents to render menus.

We know there will be some behavior shared between parts of the context menu, in which case we'll create a "service" to manage such behavior. For now, we will just worry about opening a menu.

const contextMenuService = {
  currentMenu: null,
    
  /**
   * This clones the HTML from the template, sets the x and y coordinates
   * on the <context-menu> element, and inserts it into the DOM.
   */
  open({ posX, posY }) {
    const template = document.getElementById('demo-menu-template')
                             .content
                             .cloneNode(true);
    const menu = template.querySelector('context-menu');
    this.currentMenu = menu;
    menu.setAttribute('pos-x', posX);
    menu.setAttribute('pos-y', posY);
    for (const element of Array.from(template.children)) {
      document.body.insertAdjacentElement('afterbegin', element); 
    }
  }
};

In our example, we're just making our service a global object. Depending on your application framework, something similar can be created and accessed through dependency injection. For now, an object in the global scope will be fine.

Now we will define our <context-menu> element:

class ContextMenu extends HTMLElement {

  /**
   * Whenever these attributes are changed,
   * our `attributeChangedCallback` method will get fired
   * which will recompute anything that depends on those
   * attributes.
   **/
  static observedAttributes = ['pos-x', 'pos-y'];

  constructor() {
    super();
  }
  
  /* When the element is inserted into the DOM,
     set the X position and the Y position as
     CSS variables */
  attributeChangedCallback() {    
    const posX = this.getAttribute('pos-x');
    const posY = this.getAttribute('pos-y');
    
    if (posX) {
      this.style.setProperty('--pos-x', `${posX}px`);
    }
    
    if (posY) {
      this.style.setProperty('--pos-y', `${posY}px`);
    }
  }

}

/* Define the tagName for the custom element. */
window.customElements.define('context-menu', ContextMenu);

What's cool is that every time the <context-menu> element appear in the DOM, our code in connectedCallback will be called by the browser!

For any of this to work, we've got to listen to the contextmenu event on our app container, which is an event fired by the browser when the user right-clicks.

document.getElementById('app-container').addEventListener('contextmenu', e => {
  e.preventDefault();  // this prevents the browser from opening the native context menu
  e.stopPropagation();
  contextMenuService.open({
    posX: e.clientX,
    posY: e.clientY
  });
});

Let's see what happens when we right-click in the app container:

[SCREENSHOT HERE]

The reason we insert the context menu at the highest level in the body, outside of our app container, is because this helps prevent issues with surrounding elements causing issues with positioning. It's tempting to just have the menu be inserted directly into the element where the contextmenu event emanated from, especially if you are using a templating engine. This can be problematic because a parent element's positioning can make it unnecessarily difficult to get the menu to appear exactly where the right-click occurred, even with absolute positioning. Inserting it into the top level of the body saves us from any of these hassles.

Dismissal

Opening a context menu is great and all, but we also need to make it go away.

Let's add a close method to our context menu service:

const contextMenuService = {

// ...

  close() {
    this.currentMenu.remove();
    this.currentMenu = null;
  }

// ...

};

The simplest form of dismissal we can code uses the escape key to close the menu. All we have to do is call close() in our context menu component:

class ContextMenu extends HTMLElement {
  
  /* We will listen to any escape key events and see if it's the escape key. */
  constructor() {
  // ...
    window.addEventListener('keyup', this.onCloseIntent);
  }

// ...
  
  /* If the key that was pressed is the escape key, close the menu */
  onCloseIntent(e) {
    const isEscape = e.which === 27;

    if (isEscape) {
      contextMenuService.close();
    }
  }

  /* Since we are setting the event listener on the window, we want to remove
     it when the element is removed from the DOM */ 
  disconnectedCallback() {
    window.removeEventListener('keyup', this.onCloseIntent);
  }

}

When the menu is open, the user can press the escape key to close the menu.

We now have one way to close the menu, but it's not the most obvious way to close it. The most obvious way to dismiss a context menu is to click outside of it, so we need a way to detect outside-clicks.

In my experience, the best way to do this is to use a viewport-sized element behind the menu to capture clicks. For this purpose, it's far simpler than trying to capture clicks from other surrounding elements because you never know when you are going to need to prevent a click event from bubbling with e.stopPropagation(); in some other element! An outside-click area can also serve the dual purpose of acting as a backdrop to dim the background and emphasize the menu in the foreground.

To do this, we can make our own <outside-click-area> element:

class OutsideClickArea extends HTMLElement {
  constructor() {
    super();
    
    this.addEventListener('click', function() {
      contextMenuService.close();
    });
  }
}

window.customElements.define('outside-click-area', OutsideClickArea);

It needs to span the entire viewport, so we need some CSS for that:

context-menu {
/* ... */

  /* This z-index allows the menu to appear above the click-area */
  z-index: 2;
}


outside-click-area {
  /* We set a background color with alpha so that the click
      area can also be used to emphasize the foreground */
  background: rgba(180, 180, 180, 0.7);
  height: 100vh;
  left: 0;
  position: absolute;
  top: 0;
  width: 100vw;
  /* z-index is set to 1 so it will appear below the context menu */
  z-index: 1;
}

All we need to do now is insert the click area and remove it on close:

<template id="demo-menu-template">
  <context-menu>
    <context-menu-title>
      Menu
    </context-menu-title>
    <context-menu-option>
      Alert
    </context-menu-option>
    <context-menu-option>
      Confirm
    </context-menu-option>
    <context-menu-option>
      Prompt
    </context-menu-option>
    <context-menu-option>
      <span>More</span>
      <context-menu>
        <context-menu-title>
          Child Menu
        </context-menu-title>
        <context-menu-option>foo</context-menu-option>
        <context-menu-option>bar</context-menu-option>
        <context-menu-option>baz</context-menu-option>
      </context-menu>
    </context-menu-option>
  </context-menu>
  <outside-click-area></outside-click-area>
</template>

And remove it when we close the menu:

const contextMenuService = {

// ...

  close() {
  
  // ...

    /* Find and remove the click area */
    document.querySelector('outside-click-area').remove();
  }

};

Clicking outside of the menu now makes it close!

Handling Option Behavior

A context menu is nothing if all it does is open and close. We want custom functionality to occur whenever a user clicks on a menu option.

Let's define this behavior with a custom element for our context menu options:

class ContextMenuOption extends HTMLElement {
  
  constructor() {
    super();

    /* If we receive a click, call onSelect
       to execute click behavior, unless the
       option is the parent of a child menu */
    this.addEventListener('click', e => {
      /* Prevent the event from repeating in parent options */
      e.stopPropagation();
      this.onSelect();
    });
  }

  get childMenu() {
    for (const child of this.children) {
      if (child.tagName === 'CONTEXT-MENU') {
        return child;
      }
    }
  }
  
  /* When the user selects the option, we add a 200 millisecond delay
     before we do anything.  This is optional and just allows for some
     slight visual feedback when the user clicks.
     
     Because it's not possible to directly attach a function to our
     menu element, we can take a string from the `on-select` attribute
     and evaluate it. */
  onSelect() {
    if (!this.childMenu) {
      setTimeout(() => {
        const functionExp = this.getAttribute('on-select');
        if (functionExp) {
          const fn = new Function(functionExp);
          fn();
        }
        contextMenuService.close();
      }, 200);
    }
  }
}

window.customElements.define('context-menu-option', ContextMenuOption);

Now we need some functions that can be called with our menu options:

function onAlert() {
  alert('alert!');
}

function onConfirm() {
  confirm('confirmed?');
}

function onPrompt() {
  prompt('prompt.');
}

Finally, let's update our template:

<template id="demo-menu-template">
  <context-menu>
    <context-menu-title>
      Menu
    </context-menu-title>
    <!-- Adds JavaScript strings in the `on-select` attributes -->
    <context-menu-option on-select="onAlert();">
      Alert
    </context-menu-option>
    <context-menu-option on-select="onConfirm();">
      Confirm
    </context-menu-option>
    <context-menu-option on-select="onPrompt();">
      Prompt
    </context-menu-option>
    <context-menu-option>
      <span>More</span>
      <context-menu>
        <context-menu-title>
          Child Menu
        </context-menu-title>
        <context-menu-option>foo</context-menu-option>
        <context-menu-option>bar</context-menu-option>
        <context-menu-option>baz</context-menu-option>
      </context-menu>
    </context-menu-option>
  </context-menu>
  <outside-click-area></outside-click-area>
</template>

Our menu can now call different functions when a user clicks on the available options.

We've got the beginnings of a viable context menu for the web! It's usable, but we've got some wrinkles to iron out.

Reactive Positioning

This context menu we've made has a left-to-right flow, meaning that the menu renders to the right of the cursor and child menus appear to the right of their parent. If the user opens the menu too far to the right on the screen, parts of the context menu will fall outside the edge of the viewport! The same thing can happen if they open the menu too low in their viewport.

We can mitigate this issue by using the position where the menu was invoked:

class ContextMenu extends HTMLElement {
  
// ...

  /* We refactor this a bit and include some logic to handle positioning */
  attributeChangedCallback() {
    const posX = this.getAttribute('pos-x');
    const posY = this.getAttribute('pos-y');
    /* If the menu was invoked on the right side of the viewport,
        this will be true, telling the menu to render its elements
        to the left instead of the right */
    const isLeft = parseInt(posX) > (window.innerWidth / 2);
    /* Like isLeft, if the menu was invoked towards the bottom
      of the screen, this will be true, telling the menu to render
      its elements above instead of below. */
    const isAbove = parseInt(posY) > (window.innerHeight / 2);
    
    /* Defining our translation values */
    let tx, ty;
    
    if (this.parentOption) {
      /* If this is a child menu, it will initially be hidden, but we
         need it to be briefly visible in order to calculate
         clientHeight and clientWidth */
      this.style.display = 'block';

      /* Here, we try to figure out the best position for the
         menu where it will be less likely to escape the viewport. */
      const offset = this.parentOption.clientHeight;
      ty = isAbove ? -this.clientHeight : -offset;
      tx = isLeft ? -this.clientWidth : this.clientWidth;

      /* Undo the `display: block` property */
      this.style.display = null;
    } else {
      /* For the top-level menu, we set the given position
         coordinates to variables that are used to place
         the menu at a position on-screen. */
      this.style.setProperty('--pos-x', `${posX}px`);
      this.style.setProperty('--pos-y', `${posY}px`);
      
      /* Similar to the positioning above, but relative
         to the mouse coordinates rather than a parent menu */
      tx = isLeft ? -this.clientWidth : 0;
      ty = isAbove ? -this.clientHeight : 0;
    }

    /* Set a CSS variable used to populate the `transform` property */
    this.style.setProperty('--context-menu-translation', `matrix(1, 0, 0, 1, ${tx}, ${ty})`);
  }
  
// ...

}

A sprinkling of CSS will allow us to make our additional positioning a reality. All we need to do is use the transform property to move our menu in the right direction. Because child menus are also <context-menu> elements, they will also inherit this positioning.

We need to add the transform property to our <context-menu> CSS:

context-menu {
  /* ... */
  transform: var(--context-menu-translation);
  /* ... */
}

Child menus are positioned relative to their parent option, so we need parent options to pass their position to their child menus:

class ContextMenuOption extends HTMLElement {
  
// ...
  
  /* If there is a child menu, set its coordinates to the
     position of the option. */
  connectedCallback() {
    const childMenu = this.childMenu;
    
    if (!childMenu) {
      return;
    }
    
    const { x, y } = this.getBoundingClientRect();
    
    this.childMenu.setAttribute('pos-x', x);
    this.childMenu.setAttribute('pos-y', y);
  }

// ...

}

Now the menu and its child menus will do their best to not render outside of the viewport, depending on which quadrant they're invoked in.

This approach is extremely primitive and not fool-proof. It is possible to make a context menu that never escapes the viewport, but that gets complicated and falls outside the scope of this tutorial. The basic positioning approach we've implemented here will work fine in most cases.

Scroll Prevention

While a context menu is active, you don't want a situation where the user accidentally scrolls and the surrounding document moves. In our situation, the body element continues to be scrollable despite the fact that our <outside-click-area> is covering it. It's generally preferable that, when the rest of the document can't be interacted with, the scrolling be turned off. Unfortunately, you can't simply call e.preventDefault() or e.stopPropagation() on scroll events.

However, we can disable the scrolling by setting overflow: hidden; on the body element. This can be done by using the connectedCallback and disconnectedCallback methods in our <outside-click-area>:

class OutsideClickArea extends HTMLElement {

  // ...

  connectedCallback() {
    document.body.style.overflow = 'hidden';
  }
  
  disconnectedCallback() {
    document.body.style.overflow = null;
  }
}

It could instead be done in the context menu service, but I thought it made sense to implement in our custom element since there isn't a situation where we would want the body to scroll while the <outside-click-area> is present.

Button Invocation

A context menu doesn't just have to be opened on right-click. You might want a click on an element to open a menu.

This can be done easily by adding a button to our page and setting a click element on it.

<div id="app-container">
  <h2>Context Menu Demo</h2>
  <button id="demo-button">Click Me</button>
</div>

Our event listener will be a lot like the one set for context-menu events, but in this case we're going to pass the trigger element to the service.

document.getElementById('demo-button').addEventListener('click', e => {
  e.preventDefault();
  e.stopPropagation();

  /* Optionally, you can set the coordinates to the corner of the
     button so that the menu reveals itself there, as implemented below. */
  const button = e.target;

  const {
    x,
    y,
    width,
    height
  } = button.getBoundingClientRect();
  
  const posX = x + width;
  const posY = y + height;

  contextMenuService.open({
    posX,
    posY,
    triggerElement: button
  });
});
const contextMenuService = {

  // ...

  /* Notice how we are now taking a `triggerElement` property
     in our argument object */
  open({ posX, posY, triggerElement }) {
    /* We keep track of the element so we can put the focus
       back on it when the menu closes. */
    this.triggerElement = triggerElement;

  // ...

  },

  close() {
  
  // ...

    /* Add focus back to the trigger element */
    this.triggerElement.focus();
  }
};

The menu can now be opened with the button, and the focus will be returned to the button once the menu is closed.

[SCREENSHOT HERE]

We should also change our right-click listener so that focus gets returned to the currently-focused element.

document.getElementById('app-container').addEventListener('contextmenu', e => {
  e.preventDefault();
  e.stopPropagation();
  contextMenuService.open({
    posX: e.clientX,
    posY: e.clientY,
    triggerElement: document.activeElement
  });
});

Mobile Support and Handling Focus

This style of context menu works well on desktop but isn't so good on mobile screens. Media queries can help us make our context menu to be more usable on mobile.

Since we can't depend on the :hover pseudo class on mobile, we can insted use the :focus-within pseudo class, which will be present on an element if itself or any of its children have the browser's focus, which we will use when we open a child menu.

The use of this pseudo class will be useful later when we deal tab-navigation.

**NOTE:** At the time of this writing, the `:focus-within` selector is supported by approximately 83% of browsers. If you need to target older browsers, the `:focus-within` selector can be polyfilled.

We can first add :focus-within to the selector that we use to reveal child menus so that they are not only revealed on hover but when they have focus.

context-menu-option:hover > context-menu,
context-menu-option:focus-within > context-menu {
  display: block;
}

Let's wrap our mobile-only CSS in a media query. We will pretend that the mobile devices we are targeting have a screen-width of 640px or less:

/* ... */

@media (max-width: 640px) {

  /* Make the font larger and place the menu at
     the bottom-center of the viewport */
  context-menu {
    --margin: 25px;
    --both-margins: calc(var(--margin) * 2);
    bottom: 0;
    font-size: 1.3rem;
    left: 0;
    margin: var(--margin);
    top: initial;
    /* The --context-menu-transform-mobile variable will be used for us to
       slide our menu back and forth depending on which
       child menu should be displayed */
    transform: translateX(var(--context-menu-transform-mobile));
    /* Implements a sliding animation when the user
       navigates between child menus */
    transition: transform 0.5s ease-in-out;
    /* A little math to make our mobile menu
       span the viewport with a margin */
    width: calc(100vw - var(--both-margins));
  }

  /* Mobile-specific positioning for child menus */
  context-menu-option:focus-within > context-menu {
    margin-bottom: 0;
    /* This moves the child menu to the right of
       its parent, but it also takes into account
       the 1px of box-shadow that can poke out the
       sides of the viewport. */
    transform: translate(calc(100% + 1px), 0);
  }

}

Since the focus is going to be used for navigating throughout the menu, there needs to be some visual feedback so we can easily see which option is focused. We'll do that here with some CSS:

context-menu-option:focus,
context-menu-title:focus {
  background: lightblue;
}

The use of the background property is a personal choice here. When it comes to focus, it's common to rely on the outline property, which has the advantage of adding a border to an element without taking up any actual space.

[SCREENSHOT HERE]

That looks a lot better on mobile, but we still need a way to open child menus.

The entire menu needs to move to the left in order to reveal a child menu when the parent is clicked. We can program our context menu to respond to focus events so that it can bring itself into view.

class ContextMenu extends HTMLElement {

  constructor() {
  // ...
    
    /* If an option or title in the menu comes into focus
       then translate the menu into the viewport. */
    window.addEventListener('focusin', e => {
      for (const child of this.children) {
        if (child === e.target) {
          e.stopPropagation();
          this.reveal();
          return;
        }
      }
    });
  }

// ...

  /* Focus the first option, which will also fire the `focusin` event
     we use to bring the menu into view. */
  open() {
    for (const child of this.children) {
      if (child.tagName === 'CONTEXT-MENU-OPTION') {
        child.focus();
        return;
      }
    }
  }

  /**
   * Sets a CSS variable with the horizontal position of the child menu
   * so that it can be transitioned into view.
   **/
  reveal() {
    const { x: mainX } = contextMenuService.currentMenu.getBoundingClientRect();
    const { x: childX } = this.getBoundingClientRect();
    contextMenuService.currentMenu.style.setProperty('--context-menu-transform-mobile', `-${childX - mainX}px`);
  }

  /**
   * Closes the menu by sliding the context menu back to the
   * parent menu.  If we are back to the main menu, then just
   * close the entire menu.
   **/
  close() {
    /* If this is the top-level menu, just close the whole thing. */
    const parentOption = this.parentOption;
    if (!parentOption) {
      contextMenuService.close();
      return;
    }
    /* Return focus to the parent option. */
    parentOption.focus();
  }

}

For this to work smoothly, our context menu service should place focus on the menu as soon as it opens:

const contextMenuService = {

// ...

  open({ posX, posY, triggerElement }) {
  
  // ...

    menu.open(); /* Call the `open` method on the menu to focus it. */
  },
  
// ...
}

Some changes to our <context-menu-option> element will allow parent options to open their child menus on click or tap.

class ContextMenuOption extends HTMLElement {

  constructor() {
    super();

    this.setAttribute('tabindex', '0'); /* makes this element tabbable */

    this.addEventListener('click', e => {
      e.stopPropagation();
      
      /**
       * If we know what this is a parent option then
       * open the child menu.
       **/
      if (this.childMenu) {
        this.childMenu.open();
      } else {
        this.onSelect();
      }
    });
  }
  
// ...

}

Of course, there needs to be a way to dismiss a child menu and go back to the parent menu. We can use our <context-menu-title> as a way to navigate back to the parent menu:

class ContextMenuTitle extends HTMLElement {

  constructor() {
    super();

    this.setAttribute('tabindex', '0'); /* makes this element tabbable */

    /* Listen to click events */
    this.addEventListener('click', e => {
      e.stopPropagation();
      this.onSelect();
    });
  }
  
  /* Returns the menu that the title belongs to. */
  get menu() {
    const parent = this.parentElement;
    if (parent.tagName === 'CONTEXT-MENU') {
      return parent;
    }
  }

  onSelect() {
    /* Close the menu. */
    this.menu.close();
  }

}

window.customElements.define('context-menu-title', ContextMenuTitle);

Our context menu now works great both on desktop and mobile!

[SCREENSHOT HERE]

Accessibility

And you thought we were done!

Too often, we think of HTML as a medium to render a soup of visuals. Elements tend to be used like dumb parts with no meaning on their own. This might not matter that much to those of us with good vision, but what about those who are visually-impaired?

Screen readers are software used by the visually-impaired to read aloud web pages, allowing them to interact with the web and participate in the global community. A screen reader essentially has to interpret the meaning of a web page and convert it to audio; unfortunately, most web pages are a soup of visuals that lack useful meaning. Because of this, screen readers regularly face difficulties and it's common for web pages to be simply unusable with them.

It's important that we work to change the state of accessibility on the web so that more people can effectively use it.

Firefox has an accessibility inspector that can give us an idea of how our context menu is interpreted by screen readers:

[SCREENSHOT HERE]

Yikes! It just looks like a bunch of meaningless text leaves. A screen reader will have no idea what this thing even is.

Why don't we make some simple changes to make our context menu useful to everyone?

Adding Meaning to Our Elements

Any HTML element can have a role attribute that allows screen readers to know what it represents. Let's use the role attribute to tell screen readers that our <context-menu> element is a menu.

To make this easy, let's add the attribute in our code:

class ContextMenu extends HTMLElement {
  
  constructor() {
  // ...
    
    this.setAttribute('role', 'menu');

  // ...
  }

// ...

}

This makes is so that when a <context-menu> element is inserted into the DOM, it will automatically add the role attribute:

<context-menu role="menu">

The attribute could be hard-coded into templates that use the tag, but since we're always going to want that role we might as well always have it added.

Similarly, we want to specify that our <context-menu-option> is a form of menu item:

class ContextMenuOption extends HTMLElement {

  constructor() {
  // ...

    this.setAttribute('tabindex', '0');
    this.setAttribute('role', 'menuitem');

  // ...
  }

// ...

}

As with the <context-menu-title>:

class ContextMenuTitle extends HTMLElement {

  constructor() {
  // ...

    this.setAttribute('tabindex', '0');
    this.setAttribute('role', 'menuitem');

  // ...
  }
  
// ...

}

[SCREENSHOT HERE]

Now our context menu and its options show up as menupopup and menuitem to screen readers. This is already a big improvement!

The <context-menu-title> elements are still showing up as generic text containers. Since the user still needs them for navigating between menu levels, one way we can approach this is to set the title as an aria-label on the <context-menu> element and set the aria-label on the <context-menu-title> to say "Dismiss".

class ContextMenuTitle extends HTMLElement {

  constructor() {
  // ...

    this.setAttribute('tabindex', '0');
    this.setAttribute('role', 'menuitem');
    this.setAttribute('aria-label', 'Dismiss');

    // ...
  }
  
// ...

}

Back in our <context-menu> code, we can grab the title text and use that as the aria-label:

class ContextMenu extends HTMLElement {
  
// ...

  connectedCallback() {
    /**
     * Find the title element and use its text content as the
     * aria-label.  If there is no title, just default to 
     * using 'Menu' as the label.
     **/
    const title = this.querySelector('* > context-menu-title');
    
    if (title) {
      this.setAttribute('aria-label', title.textContent.trim());
    } else {
      this.setAttribute('aria-label', 'Menu');
    }

  // ...

  }

// ...

}

Let's check out how this looks in the accessibility inspector:

[SCREENSHOT HERE]

Before adding roles and labels, we had structure without meaning. Now, the context menu is now very easy for software to interpret.

**NOTE**: Remember the above section on button invocation? For elements that open a context menu, it's a good idea to set the `aria-haspopup` attribute. In the example where we add a button to open the context menu, we can simply do this:
<div id="app-container">
  <h2>Context Menu Demo</h2>
  <button id="demo-button" aria-haspopup="true">Click Me</button>
</div>

Hiding Distractions

While the context menu is open and in focus, we've chosen to effectively disable the surrounding page content with our <outside-click-area>. Thus, it's not particularly useful for a screen reader to be interpreting this content. We should signal to screen readers to ignore the disabled content so that it doesn't attempt to read it when the context menu is open.

Let's dive back into the code for our <outside-click-area> and make it add an aria-hidden attribute to our app container element so that it is hidden from screen readers:

class OutsideClickArea extends HTMLElement {
  // ...

  connectedCallback() {
    document.body.style.overflow = 'hidden';
    /* Hide main app content from screen readers while this element is present */
    document.getElementById('app-container').setAttribute('aria-hidden', true);
  }
  
  disconnectedCallback() {
    document.body.style.overflow = null;
    /* Show main app content once this element has been removed and menu is closed */
    document.getElementById('app-container').setAttribute('aria-hidden', false);
  }

}

// ...

Back in the accessibility inspector, you can see that the rest of the page is hidden, leaving only the context menu elements as readable.

Keyboard Navigation

By virtue of setting a tabindex attribute on our elements and relying on focus to reveal children, our menus are already navigable with the keyboard through the tab-key. But we can go further in making it practical to use a keyboard with our context menu.

The first step we can take is it make it so options can be selected using either the spacebar or enter key:

const ENTER_KEY = 13;
const SPACEBAR_KEY = 32;

// ...

class ContextMenuOption extends HTMLElement {

  constructor() {
  // ...

    this.addEventListener('keyup', e => {
      e.preventDefault();
      
      if (e.which === ENTER_KEY || e.which === SPACEBAR_KEY) {
        e.stopPropagation(); /* prevent the event from bubbling */
        this.onSelect();
      }
    });
  }

// ...

}

Similarly, for the <context-menu-title>:

// ...

class ContextMenuTitle extends HTMLElement {

  constructor() {
  // ...

    this.addEventListener('keyup', e => {
      e.preventDefault();
      
      if (e.which === ENTER_KEY || e.which === SPACEBAR_KEY) {
        e.stopPropagation();
        this.onSelect();
      }
    });
  }

// ...

}

The context menu can be interacted with using not just clicks but now the spacebar and enter keys.

What if we could enable the arrow keys for more flexible navigation than what the tab key can provide?

const LEFT_KEY = 37;
const UP_KEY = 38;
const RIGHT_KEY = 39;
const DOWN_KEY = 40;

// ...

class ContextMenuOption extends HTMLElement {

  constructor() {
  // ...

    this.addEventListener('keyup', e => {
    // ...

      if (e.which === UP_KEY && this.previousElementChild) {
        e.stopPropagation();
        this.previousElementChild.focus();
        return;
      }

      if (e.which === DOWN_KEY && this.nextElementSibling) {
        e.stopPropagation();
        this.nextElementSibling.focus();
        return;
      }

      /* We have to account for the fact that child menus may appear
         on either side of their parent. */
      const isOpenChildKey = e.which === (this.menu.isLeft ? LEFT_KEY : RIGHT_KEY);
      if (isOpenChildKey && this.childMenu) {
        e.stopPropagation();
        this.childMenu.open();
        return;
      }

      /* Handles using an arrow key to focus back on the parent menu option */
      const isSelectParentKey = e.which === (this.menu.isLeft ? RIGHT_KEY : LEFT_KEY); 
      if (isSelectParentKey && this.menu.parentOption) {
        e.stopPropagation();
        this.menu.parentOption.focus();
      }
    });
  }

// ...

}

Similar behavior needs to be implemented for our <context-menu-title>:

// ...

class ContextMenuTitle extends HTMLElement {

  constructor() {
  // ...

    this.addEventListener('keyup', e => {
    // ...

      /* Focus next sibling on down key */
      if (e.which === DOWN_KEY && this.nextElementSibling) {
        e.stopPropagation();
        this.nextElementSibling.focus();
        return;
      }

      /* Since a title can't have a child menu, we only care about navigating
         back to the parent menu. */
      const isSelectParentKey = e.which === (this.menu.isLeft ? RIGHT_KEY : LEFT_KEY); 
      if (isSelectParentKey && this.menu.parentOption) {
        e.stopPropagation();
        this.menu.parentOption.focus();
      }
    });
  }

// ...

}

We now have a context menu that is flexible, based on modern web technology, and is highly accessible! Although this blog post might make it seem like we've been dealing with a lot of code, the code in its final form is relatively short.

Check out the full demo here.

Final Thoughts

This isn't the only way to build an HTML context menu. You can choose not to use custom elements at all, but instead use a library like React or Ember. It's even possible to go further and add icons or smarter positioning.

What this blog post shows is that, while context menus are simple to understand, there's more nuance involved than first meets the eye.

Nevertheless, it's a timeless UI pattern that is simple to implement without requiring any fancy JavaScript libraries.

Even if you don't choose to build your own context menu in the future, I hope you take the ideas talked about in this blog post and apply them to your own projects. The concept of accessibility, for instance, can be applied nearly everywhere and is a valuable skill to add to your repetoire.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment