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.
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
// @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();
|
|
});
|
|
});
|