From ca585447ffbc8e2ab356892dae30151fd86afaf8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:38:55 +0100 Subject: [PATCH] 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. --- web/src/dirAutocomplete.test.ts | 143 ++++++++++++++++++++++++++++++++ web/src/dirAutocomplete.ts | 63 ++++++++------ 2 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 web/src/dirAutocomplete.test.ts diff --git a/web/src/dirAutocomplete.test.ts b/web/src/dirAutocomplete.test.ts new file mode 100644 index 0000000..3c9e811 --- /dev/null +++ b/web/src/dirAutocomplete.test.ts @@ -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; +let ac: DirAutocompleteHandle | undefined; + +function popup(): HTMLUListElement { + return document.querySelector(".dir-ac") as HTMLUListElement; +} + +function items(): HTMLElement[] { + return [...popup().querySelectorAll(".dir-ac-item")]; +} + +async function type(value: string): Promise { + 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(); + }); +}); diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 3bf9647..0eb5efe 100644 --- a/web/src/dirAutocomplete.ts +++ b/web/src/dirAutocomplete.ts @@ -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 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 . - 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);