// 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));
// Copy the choice onto the element that shows it, e.g. the split button's label.
document.querySelectorAll(`[data-choice="${set.closest('[role="menu"]').id}"]`).forEach(el => {
el.textContent = item.dataset.choiceText;
if (item.dataset.value) el.dataset.value = item.dataset.value;
});
}
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();
});
})();
// Radio items in a Bootstrap dropdown: Bootstrap closes the menu on click; this keeps aria-checked in step
// and copies the choice onto the element that shows it (data-choice="<menu id>").
document.addEventListener('click', e => {
const item = e.target.closest('.dropdown-menu [role="menuitemradio"]');
if (!item) return;
const menu = item.closest('.dropdown-menu');
menu.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
document.querySelectorAll(`[data-choice="${menu.id}"]`).forEach(el => {
el.textContent = item.dataset.choiceText;
if (item.dataset.value) el.dataset.value = item.dataset.value;
});
});
// 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));
// Copy the choice onto the element that shows it, e.g. the split button's label.
document.querySelectorAll(`[data-choice="${set.closest('[role="menu"]').id}"]`).forEach(el => {
el.textContent = item.dataset.choiceText;
if (item.dataset.value) el.dataset.value = item.dataset.value;
});
}
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();
});
})();
About this set
Three dropdowns for dark interfaces. Command menu with shortcuts is a Deploy button with a gradient border and glow, whose menu starts with the branch and commit, then Deploy to preview, Promote to production and Redeploy with shortcut keys, build log and URL actions, and an amber Roll back item. Status menu with radio items is a chat profile button showing the current status; its Set status group offers Online, Away, Do not disturb until 17:00 and Invisible, and the choice updates the button’s label and dot. Glass menu over artwork is a more menu on an album card with a translucent, blurred menu for queue, album, artist, share and save. Muted text and shortcut keys are tuned to reach 4.5:1 on near-black, and the focus outline uses a light indigo that stays visible on dark and glass surfaces. Status choices are menuitemradio items whose colour dots are backed by their text. The Bootstrap version sets data-bs-theme=”dark” on each dropdown and tunes Bootstrap’s dropdown variables.
What’s included
Gradient-border trigger and shortcut keys as keycaps
Radio status items that update the trigger label and dot
Translucent glass menu with backdrop blur over artwork
Muted text at 4.5:1 or better on near-black
Bootstrap data-bs-theme="dark" dropdowns
Accessibility
Triggers carry aria-haspopup, aria-expanded and aria-controls, and the icon-only album button is named. Status options are menuitemradio items with aria-checked, and the status dots are decorative next to the written status. Shortcuts use aria-keyshortcuts. Focus outlines are light indigo for contrast on dark backgrounds, keyboard handling follows the WAI-ARIA menu button pattern, and animations stop under prefers-reduced-motion.
Customise it
The dark palette is the --dk-* custom properties on .dk-wrap (ink, muted, line, background, hover, focus, warning). Change the glass strength on .dk-menu.glass. In Bootstrap the same properties feed --bs-dropdown-*; in Tailwind edit the hex values in the classes.