// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
// Context menu: right-click, Shift+F10 or the Menu key on a [data-context] element opens its menu at the pointer.
document.addEventListener('contextmenu', e => {
const t = e.target.closest('[data-context]');
if (!t) return;
e.preventDefault();
const m = menuOf(t), box = m.parentElement.getBoundingClientRect(), r = t.getBoundingClientRect();
const x = e.clientX || r.left + 24, y = e.clientY || r.top + 24;
open(t);
m.style.left = Math.max(8, Math.min(x - box.left, box.width - m.offsetWidth - 8)) + 'px';
m.style.top = Math.max(8, Math.min(y - box.top, box.height - m.offsetHeight - 8)) + 'px';
itemsOf(m)[0]?.focus();
});
})();
// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
// Context menu: right-click, Shift+F10 or the Menu key on a [data-context] element opens its menu at the pointer.
document.addEventListener('contextmenu', e => {
const t = e.target.closest('[data-context]');
if (!t) return;
e.preventDefault();
const m = menuOf(t), box = m.parentElement.getBoundingClientRect(), r = t.getBoundingClientRect();
const x = e.clientX || r.left + 24, y = e.clientY || r.top + 24;
open(t);
m.style.left = Math.max(8, Math.min(x - box.left, box.width - m.offsetWidth - 8)) + 'px';
m.style.top = Math.max(8, Math.min(y - box.top, box.height - m.offsetHeight - 8)) + 'px';
itemsOf(m)[0]?.focus();
});
})();
About this set
Menus that belong to a single item on the page. Overflow menus on list rows is a team members list where each row ends in a more button that opens actions for that person: View profile, Change role, Copy email and Remove from team, plus Resend invite for the pending invite. For the owner, Remove from team is disabled and says Owner. Right-click context menu is a photo board: right-click a photo, or focus it and press Shift+F10 or the Menu key, and a menu opens at the pointer with Open, Rename, Duplicate, a Favourite checkbox item, Set as album cover and Move to trash. The context menu is placed inside the board and nudged back from its edges, and choosing an item or pressing Esc returns focus to the photo. Each more button has a name that includes the person, so a list of identical icons is still distinguishable by ear. The Bootstrap version uses the Dropdown plugin for the rows and Bootstrap’s dropdown styles with a short script for the context menu, since the plugin needs a toggle button.
What’s included
More buttons named for their row, such as Actions for Tomás Rivera
Disabled item that shows why it is unavailable
Context menu at the pointer on right-click, Shift+F10 or Menu key
Checkbox item that stays open when toggled
Focus returns to the photo when the menu closes
Accessibility
Row buttons carry aria-label, aria-haspopup, aria-expanded and aria-controls, and each menu is labelled with the person it acts on. Photos are links described by a visible hint explaining how to open the context menu from the keyboard, and they expose aria-expanded while it is open. Favourite is a menuitemcheckbox with aria-checked. Focus outlines are visible throughout and animations stop under prefers-reduced-motion.
Customise it
Colours are the --cx-* custom properties on .cx-wrap. The photo artwork is the .a1, .a2 and .a3 gradients; replace them with images. In Bootstrap the menus also use --bs-dropdown-* on .cx-menu; in Tailwind edit the hex values in the classes.