diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bc598c1b633..d42e8cdfa1b 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/ui/views/command-palette.test.ts b/ui/src/ui/views/command-palette.test.ts index b6a888cd67d..b010ac5f54e 100644 --- a/ui/src/ui/views/command-palette.test.ts +++ b/ui/src/ui/views/command-palette.test.ts @@ -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((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).showModal; +} + +function createProps(overrides: Partial = {}): CommandPaletteProps { + return { + open: true, + query: "", + activeIndex: 0, + onToggle: () => undefined, + onQueryChange: () => undefined, + onActiveIndexChange: () => undefined, + onNavigate: () => undefined, + onSlashCommand: () => undefined, + ...overrides, + }; +} + +async function renderPalette(overrides: Partial = {}) { + 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("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("#cmd-palette-label"); + const input = container.querySelector("#cmd-palette-input"); + const listbox = container.querySelector("#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("#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("#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("dialog.cmd-palette-overlay"); + const input = container.querySelector("#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); + }); }); diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts index 28a6d219d8c..af073f481f0 100644 --- a/ui/src/ui/views/command-palette.ts +++ b/ui/src/ui/views/command-palette.ts @@ -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(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` -
{ - props.onToggle(); - restoreFocus(); + aria-labelledby=${paletteDialogLabelId} + @cancel=${(e: Event) => { + e.preventDefault(); + closePalette(props); + }} + @click=${(e: Event) => { + if (e.target === e.currentTarget) { + closePalette(props); + } }} >
e.stopPropagation()} @keydown=${(e: KeyboardEvent) => handleKeydown(e, props)} > + { props.onQueryChange((e.target as HTMLInputElement).value); props.onActiveIndexChange(0); }} /> -
+
${grouped.length === 0 ? html`
{ e.stopPropagation(); selectItem(item, props); @@ -287,6 +409,6 @@ export function renderCommandPalette(props: CommandPaletteProps) { esc ${t("overview.palette.footer.close")}
-
+ `; }