Skip to content

Instantly share code, notes, and snippets.

@Dustin4444
Created August 22, 2025 17:41
Show Gist options
  • Save Dustin4444/4091cc9b3e088903fb5e9c77af6d65e1 to your computer and use it in GitHub Desktop.
Save Dustin4444/4091cc9b3e088903fb5e9c77af6d65e1 to your computer and use it in GitHub Desktop.
pen
<div class="menu-button-actions">
<button type="button" id="menubutton1" aria-haspopup="true" aria-expanded="false" aria-controls="menu1">
Actions
<svg xmlns="http://www.w3.org/2000/svg" class="down" width="12" height="9" viewBox="0 0 12 9">
<polygon points="1 0, 11 0, 6 8"></polygon>
</svg>
</button>
<ul id="menu1" role="menu" tabindex="-1" aria-labelledby="menubutton1" aria-activedescendant="mi1">
<li id="mi1" role="menuitem">Action 1</li>
<li id="mi2" role="menuitem">Action 2</li>
<li id="mi3" role="menuitem">Action 3</li>
<li id="mi4" role="menuitem">Action 4</li>
</ul>
</div>
<p>
<label>Last Action: <input class="action" id="action_output" type="text" value=""></label>
</p>
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*
* File: menu-button-actives-active-descendant.js
*
* Desc: Creates a menu button that opens a menu of actions using aria-activedescendants
*/
"use strict";
class MenuButtonActionsActiveDescendant {
constructor(domNode, performMenuAction) {
this.domNode = domNode;
this.performMenuAction = performMenuAction;
this.buttonNode = domNode.querySelector("button");
this.menuNode = domNode.querySelector('[role="menu"]');
this.currentMenuitem = {};
this.menuitemNodes = [];
this.firstMenuitem = false;
this.lastMenuitem = false;
this.firstChars = [];
this.buttonNode.addEventListener(
"keydown",
this.onButtonKeydown.bind(this)
);
this.buttonNode.addEventListener("click", this.onButtonClick.bind(this));
this.menuNode.addEventListener("keydown", this.onMenuKeydown.bind(this));
var nodes = domNode.querySelectorAll('[role="menuitem"]');
for (var i = 0; i < nodes.length; i++) {
var menuitem = nodes[i];
this.menuitemNodes.push(menuitem);
menuitem.tabIndex = -1;
this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase());
menuitem.addEventListener("click", this.onMenuitemClick.bind(this));
menuitem.addEventListener(
"mouseover",
this.onMenuitemMouseover.bind(this)
);
if (!this.firstMenuitem) {
this.firstMenuitem = menuitem;
}
this.lastMenuitem = menuitem;
}
domNode.addEventListener("focusin", this.onFocusin.bind(this));
domNode.addEventListener("focusout", this.onFocusout.bind(this));
window.addEventListener(
"mousedown",
this.onBackgroundMousedown.bind(this),
true
);
}
setFocusToMenuitem(newMenuitem) {
for (var i = 0; i < this.menuitemNodes.length; i++) {
var menuitem = this.menuitemNodes[i];
if (menuitem === newMenuitem) {
this.currentMenuitem = newMenuitem;
menuitem.classList.add("focus");
this.menuNode.setAttribute("aria-activedescendant", newMenuitem.id);
} else {
menuitem.classList.remove("focus");
}
}
}
setFocusToFirstMenuitem() {
this.setFocusToMenuitem(this.firstMenuitem);
}
setFocusToLastMenuitem() {
this.setFocusToMenuitem(this.lastMenuitem);
}
setFocusToPreviousMenuitem() {
var newMenuitem, index;
if (this.currentMenuitem === this.firstMenuitem) {
newMenuitem = this.lastMenuitem;
} else {
index = this.menuitemNodes.indexOf(this.currentMenuitem);
newMenuitem = this.menuitemNodes[index - 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusToNextMenuitem() {
var newMenuitem, index;
if (this.currentMenuitem === this.lastMenuitem) {
newMenuitem = this.firstMenuitem;
} else {
index = this.menuitemNodes.indexOf(this.currentMenuitem);
newMenuitem = this.menuitemNodes[index + 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusByFirstCharacter(char) {
var start, index;
if (char.length > 1) {
return;
}
char = char.toLowerCase();
// Get start index for search based on position of currentItem
start = this.menuitemNodes.indexOf(this.currentMenuitem) + 1;
if (start >= this.menuitemNodes.length) {
start = 0;
}
// Check remaining slots in the menu
index = this.firstChars.indexOf(char, start);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = this.firstChars.indexOf(char, 0);
}
// If match was found...
if (index > -1) {
this.setFocusToMenuitem(this.menuitemNodes[index]);
}
}
// Utilities
getIndexFirstChars(startIndex, char) {
for (var i = startIndex; i < this.firstChars.length; i++) {
if (char === this.firstChars[i]) {
return i;
}
}
return -1;
}
// Popup menu methods
openPopup() {
this.menuNode.style.display = "block";
this.buttonNode.setAttribute("aria-expanded", "true");
this.menuNode.focus();
this.setFocusToFirstMenuitem();
}
closePopup() {
if (this.isOpen()) {
this.buttonNode.setAttribute("aria-expanded", "false");
this.menuNode.setAttribute("aria-activedescendant", "");
for (let i = 0; i < this.menuitemNodes.length; i++) {
this.menuitemNodes[i].classList.remove("focus");
}
this.menuNode.style.display = "none";
this.buttonNode.focus();
}
}
isOpen() {
return this.buttonNode.getAttribute("aria-expanded") === "true";
}
// Menu event handlers
onFocusin() {
this.domNode.classList.add("focus");
}
onFocusout() {
this.domNode.classList.remove("focus");
}
onButtonKeydown(event) {
var key = event.key,
flag = false;
switch (key) {
case " ":
case "Enter":
case "ArrowDown":
case "Down":
this.openPopup();
this.setFocusToFirstMenuitem();
flag = true;
break;
case "Esc":
case "Escape":
this.closePopup();
flag = true;
break;
case "Up":
case "ArrowUp":
this.openPopup();
this.setFocusToLastMenuitem();
flag = true;
break;
default:
break;
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onButtonClick(event) {
if (this.isOpen()) {
this.closePopup();
} else {
this.openPopup();
}
event.stopPropagation();
event.preventDefault();
}
onMenuKeydown(event) {
var key = event.key,
flag = false;
function isPrintableCharacter(str) {
return str.length === 1 && str.match(/\S/);
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (event.shiftKey) {
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(key);
flag = true;
}
if (event.key === "Tab") {
this.closePopup();
flag = true;
}
} else {
switch (key) {
case " ":
case "Enter":
this.closePopup();
this.performMenuAction(this.currentMenuitem);
flag = true;
break;
case "Esc":
case "Escape":
this.closePopup();
flag = true;
break;
case "Up":
case "ArrowUp":
this.setFocusToPreviousMenuitem();
flag = true;
break;
case "ArrowDown":
case "Down":
this.setFocusToNextMenuitem();
flag = true;
break;
case "Home":
case "PageUp":
this.setFocusToFirstMenuitem();
flag = true;
break;
case "End":
case "PageDown":
this.setFocusToLastMenuitem();
flag = true;
break;
case "Tab":
this.closePopup();
break;
default:
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(key);
flag = true;
}
break;
}
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onMenuitemMouseover(event) {
var tgt = event.currentTarget;
this.setFocusToMenuitem(tgt);
}
onMenuitemClick(event) {
var tgt = event.currentTarget;
this.closePopup();
this.performMenuAction(tgt);
}
onBackgroundMousedown(event) {
if (!this.domNode.contains(event.target)) {
if (this.isOpen()) {
this.closePopup();
}
}
}
}
// Initialize menu buttons
window.addEventListener("load", function () {
document.getElementById("action_output").value = "none";
function performMenuAction(node) {
document.getElementById("action_output").value = node.textContent.trim();
}
var menuButtons = document.querySelectorAll(".menu-button-actions");
for (var i = 0; i < menuButtons.length; i++) {
new MenuButtonActionsActiveDescendant(menuButtons[i], performMenuAction);
}
});
<script src="https://www.w3.orgcontent/shared/js/utils.js"></script>
.menu-button-actions {
margin: 0;
padding: 0;
}
.menu-button-actions button {
margin: 0;
padding: 6px;
display: inline-block;
position: relative;
background-color: #034575;
border: 1px solid #034575;
font-size: 0.9em;
color: white;
border-radius: 5px;
}
.menu-button-actions [role="menu"] {
display: none;
position: absolute;
margin: 0;
padding: 7px 4px;
border: 2px solid #034575;
border-radius: 5px;
background-color: #eee;
}
.menu-button-actions [role="menuitem"],
.menu-button-actions [role="separator"] {
margin: 0;
padding: 6px;
display: block;
width: 4em;
background-color: #eee;
color: black;
border-radius: 5px;
}
.menu-button-actions [role="separator"] {
padding-top: 3px;
background-image: url("../images/separator.svg");
background-position: center;
background-repeat: repeat-x;
}
.menu-button-actions button svg.down {
padding-left: 0.125em;
fill: currentcolor;
stroke: currentcolor;
}
.menu-button-actions button[aria-expanded="true"] svg.down {
transform: rotate(180deg);
}
/* focus styling */
.menu-button-actions button:hover,
.menu-button-actions button:focus,
.menu-button-actions button[aria-expanded="true"] {
padding: 4px;
border: 3px solid #034575;
background: #eee;
color: #222;
outline: none;
margin: 0;
}
.menu-button-actions [role="menuitem"].focus,
.menu-button-actions [role="menuitem"]:focus {
padding: 4px;
border: 2px solid #034575;
background: #034575;
color: #fff;
outline: none;
margin: 0;
}
input.action:focus {
outline: 2px solid #034575;
background: #def;
}
<link href="https://www.w3.org/content/shared/css/core.css" rel="stylesheet" />
<link href="https://www.w3.org/StyleSheets/TR/2016/base.css" rel="stylesheet" />
<link href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment