mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user