So I made… context menu library for web apps.

Pawel Uchida-Psztyc
5 min readMay 25, 2021

Recently I needed to add a context menu to my web application. I was surprised how few libraries are out there. Even with these libraries, I couldn’t get the kind of UI and functionality I needed. So I made one myself.

Multi-level content menu

If you think building a context menu is easy, you probably never tried to do it yourself. Let's start defining what a context menu is. A context menu is a dialog that appears after triggering a secondary mouse click or a long press on the pointer device (pen, finger on a touch screen). Because the context menu registers global event listeners (keyboard and mouse listeners on the menu and the document), it would be inefficient to create multiple instances of a context menu for multiple items in the UI. As a result, the menu needs to recognize the “target” of the click and match commands to the current context (hence the context menu).

My ideal context menu has to:
- offer customization of the general styling of the menu (text colors, backgrounds, icons)
- support multi-level navigation structure (a sub-menu of the menu)
- be accessible (a11y compliance)
- dynamically determine whether the command should be visible in the current context (when the menu is being built)
- dynamically set the “disabled” state of a menu item
- execute the callback function when the menu entry is activated on both the item or on the parent menu item (when sub-menu is activated)
- be able to dynamically update the label of the menu entry when the menu is rendered
- trigger the menu programmatically without having a reference to the context menu instance
- position the menu and the sub-menus on the screen relative to the click position and the viewport
- be able to render a custom icon next to the command label
- be able to render the “selected” or “checked” state of the command
- separate group of commands visually (via dividers and group labels)
- execute the menu command from a keyboard shortcut.

I built a library that supports all of the above except for the last point — the keyboard shortcuts (also known as accelerators). This is a much more complex topic, so that it will stay in the roadmap planning for now.

Using the context menu

The context menu is an ES module, so you have to import it as such. The menu UI uses a self-contained web component. To start, you first need to install the library as a dependency of your project.

npm i -S @api-client/context-menu

Then you import and initialize the menu with the workspace, which is the Element that the library is listening to the events onto.

import { ContextMenu } from '@api-client/context-menu';const menu = new ContextMenu(document.body);
menu.connect();

The workspace can be any HTML element. This way, you can limit the interaction area just to a specific region. The connect() function tells the library to start listening to the mouse events.

The next step is to define and register commands. Each command is a definition of a menu entry. The minimal example would be:

const commands = [{
target: 'all',
label: 'My command',
}];
menu.registerCommands(commands);

On the top level, each command requires two properties: label and target . The label is the text to be rendered in the menu entry. The target is used to match the command with the current click target. In the example above, I used the all keyword, which means the menu entry is rendered for all click targets. There is also the root target which refers to the passed workspace element. By default, the target is generated from the click target element’s local name concatenated with a dot with the list of class names joined with a dot. So the element <article class="main active"></section> is translated into article.main.active target. If you register a command with this target value and the user clicks on the article, this menu entry will build the sub-menu.

After the user selects a menu item, the context menu will call the execute function defined on the item definition. If the function is missing, it will try to execute the first parent execute function. If none is found, then a custom event is dispatched from the workspace Element.

cnst commands = [{
target: 'all',
label: 'Executable item',
execute: (ctx) => {
// ...
},
}];

The menu passes the execution context to the execute function with references to the workspace, clicked element, the click position, and the internal store (a Map) that you can use to store any data between menu items lifecycle functions.

To add children to the command, add the children property in the command. This should be a list of command definitions, as previously. Children don’t need to define the target as it is not used to filter commands.

const commands = [{
target: 'all',
label: 'My command',
children: [
{
label: "I am a child",
execute: (ctx) => {
// ...
},
}
]
}];

I could define the execute function in the parent item definition, and the menu would call it instead. An additional itemproperty is then passed to the context object. This property is the definition of the child item that has been activated. You can combine this with manually set the id property to determine which item was clicked.

const commands = [{
target: 'div.editor',
label: 'Font size',
execute: (ctx) => {
console.log('Selected font size:', ctx.item.id);
},
children: [
{
label: 'Small',
id: '0.75rem',
},
{
label: 'Normal',
id: '1rem',
},
{
label: 'Large',
id: '1.25rem',
},
],
}];

Another handy option is the ability to make the item visible depending on some context. When defining a menu item, you can define the visible property with a boolean value or a function. If the passed value is a function, it is executed when the menu item is built. When the function returns true , then the menu item is visible. This allows to dynamically manipulate the visibility of a menu item depending on the current situation on the workspace Element. Similarly, you can use visible property/function to render the menu item as “disabled”. This may be used in a situation of the copy/cut/paste menu items.

const commands = [
{
target: 'all',
label: 'Copy',
execute: (ctx) => {
// assuming the target has the "data-id" attribute
const { id } = ctx.target.dataset;
ctx.store.set('copy', id);
}
},
{
target: 'all',
label: 'Cut',
execute: (ctx) => {
const { id } = ctx.target.dataset;
ctx.store.set('cut', id);
}
},
{
target: 'all',
label: 'Paste',
execute: (ctx) => {
if (ctx.store.has('copy')) {
const copyId = ctx.store.get('copy');
ctx.store.delete('copy');
// copy the item
} else if (ctx.store.has('paste')) {
const pasteId = ctx.store.get('paste');
ctx.store.delete('paste');
// cut and paste the item
}
},
enabled: (ctx) => ctx.store.has('copy') || ctx.store.has('cut'),
},
];

The full list of supported options you will find in the project’s readme file at: https://github.com/api-client/context-menu#readme

Licensing

The context menu library is licensed under the Creative Commons — By Attribution license (CC-BY), which means you can use it as you pleased with or without modifications, in OSS or commercial projects, as long as you give the reasonable attribution to the author, like mentioning me in the “credits” section.

--

--

Pawel Uchida-Psztyc

Design, APIs, front-end, strategy, product, and educator. I work for big tech but I share my personal views and opinions.