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:
+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