Merge pull request #597 from PR0M3TH3AN/codex/extend-popoverengine-accessibility-features

Improve PopoverEngine menu accessibility and keyboard navigation
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-16 13:24:26 -04:00
commit d9de74a446
2 files changed
+653

No files matched your search

+407
View File
@@ -9,9 +9,48 @@ import { ensureOverlayRoot } from "./overlayRoot.js";
const DEFAULT_PLACEMENT = "bottom-start";
const DEFAULT_STRATEGY = "fixed";
const DEFAULT_MAX_WIDTH_TOKEN = "--popover-inline-safe-max";
const MENU_TYPEAHEAD_RESET_MS = 500;
let popoverPanelIdCounter = 0;
let activePopoverInstance = null;
function generatePanelId() {
popoverPanelIdCounter += 1;
return `popover-panel-${popoverPanelIdCounter}`;
}
function isDisabled(element) {
if (!element) {
return true;
}
if (element.hasAttribute("disabled")) {
return true;
}
const ariaDisabled = element.getAttribute("aria-disabled");
if (typeof ariaDisabled === "string" && ariaDisabled.toLowerCase() === "true") {
return true;
}
if (element.hidden || element.getAttribute("aria-hidden") === "true") {
return true;
}
return false;
}
function getItemLabel(element) {
if (!element) {
return "";
}
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel) {
return ariaLabel.trim();
}
if (typeof element.textContent === "string") {
return element.textContent.trim();
}
return "";
}
function isElement(node) {
return Boolean(node && typeof node === "object" && node.nodeType === 1);
}
@@ -225,6 +264,11 @@ export function createPopover(trigger, render, options = {}) {
const anchor = isElement(trigger) ? trigger : null;
const documentRef = resolveDocument(anchor, options.document);
if (anchor && typeof anchor.setAttribute === "function") {
anchor.setAttribute("aria-haspopup", "menu");
anchor.setAttribute("aria-expanded", "false");
}
const portal = documentRef ? documentRef.createElement("div") : null;
if (portal) {
portal.dataset.popoverPortal = "true";
@@ -258,6 +302,344 @@ export function createPopover(trigger, render, options = {}) {
let autoUpdateCleanup = null;
let isOpen = false;
let previousActiveElement = null;
let menuItemIdCounter = 0;
const menuHandlers = {
keydown: null,
focusin: null,
};
const menuState = {
panel: null,
items: [],
activeIndex: -1,
typeaheadBuffer: "",
typeaheadTimeout: null,
};
function getView() {
return documentRef?.defaultView || globalThis;
}
function resetTypeaheadBuffer() {
const view = getView();
if (menuState.typeaheadTimeout) {
view.clearTimeout(menuState.typeaheadTimeout);
}
menuState.typeaheadTimeout = null;
menuState.typeaheadBuffer = "";
}
function scheduleTypeaheadReset() {
const view = getView();
if (!view || typeof view.setTimeout !== "function") {
return;
}
if (menuState.typeaheadTimeout) {
view.clearTimeout(menuState.typeaheadTimeout);
}
menuState.typeaheadTimeout = view.setTimeout(() => {
menuState.typeaheadTimeout = null;
menuState.typeaheadBuffer = "";
}, MENU_TYPEAHEAD_RESET_MS);
}
function ensurePanelId(panelElement) {
if (!panelElement.id) {
panelElement.id = generatePanelId();
}
return panelElement.id;
}
function findMenuItems(panelElement) {
if (!panelElement) {
return [];
}
const candidates = Array.from(
panelElement.querySelectorAll("[role=\"menuitem\"], .menu__item"),
).filter((node) => isElement(node));
candidates.forEach((item) => {
if (!item.hasAttribute("role")) {
item.setAttribute("role", "menuitem");
}
if (!item.id) {
menuItemIdCounter += 1;
item.id = `${ensurePanelId(panelElement)}-item-${menuItemIdCounter}`;
}
item.setAttribute("tabindex", "-1");
});
return candidates;
}
function updateAriaActiveDescendant(panelElement, activeItem) {
if (!panelElement) {
return;
}
if (activeItem && activeItem.id) {
panelElement.setAttribute("aria-activedescendant", activeItem.id);
} else {
panelElement.removeAttribute("aria-activedescendant");
}
}
function setActiveItem(index, { focus = true } = {}) {
const panelElement = menuState.panel;
if (!panelElement || !menuState.items.length) {
menuState.activeIndex = -1;
updateAriaActiveDescendant(panelElement, null);
return;
}
if (index < 0 || index >= menuState.items.length) {
menuState.activeIndex = -1;
menuState.items.forEach((item) => {
item.setAttribute("tabindex", "-1");
});
updateAriaActiveDescendant(panelElement, null);
return;
}
const target = menuState.items[index];
if (!target || isDisabled(target)) {
return;
}
menuState.items.forEach((item, itemIndex) => {
const value = itemIndex === index ? "0" : "-1";
item.setAttribute("tabindex", value);
});
menuState.activeIndex = index;
updateAriaActiveDescendant(panelElement, target);
if (focus) {
try {
target.focus({ preventScroll: true });
} catch (error) {
target.focus();
}
}
}
function focusFirstEnabledItem({ focusPanelFallback = true } = {}) {
const panelElement = menuState.panel;
if (!panelElement) {
return;
}
const items = menuState.items;
const index = items.findIndex((item) => !isDisabled(item));
if (index >= 0) {
setActiveItem(index);
return;
}
menuState.activeIndex = -1;
updateAriaActiveDescendant(panelElement, null);
if (focusPanelFallback) {
panelElement.setAttribute("tabindex", panelElement.getAttribute("tabindex") || "-1");
try {
panelElement.focus({ preventScroll: true });
} catch (error) {
panelElement.focus();
}
}
}
function findNextEnabled(currentIndex, direction) {
const { items } = menuState;
if (!items.length) {
return -1;
}
const total = items.length;
let steps = 0;
let index = currentIndex;
while (steps < total) {
index = (index + direction + total) % total;
const candidate = items[index];
if (candidate && !isDisabled(candidate)) {
return index;
}
steps += 1;
}
return -1;
}
function moveFocus(direction) {
const startIndex = menuState.activeIndex >= 0 ? menuState.activeIndex : 0;
const nextIndex = findNextEnabled(startIndex, direction);
if (nextIndex >= 0) {
setActiveItem(nextIndex);
}
}
function focusEdge(direction) {
const items = menuState.items;
if (!items.length) {
return;
}
const iterate = direction > 0 ? items : [...items].reverse();
for (let idx = 0; idx < iterate.length; idx += 1) {
const item = iterate[idx];
if (!isDisabled(item)) {
const originalIndex = items.indexOf(item);
if (originalIndex >= 0) {
setActiveItem(originalIndex);
}
return;
}
}
}
function findMatchFrom(query, startIndex) {
const { items } = menuState;
if (!items.length || !query) {
return -1;
}
const total = items.length;
for (let offset = 0; offset < total; offset += 1) {
const index = (startIndex + offset) % total;
const item = items[index];
if (!item || isDisabled(item)) {
continue;
}
const label = getItemLabel(item).toLowerCase();
if (label && label.startsWith(query)) {
return index;
}
}
return -1;
}
function handleTypeahead(char) {
if (!char) {
return;
}
menuState.typeaheadBuffer += char;
scheduleTypeaheadReset();
const startIndex = menuState.activeIndex >= 0 ? menuState.activeIndex + 1 : 0;
let matchIndex = findMatchFrom(menuState.typeaheadBuffer, startIndex);
if (matchIndex < 0 && menuState.typeaheadBuffer.length > 1) {
matchIndex = findMatchFrom(char, startIndex);
}
if (matchIndex >= 0) {
setActiveItem(matchIndex);
}
}
function handleMenuKeydown(event) {
if (!menuState.panel || !containsTarget(menuState.panel, event.target)) {
return;
}
menuState.items = findMenuItems(menuState.panel);
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
moveFocus(1);
return;
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
moveFocus(-1);
return;
case "Home":
event.preventDefault();
focusEdge(1);
return;
case "End":
event.preventDefault();
focusEdge(-1);
return;
default:
break;
}
if (
event.key &&
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
const char = event.key.toLowerCase();
if (char.trim()) {
event.preventDefault();
handleTypeahead(char);
}
}
}
function handleMenuFocusIn(event) {
if (!menuState.panel || !containsTarget(menuState.panel, event.target)) {
return;
}
menuState.items = findMenuItems(menuState.panel);
const index = menuState.items.indexOf(event.target);
if (index >= 0 && !isDisabled(event.target)) {
setActiveItem(index, { focus: false });
}
}
function ensureMenu(panelElement) {
if (!panelElement) {
return;
}
if (menuState.panel && menuState.panel !== panelElement) {
if (menuHandlers.keydown) {
menuState.panel.removeEventListener("keydown", menuHandlers.keydown);
}
if (menuHandlers.focusin) {
menuState.panel.removeEventListener("focusin", menuHandlers.focusin);
}
}
menuState.panel = panelElement;
ensurePanelId(panelElement);
panelElement.setAttribute("role", "menu");
if (!panelElement.hasAttribute("tabindex")) {
panelElement.setAttribute("tabindex", "-1");
}
menuState.items = findMenuItems(panelElement);
menuState.activeIndex = -1;
updateAriaActiveDescendant(panelElement, null);
resetTypeaheadBuffer();
if (!menuHandlers.keydown) {
menuHandlers.keydown = (event) => handleMenuKeydown(event);
}
if (!menuHandlers.focusin) {
menuHandlers.focusin = (event) => handleMenuFocusIn(event);
}
panelElement.addEventListener("keydown", menuHandlers.keydown);
panelElement.addEventListener("focusin", menuHandlers.focusin);
}
function teardownMenu() {
const panelElement = menuState.panel;
if (panelElement && menuHandlers.keydown) {
panelElement.removeEventListener("keydown", menuHandlers.keydown);
}
if (panelElement && menuHandlers.focusin) {
panelElement.removeEventListener("focusin", menuHandlers.focusin);
}
resetTypeaheadBuffer();
menuState.panel = null;
menuState.items = [];
menuState.activeIndex = -1;
}
function applyPanelTokens() {
if (!panel) {
@@ -317,6 +699,13 @@ export function createPopover(trigger, render, options = {}) {
portal.appendChild(panel);
}
ensurePanelId(panel);
if (anchor && typeof anchor.setAttribute === "function") {
anchor.setAttribute("aria-controls", panel.id);
}
ensureMenu(panel);
arrowElement = resolveArrow(options.arrow, panel) || arrowElement;
applyPanelTokens();
@@ -529,6 +918,9 @@ export function createPopover(trigger, render, options = {}) {
await updatePosition();
ensureMenu(panelElement);
focusFirstEnabledItem();
isOpen = true;
activePopoverInstance = api;
return true;
@@ -554,9 +946,16 @@ export function createPopover(trigger, render, options = {}) {
panel.setAttribute("aria-hidden", "true");
panel.hidden = true;
panel.dataset.popoverState = "closed";
updateAriaActiveDescendant(panel, null);
if (menuState.items.length) {
menuState.items.forEach((item) => {
item.setAttribute("tabindex", "-1");
});
}
}
setExpandedAttribute(anchor, false);
resetTypeaheadBuffer();
if (restoreFocus && restoreFocusOnClose) {
restoreTriggerFocus();
@@ -601,6 +1000,8 @@ export function createPopover(trigger, render, options = {}) {
function destroy() {
close({ restoreFocus: false });
teardownMenu();
if (panel && panel.parentNode === portal) {
portal.removeChild(panel);
}
@@ -610,6 +1011,12 @@ export function createPopover(trigger, render, options = {}) {
panel = null;
arrowElement = null;
if (anchor) {
anchor.removeAttribute("aria-controls");
anchor.removeAttribute("aria-expanded");
anchor.removeAttribute("aria-haspopup");
}
}
const api = {
+246
View File
@@ -110,6 +110,9 @@ test("opens a popover in the overlay root and positions the panel", async () =>
{ document: documentRef },
);
assert.equal(trigger.getAttribute("aria-haspopup"), "menu");
assert.equal(trigger.getAttribute("aria-expanded"), "false");
await popover.open();
const overlayRoot = documentRef.getElementById("uiOverlay");
@@ -119,11 +122,14 @@ test("opens a popover in the overlay root and positions the panel", async () =>
const panel = documentRef.getElementById("popover-panel");
assert.ok(panel, "panel should be rendered");
assert.equal(trigger.getAttribute("aria-controls"), panel.id);
assert.equal(panel.dataset.popoverState, "open");
assert.equal(panel.dataset.popoverPlacement, "bottom-start");
assert.equal(panel.style.position, "fixed");
assert.equal(panel.style.left, "100px");
assert.equal(panel.style.top, "148px");
assert.equal(panel.getAttribute("role"), "menu");
assert.equal(documentRef.activeElement, panel);
assert.equal(trigger.getAttribute("aria-expanded"), "true");
popover.close();
@@ -186,6 +192,246 @@ test("closes on outside pointer events and restores focus", async () => {
popover.destroy();
});
test("supports roving focus, home/end navigation, and typeahead", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");
setupBoundingClientRect(trigger, {
x: 40,
y: 40,
top: 40,
bottom: 80,
left: 40,
right: 80,
width: 40,
height: 40,
});
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 120,
left: 0,
right: 200,
width: 200,
height: 120,
});
const list = documentRef.createElement("div");
panel.appendChild(list);
const disabled = documentRef.createElement("button");
disabled.className = "menu__item";
disabled.type = "button";
disabled.textContent = "Disabled";
disabled.disabled = true;
list.appendChild(disabled);
const copy = documentRef.createElement("button");
copy.className = "menu__item";
copy.type = "button";
copy.textContent = "Copy link";
list.appendChild(copy);
const deleteBtn = documentRef.createElement("button");
deleteBtn.className = "menu__item";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete video";
list.appendChild(deleteBtn);
const share = documentRef.createElement("button");
share.className = "menu__item";
share.type = "button";
share.textContent = "Share";
list.appendChild(share);
container.appendChild(panel);
return panel;
},
{ document: documentRef },
);
await popover.open();
const panel = documentRef.querySelector(".popover__panel");
const items = panel.querySelectorAll(".menu__item");
const [disabled, copy, deleteBtn, share] = items;
assert.equal(documentRef.activeElement, copy);
assert.equal(panel.getAttribute("aria-activedescendant"), copy.id);
assert.equal(copy.getAttribute("tabindex"), "0");
assert.equal(disabled.getAttribute("tabindex"), "-1");
copy.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
);
assert.equal(documentRef.activeElement, deleteBtn);
assert.equal(panel.getAttribute("aria-activedescendant"), deleteBtn.id);
deleteBtn.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
);
assert.equal(documentRef.activeElement, share);
share.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
);
assert.equal(documentRef.activeElement, copy);
copy.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
);
assert.equal(documentRef.activeElement, share);
share.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "Home", bubbles: true }),
);
assert.equal(documentRef.activeElement, copy);
copy.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "End", bubbles: true }),
);
assert.equal(documentRef.activeElement, share);
share.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "d", bubbles: true }),
);
assert.equal(documentRef.activeElement, deleteBtn);
assert.equal(panel.getAttribute("aria-activedescendant"), deleteBtn.id);
popover.destroy();
});
test("escape closes the popover and restores trigger focus", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");
setupBoundingClientRect(trigger, {
x: 60,
y: 60,
top: 60,
bottom: 100,
left: 60,
right: 100,
width: 40,
height: 40,
});
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 90,
left: 0,
right: 180,
width: 180,
height: 90,
});
const first = documentRef.createElement("button");
first.className = "menu__item";
first.type = "button";
first.textContent = "First";
panel.appendChild(first);
const second = documentRef.createElement("button");
second.className = "menu__item";
second.type = "button";
second.textContent = "Second";
panel.appendChild(second);
container.appendChild(panel);
return panel;
},
{ document: documentRef },
);
await popover.open();
const panel = documentRef.querySelector(".popover__panel");
const first = panel.querySelector(".menu__item");
assert.equal(documentRef.activeElement, first);
first.dispatchEvent(
new windowRef.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
assert.equal(popover.isOpen(), false);
assert.equal(panel.dataset.popoverState, "closed");
assert.equal(panel.getAttribute("aria-activedescendant"), null);
assert.equal(documentRef.activeElement, trigger);
popover.destroy();
});
test("close respects restoreFocus option for contextual menus", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");
setupBoundingClientRect(trigger, {
x: 80,
y: 80,
top: 80,
bottom: 120,
left: 80,
right: 120,
width: 40,
height: 40,
});
const popover = createPopover(
trigger,
({ container }) => {
const panel = documentRef.createElement("div");
panel.className = "popover__panel";
setupBoundingClientRect(panel, {
x: 0,
y: 0,
top: 0,
bottom: 90,
left: 0,
right: 180,
width: 180,
height: 90,
});
const action = documentRef.createElement("button");
action.className = "menu__item";
action.type = "button";
action.textContent = "Action";
panel.appendChild(action);
container.appendChild(panel);
return panel;
},
{ document: documentRef },
);
await popover.open();
const action = documentRef.querySelector(".menu__item");
assert.equal(documentRef.activeElement, action);
popover.close({ restoreFocus: false });
assert.equal(popover.isOpen(), false);
assert.notEqual(documentRef.activeElement, trigger);
assert.equal(documentRef.activeElement, action);
popover.destroy();
});
test("ensures only one popover is open at a time", async () => {
const trigger = documentRef.getElementById("trigger");
trigger.setAttribute("tabindex", "0");