Skip to content

Commit 6289ecf

Browse files
Keyboard accessibility and ARIA for column cog menu
The column actions (cog) menu was mouse-only: the trigger had no keyboard affordance and the menu had no ARIA roles or arrow-key navigation. - Make the cog trigger a focusable role="button" with aria-haspopup="menu", aria-expanded, and an aria-label; open it with Enter/Space/ArrowDown. - Add role="menu"/role="menuitem" and roving focus with Up/Down/Home/End. - Escape closes and restores focus to the trigger; Tab closes the menu. Refs #1964 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 12b25af commit 6289ecf

1 file changed

Lines changed: 71 additions & 2 deletions

File tree

datasette/static/table.js

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@ function renderActionLink(itemConfig) {
500500
var newLink = document.createElement("a");
501501
newLink.textContent = itemConfig.label;
502502
newLink.href = itemConfig.href || "#";
503+
newLink.setAttribute("role", "menuitem");
504+
newLink.tabIndex = -1;
503505
if (itemConfig.onClick) {
504506
newLink.addEventListener("click", itemConfig.onClick);
505507
}
@@ -512,9 +514,36 @@ const initDatasetteTable = function (manager) {
512514
if (!window.URLSearchParams) {
513515
return;
514516
}
515-
function closeMenu() {
517+
function closeMenu(options) {
516518
menu.style.display = "none";
517519
menu.classList.remove("anim-scale-in");
520+
if (menu._trigger) {
521+
menu._trigger.setAttribute("aria-expanded", "false");
522+
if (options && options.restoreFocus) {
523+
menu._trigger.focus();
524+
}
525+
menu._trigger = null;
526+
}
527+
}
528+
529+
// Move keyboard focus between the open menu's items (wraps around).
530+
function focusMenuItem(index) {
531+
var items = menu.querySelectorAll('[role="menuitem"]');
532+
if (!items.length) {
533+
return;
534+
}
535+
var i = (index + items.length) % items.length;
536+
items[i].focus();
537+
}
538+
539+
// Keyboard support on the column cog trigger: open with Enter/Space/ArrowDown
540+
// and move focus onto the first item, per the ARIA menu button pattern.
541+
function onTriggerKeydown(ev) {
542+
if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
543+
ev.preventDefault();
544+
onTableHeaderClick(ev);
545+
focusMenuItem(0);
546+
}
518547
}
519548

520549
const tableWrapper = document.querySelector(manager.selectors.tableWrapper);
@@ -535,6 +564,7 @@ const initDatasetteTable = function (manager) {
535564
function onTableHeaderClick(ev) {
536565
ev.preventDefault();
537566
ev.stopPropagation();
567+
var trigger = ev.currentTarget;
538568
menu.innerHTML = DROPDOWN_HTML;
539569
var th = ev.target;
540570
while (th.nodeName != "TH") {
@@ -560,9 +590,11 @@ const initDatasetteTable = function (manager) {
560590
},
561591
});
562592
var menuList = menu.querySelector("ul.dropdown-actions");
593+
menuList.setAttribute("role", "menu");
563594
menuList.innerHTML = "";
564595
actionState.actionItems.forEach((itemConfig) => {
565596
var menuItem = document.createElement("li");
597+
menuItem.setAttribute("role", "none");
566598
menuItem.appendChild(renderActionLink(itemConfig));
567599
menuList.appendChild(menuItem);
568600
});
@@ -607,8 +639,35 @@ const initDatasetteTable = function (manager) {
607639
// And move hook tip as well
608640
hook.style.left = menuWidth - 13 + "px";
609641
}
642+
643+
menu._trigger = trigger;
644+
trigger.setAttribute("aria-expanded", "true");
610645
}
611646

647+
// Keyboard navigation within the open menu (ARIA menu button pattern).
648+
menu.addEventListener("keydown", function (ev) {
649+
var items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
650+
var currentIndex = items.indexOf(document.activeElement);
651+
if (ev.key === "ArrowDown") {
652+
ev.preventDefault();
653+
focusMenuItem(currentIndex + 1);
654+
} else if (ev.key === "ArrowUp") {
655+
ev.preventDefault();
656+
focusMenuItem(currentIndex - 1);
657+
} else if (ev.key === "Home") {
658+
ev.preventDefault();
659+
focusMenuItem(0);
660+
} else if (ev.key === "End") {
661+
ev.preventDefault();
662+
focusMenuItem(items.length - 1);
663+
} else if (ev.key === "Escape") {
664+
ev.preventDefault();
665+
closeMenu({ restoreFocus: true });
666+
} else if (ev.key === "Tab") {
667+
closeMenu();
668+
}
669+
});
670+
612671
var svg = document.createElement("div");
613672
svg.innerHTML = DROPDOWN_ICON_SVG;
614673
svg = svg.querySelector("*");
@@ -624,11 +683,21 @@ const initDatasetteTable = function (manager) {
624683
document.querySelectorAll(manager.selectors.tableHeaders),
625684
);
626685
ths.forEach((th) => {
627-
if (!th.querySelector("a")) {
686+
var columnLink = th.querySelector("a");
687+
if (!columnLink) {
628688
return;
629689
}
630690
var icon = svg.cloneNode(true);
691+
icon.setAttribute("role", "button");
692+
icon.setAttribute("tabindex", "0");
693+
icon.setAttribute("aria-haspopup", "menu");
694+
icon.setAttribute("aria-expanded", "false");
695+
icon.setAttribute(
696+
"aria-label",
697+
"Column actions for " + columnLink.textContent.trim(),
698+
);
631699
icon.addEventListener("click", onTableHeaderClick);
700+
icon.addEventListener("keydown", onTriggerKeydown);
632701
th.appendChild(icon);
633702
});
634703
};

0 commit comments

Comments
 (0)