From 7c1f2280a9b3de901425ac83b10f227ea9b86ba3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:23:28 -0400 Subject: [PATCH] Enhance popover accessibility and keyboard support --- js/ui/overlay/popoverEngine.js | 407 ++++++++++++++++++++++++++++++++ tests/ui/popoverEngine.test.mjs | 246 +++++++++++++++++++ 2 files changed, 653 insertions(+) diff --git a/js/ui/overlay/popoverEngine.js b/js/ui/overlay/popoverEngine.js index 2a9a96a4..6ecfc1e5 100644 --- a/js/ui/overlay/popoverEngine.js +++ b/js/ui/overlay/popoverEngine.js @@ -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 = { diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs index ff9dafdb..e91c7bb7 100644 --- a/tests/ui/popoverEngine.test.mjs +++ b/tests/ui/popoverEngine.test.mjs @@ -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");