fix(maestro): dir autocomplete dismissal and key handling
Escape closes the popup, refocuses the input and stops propagation so it can't also discard a surrounding draft pane. Tab commits the highlighted entry and lets focus move to the next field. Blur / clicking outside dismisses, and a dismissed popup stays closed until the operator types again.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
|
||||
|
||||
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
const listDirMock = vi.mocked(listDir);
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let nextField: HTMLInputElement;
|
||||
let docKeydown: ReturnType<typeof vi.fn>;
|
||||
let ac: DirAutocompleteHandle | undefined;
|
||||
|
||||
function popup(): HTMLUListElement {
|
||||
return document.querySelector(".dir-ac") as HTMLUListElement;
|
||||
}
|
||||
|
||||
function items(): HTMLElement[] {
|
||||
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
|
||||
}
|
||||
|
||||
async function type(value: string): Promise<void> {
|
||||
input.focus();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
|
||||
}
|
||||
|
||||
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
|
||||
* default browser behaviour (focus move, form submit) was suppressed. */
|
||||
function press(key: string): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
document.body.replaceChildren();
|
||||
input = document.createElement("input");
|
||||
nextField = document.createElement("input");
|
||||
document.body.append(input, nextField);
|
||||
docKeydown = vi.fn();
|
||||
document.addEventListener("keydown", docKeydown);
|
||||
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ac?.destroy();
|
||||
document.removeEventListener("keydown", docKeydown);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("dirAutocomplete dismissal", () => {
|
||||
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
const { defaultPrevented } = press("Escape");
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(popup().hidden).toBe(true);
|
||||
expect(input.value).toBe("/tmp/pro");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
|
||||
});
|
||||
|
||||
it("blur dismisses the popup", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
expect(popup().hidden).toBe(false);
|
||||
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
expect(popup().hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reopen on refocus until the operator types again", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro");
|
||||
input.blur();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
await type("/tmp/ot");
|
||||
expect(popup().hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete keyboard model", () => {
|
||||
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
|
||||
|
||||
nextField.focus();
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(input.value).toBe("/tmp/proj-a/");
|
||||
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
|
||||
expect(document.activeElement).toBe(nextField); // focus was not yanked back
|
||||
});
|
||||
|
||||
it("Tab with nothing highlighted just moves on", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/nope-matches-nothing");
|
||||
expect(popup().hidden).toBe(true);
|
||||
|
||||
const { defaultPrevented } = press("Tab");
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(input.value).toBe("/tmp/nope-matches-nothing");
|
||||
});
|
||||
|
||||
it("arrows move the highlight and Enter commits it without submitting", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
await type("/tmp/");
|
||||
|
||||
press("ArrowDown");
|
||||
expect(items()[1].classList.contains("is-active")).toBe(true);
|
||||
|
||||
const enter = press("Enter");
|
||||
expect(enter.defaultPrevented).toBe(true); // no form submit
|
||||
expect(input.value).toBe("/tmp/proj-b/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirAutocomplete opening", () => {
|
||||
it("still opens on focus when not previously dismissed", async () => {
|
||||
ac = attachDirAutocomplete(input);
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(popup().hidden).toBe(false);
|
||||
expect(listDirMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+36
-27
@@ -4,15 +4,19 @@
|
||||
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
||||
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
||||
//
|
||||
// Tab complete the longest common prefix; if one match, accept it (and
|
||||
// append `/` so the next level loads); if nothing to extend, cycle.
|
||||
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||
// highlighted, plain tab navigation
|
||||
// ↑/↓ move the highlight
|
||||
// Enter accept the highlighted entry (and suppress form submit)
|
||||
// Esc close the dropdown
|
||||
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||
// can't also discard a surrounding draft pane
|
||||
//
|
||||
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||
// again — refocusing alone doesn't reopen it.
|
||||
//
|
||||
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { listDir } from "./api.js";
|
||||
|
||||
export interface DirAutocompleteHandle {
|
||||
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
let fetchSeq = 0;
|
||||
let debounce: number | undefined;
|
||||
let open = false;
|
||||
// Set when the operator dismissed the popup (Esc, blur, outside click); a
|
||||
// refocus must not reopen it — only new typing does.
|
||||
let dismissed = false;
|
||||
|
||||
|
||||
function close(): void {
|
||||
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
window.removeEventListener("resize", reposition);
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
dismissed = true;
|
||||
close();
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
const r = input.getBoundingClientRect();
|
||||
dropdown.style.left = `${r.left}px`;
|
||||
@@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
}
|
||||
|
||||
// Accept `name` as the next path segment: complete it and append `/` so the
|
||||
// child directory's listing loads immediately (the drill-in gesture).
|
||||
function accept(name: string): void {
|
||||
// child directory's listing loads on the next interaction (the drill-in
|
||||
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
|
||||
// and move to the next field in one gesture.
|
||||
function commit(name: string): void {
|
||||
const { dir } = splitPath(input.value);
|
||||
input.value = `${dir}${name}/`;
|
||||
input.focus();
|
||||
// Notify other listeners (spawn.ts marks the field user-edited) and load
|
||||
// the child level.
|
||||
// Notify other listeners (spawnForm marks the field user-edited).
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function accept(name: string): void {
|
||||
commit(name);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
dismissed = false;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (dismissed) return;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
// Swallow the key: an Escape that bubbles would also discard the
|
||||
// draft pane hosting this form.
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
input.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (!open || filtered.length === 0) return; // let focus move normally
|
||||
e.preventDefault();
|
||||
if (filtered.length === 1) {
|
||||
accept(filtered[0]);
|
||||
return;
|
||||
}
|
||||
const { dir, prefix } = splitPath(input.value);
|
||||
const lcp = longestCommonPrefix(filtered);
|
||||
if (lcp.length > prefix.length) {
|
||||
input.value = `${dir}${lcp}`;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} else {
|
||||
// Nothing to extend — cycle the highlight like a second shell <tab>.
|
||||
highlight = (highlight + 1) % filtered.length;
|
||||
render();
|
||||
}
|
||||
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
@@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
||||
|
||||
function onBlur(): void {
|
||||
// Delay so a dropdown mousedown can complete first.
|
||||
window.setTimeout(close, 120);
|
||||
window.setTimeout(dismiss, 120);
|
||||
}
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
|
||||
Reference in New Issue
Block a user