fix(ui): improve command palette accessibility

Render the command palette as a native modal dialog with labelled combobox/listbox semantics, stable active-descendant wiring, and guarded close behavior.\n\nValidated with targeted command palette tests and formatter checks.
This commit is contained in:
Val Alexander
2026-04-29 07:44:03 -05:00
committed by GitHub
parent 03148a6a76
commit 88101e81ef
3 changed files with 320 additions and 17 deletions

View File

@@ -4848,14 +4848,29 @@ td.data-table-key-col {
position: fixed;
inset: 0;
z-index: 1000;
width: 100vw;
max-width: none;
height: 100dvh;
max-height: none;
margin: 0;
border: 0;
box-sizing: border-box;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: min(20vh, 160px);
padding-right: 0;
padding-bottom: 0;
padding-left: 0;
background: rgba(0, 0, 0, 0.5);
color: var(--text);
animation: fade-in 0.12s ease-out;
}
.cmd-palette-overlay::backdrop {
background: transparent;
}
.cmd-palette {
width: min(560px, 90vw);
overflow: hidden;
@@ -4866,6 +4881,19 @@ td.data-table-key-col {
animation: scale-in 0.15s ease-out;
}
.cmd-palette__label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.cmd-palette__input {
width: 100%;
padding: 14px 18px;

View File

@@ -1,9 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { nothing, render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../../i18n/index.ts";
import { refreshSlashCommands, resetSlashCommandsForTest } from "../chat/slash-commands.ts";
import { getFilteredPaletteItems, getPaletteItems } from "./command-palette.ts";
import {
getFilteredPaletteItems,
getPaletteItems,
renderCommandPalette,
type CommandPaletteProps,
} from "./command-palette.ts";
let container: HTMLDivElement;
const showModalDescriptor = Object.getOwnPropertyDescriptor(
HTMLDialogElement.prototype,
"showModal",
);
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function installDialogPolyfill() {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
configurable: true,
value(this: HTMLDialogElement) {
this.setAttribute("open", "");
},
});
}
function restoreShowModalDescriptor() {
if (showModalDescriptor) {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", showModalDescriptor);
return;
}
delete (HTMLDialogElement.prototype as Partial<HTMLDialogElement>).showModal;
}
function createProps(overrides: Partial<CommandPaletteProps> = {}): CommandPaletteProps {
return {
open: true,
query: "",
activeIndex: 0,
onToggle: () => undefined,
onQueryChange: () => undefined,
onActiveIndexChange: () => undefined,
onNavigate: () => undefined,
onSlashCommand: () => undefined,
...overrides,
};
}
async function renderPalette(overrides: Partial<CommandPaletteProps> = {}) {
const props = createProps(overrides);
render(renderCommandPalette(props), container);
await nextFrame();
return props;
}
beforeEach(() => {
installDialogPolyfill();
container = document.createElement("div");
document.body.append(container);
});
afterEach(async () => {
render(nothing, container);
container.remove();
restoreShowModalDescriptor();
vi.restoreAllMocks();
resetSlashCommandsForTest();
await i18n.setLocale("en");
});
@@ -69,4 +136,90 @@ describe("command palette", () => {
}),
);
});
it("renders a labelled modal combobox with listbox options", async () => {
await renderPalette({ query: "overview", activeIndex: 0 });
const dialog = container.querySelector<HTMLDialogElement>("dialog.cmd-palette-overlay");
expect(dialog).not.toBeNull();
expect(dialog!.open).toBe(true);
expect(dialog!.hasAttribute("role")).toBe(false);
expect(dialog!.hasAttribute("aria-modal")).toBe(false);
expect(dialog!.getAttribute("aria-labelledby")).toBe("cmd-palette-label");
const label = container.querySelector<HTMLLabelElement>("#cmd-palette-label");
const input = container.querySelector<HTMLInputElement>("#cmd-palette-input");
const listbox = container.querySelector<HTMLElement>("#cmd-palette-listbox");
expect(label?.textContent).toBe("Type a command…");
expect(label?.getAttribute("for")).toBe("cmd-palette-input");
expect(input).not.toBeNull();
expect(input!.getAttribute("role")).toBe("combobox");
expect(input!.getAttribute("aria-autocomplete")).toBe("list");
expect(input!.getAttribute("aria-expanded")).toBe("true");
expect(input!.getAttribute("aria-controls")).toBe("cmd-palette-listbox");
expect(input!.getAttribute("aria-activedescendant")).toBe("cmd-palette-option-nav-overview");
expect(document.activeElement).toBe(input);
expect(listbox).not.toBeNull();
expect(listbox!.getAttribute("role")).toBe("listbox");
const option = listbox!.querySelector<HTMLElement>("#cmd-palette-option-nav-overview");
expect(option).not.toBeNull();
expect(option!.getAttribute("role")).toBe("option");
expect(option!.getAttribute("aria-selected")).toBe("true");
});
it("traps Tab on the combobox and restores focus on Escape", async () => {
const returnTarget = document.createElement("button");
returnTarget.textContent = "Open palette";
document.body.append(returnTarget);
returnTarget.focus();
const onToggle = vi.fn();
await renderPalette({ onToggle });
const input = container.querySelector<HTMLInputElement>("#cmd-palette-input");
expect(input).not.toBeNull();
expect(document.activeElement).toBe(input);
const tab = new KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
});
input!.dispatchEvent(tab);
expect(tab.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
const escape = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
});
input!.dispatchEvent(escape);
expect(escape.defaultPrevented).toBe(true);
expect(onToggle).toHaveBeenCalledTimes(1);
await nextFrame();
expect(document.activeElement).toBe(returnTarget);
returnTarget.remove();
});
it("does not toggle twice when Escape is followed by dialog cancel", async () => {
const onToggle = vi.fn();
await renderPalette({ onToggle });
const dialog = container.querySelector<HTMLDialogElement>("dialog.cmd-palette-overlay");
const input = container.querySelector<HTMLInputElement>("#cmd-palette-input");
expect(dialog).not.toBeNull();
expect(input).not.toBeNull();
input!.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
}),
);
dialog!.dispatchEvent(new Event("cancel", { cancelable: true }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});

View File

@@ -135,16 +135,40 @@ function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
}
let previouslyFocused: Element | null = null;
let activeDialog: HTMLDialogElement | null = null;
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[tabindex]:not([tabindex='-1'])",
].join(",");
const paletteDialogLabelId = "cmd-palette-label";
const paletteInputId = "cmd-palette-input";
const paletteListboxId = "cmd-palette-listbox";
function saveFocus() {
if (previouslyFocused) {
return;
}
previouslyFocused = document.activeElement;
}
function restoreFocus() {
if (previouslyFocused && previouslyFocused instanceof HTMLElement) {
requestAnimationFrame(() => previouslyFocused && (previouslyFocused as HTMLElement).focus());
}
const target = previouslyFocused;
previouslyFocused = null;
activeDialog = null;
if (target instanceof HTMLElement && target.isConnected) {
requestAnimationFrame(() => {
if (target.isConnected) {
target.focus();
}
});
}
}
function selectItem(item: PaletteItem, props: CommandPaletteProps) {
@@ -157,6 +181,14 @@ function selectItem(item: PaletteItem, props: CommandPaletteProps) {
restoreFocus();
}
function closePalette(props: CommandPaletteProps) {
if (!activeDialog) {
return;
}
props.onToggle();
restoreFocus();
}
function scrollActiveIntoView() {
requestAnimationFrame(() => {
const el = document.querySelector(".cmd-palette__item--active");
@@ -164,7 +196,41 @@ function scrollActiveIntoView() {
});
}
function trapFocus(event: KeyboardEvent, root: HTMLElement) {
const focusable = [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => element.isConnected && element.tabIndex >= 0 && !element.closest("[hidden]"),
);
if (focusable.length === 0) {
event.preventDefault();
root.focus();
return;
}
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const focusInside = active ? focusable.includes(active) : false;
if (event.shiftKey && (!focusInside || active === first)) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && (!focusInside || active === last)) {
event.preventDefault();
first.focus();
}
}
function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
if (e.key === "Tab") {
const dialog = (e.currentTarget as HTMLElement | null)?.closest("dialog");
if (dialog instanceof HTMLElement) {
trapFocus(e, dialog);
}
return;
}
const items = filteredItems(props.query);
if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) {
return;
@@ -188,8 +254,8 @@ function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
break;
case "Escape":
e.preventDefault();
props.onToggle();
restoreFocus();
e.stopPropagation();
closePalette(props);
break;
}
}
@@ -207,10 +273,44 @@ function getCategoryLabel(category: string): string {
}
}
function focusInput(el: Element | undefined) {
if (el) {
function getOptionId(item: PaletteItem): string {
return `cmd-palette-option-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
function syncDialog(el: Element | undefined) {
if (!(el instanceof HTMLDialogElement)) {
if (activeDialog) {
restoreFocus();
}
return;
}
if (activeDialog !== el) {
saveFocus();
requestAnimationFrame(() => (el as HTMLInputElement).focus());
activeDialog = el;
}
if (el.open) {
return;
}
if (typeof el.showModal === "function") {
try {
el.removeAttribute("aria-modal");
el.showModal();
return;
} catch {
// Fall through to the open attribute fallback below.
}
}
el.setAttribute("aria-modal", "true");
el.setAttribute("open", "");
}
function focusInput(el: Element | undefined) {
if (el instanceof HTMLInputElement) {
requestAnimationFrame(() => {
if (el.isConnected) {
el.focus();
}
});
}
}
@@ -221,13 +321,23 @@ export function renderCommandPalette(props: CommandPaletteProps) {
const items = filteredItems(props.query);
const grouped = groupItems(items);
const activeItem = items[props.activeIndex];
const activeOptionId = activeItem ? getOptionId(activeItem) : nothing;
const paletteLabel = t("overview.palette.placeholder");
return html`
<div
<dialog
${ref(syncDialog)}
class="cmd-palette-overlay"
@click=${() => {
props.onToggle();
restoreFocus();
aria-labelledby=${paletteDialogLabelId}
@cancel=${(e: Event) => {
e.preventDefault();
closePalette(props);
}}
@click=${(e: Event) => {
if (e.target === e.currentTarget) {
closePalette(props);
}
}}
>
<div
@@ -235,17 +345,26 @@ export function renderCommandPalette(props: CommandPaletteProps) {
@click=${(e: Event) => e.stopPropagation()}
@keydown=${(e: KeyboardEvent) => handleKeydown(e, props)}
>
<label id=${paletteDialogLabelId} class="cmd-palette__label" for=${paletteInputId}
>${paletteLabel}</label
>
<input
${ref(focusInput)}
id=${paletteInputId}
class="cmd-palette__input"
placeholder="${t("overview.palette.placeholder")}"
role="combobox"
aria-autocomplete="list"
aria-controls=${paletteListboxId}
aria-activedescendant=${activeOptionId}
aria-expanded="true"
placeholder=${paletteLabel}
.value=${props.query}
@input=${(e: Event) => {
props.onQueryChange((e.target as HTMLInputElement).value);
props.onActiveIndexChange(0);
}}
/>
<div class="cmd-palette__results">
<div id=${paletteListboxId} class="cmd-palette__results" role="listbox">
${grouped.length === 0
? html`<div class="cmd-palette__empty">
<span class="nav-item__icon" style="opacity:0.3;width:20px;height:20px"
@@ -261,7 +380,10 @@ export function renderCommandPalette(props: CommandPaletteProps) {
const isActive = globalIndex === props.activeIndex;
return html`
<div
id=${getOptionId(item)}
class="cmd-palette__item ${isActive ? "cmd-palette__item--active" : ""}"
role="option"
aria-selected=${isActive ? "true" : "false"}
@click=${(e: Event) => {
e.stopPropagation();
selectItem(item, props);
@@ -287,6 +409,6 @@ export function renderCommandPalette(props: CommandPaletteProps) {
<span><kbd>esc</kbd> ${t("overview.palette.footer.close")}</span>
</div>
</div>
</div>
</dialog>
`;
}