From 770c9855f61c51b841f21d44d9024f98d7a7bea5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Mon, 26 Oct 2026 13:39:37 -0700 Subject: [PATCH] fix(web): a dismissed folder picker stays closed when its label forwards the click Clicking the field's "Working directory" label closed the open panel on the outside pointerdown, and the browser's label activation then dispatched the same gesture's click on the input, which still held focus, reopening it. A dismiss now arms a flag that the next gesture's capture-phase pointerdown clears; a click-driven open that arrives while it is armed is the tail of the dismissing gesture and drops. --- .changeset/fix-dir-picker-label-reopen.md | 5 ++ .../__tests__/directory-picker-input.test.tsx | 36 +++++++++++++ .../src/components/directory-picker-input.tsx | 50 +++++++++++++++++-- 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-dir-picker-label-reopen.md diff --git a/.changeset/fix-dir-picker-label-reopen.md b/.changeset/fix-dir-picker-label-reopen.md new file mode 100644 index 000000000..571b281bb --- /dev/null +++ b/.changeset/fix-dir-picker-label-reopen.md @@ -0,0 +1,5 @@ +--- +"@internal/server": patch +--- + +Clicking a field's "Working directory" label now closes an open folder picker instead of bouncing it back open. One label click was two events: the click outside the panel closed it, and the browser then forwarded the label's activation click to the input, which reopened it. diff --git a/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx b/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx index 0d90e5778..08659e32c 100644 --- a/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx +++ b/apps/server/web/src/components/__tests__/directory-picker-input.test.tsx @@ -977,6 +977,42 @@ describe("DirectoryPickerInput", () => { } }); + /** + * The label trap (user report 2026-10-26): clicking the field's "Working + * directory" label to dismiss an open panel reopened it instead. One label + * click is two events: a pointerdown on the label (outside the picker, so + * the panel closes) and the label's activation behavior then dispatches a + * click on the input, which still holds focus and answers no focus event of + * its own. That forwarded click is the tail of the dismissing gesture and + * must not undo it — while the NEXT click on the field opens again as usual. + */ + it("clicking the field's label closes an open panel without reopening it", async () => { + const { restore } = mockExplore({ "/tmp": exploreBody("/tmp", ["cache"]) }); + // Stands in for the launch form's label, wired to the id renderField gives + // the input. happy-dom has no label activation behavior, so each label + // click below is driven as the two events a real browser produces. + const label = document.createElement("label"); + label.setAttribute("for", "wd"); + label.textContent = "Working directory"; + document.body.appendChild(label); + try { + renderField({ value: "/tmp" }); + const input = screen.getByRole("textbox"); + fireEvent.focus(input); + await screen.findByText("cache"); + fireEvent(label, new Event("pointerdown", { bubbles: true })); + fireEvent.click(input); + await waitFor(() => expect(screen.queryByText("cache") === null).toBe(true)); + // A fresh label click is now an ordinary field click: the panel opens. + fireEvent(label, new Event("pointerdown", { bubbles: true })); + fireEvent.click(input); + await screen.findByText("cache"); + } finally { + label.remove(); + restore(); + } + }); + // The server pages LOCAL browses (200 entries per page) and the picker // walks the cursors when the listing's tail comes into view. happy-dom has // no scroll, so these tests drive the trigger the same way a small folder diff --git a/apps/server/web/src/components/directory-picker-input.tsx b/apps/server/web/src/components/directory-picker-input.tsx index 3ea6310f7..4bea191e5 100644 --- a/apps/server/web/src/components/directory-picker-input.tsx +++ b/apps/server/web/src/components/directory-picker-input.tsx @@ -95,6 +95,34 @@ export function DirectoryPickerInput({ const rootRef = useRef(null); const inputRef = useRef(null); const restoringFocusRef = useRef(false); + // One gesture can dismiss AND reopen: clicking the field's label closes the + // panel on the outside pointerdown, and the label's activation behavior then + // dispatches a click on the input — which still holds focus, so it fires no + // focus event, and only the click handler can see it (user report + // 2026-10-26: "I tend to click above the input to close it and it reopens"). + // An outside pointerdown therefore arms this ref, and a click-driven open + // that arrives while it is armed is the tail of the dismissing gesture and + // drops. The capture-phase listener below disarms at the START of every + // gesture, which is what makes the next click on the field open again; + // click-side clearing alone could not — a dismissed gesture whose mouseup + // lands elsewhere leaves no click at the input to clear it, and the next + // honest click would be swallowed. One accepted residual rides the other + // way: a synthetic click with no pointer at all (AT activation, a + // `click()` call) landing on the input after an armed dismissal is dropped + // once, and the next press or refocus opens as usual. + const dismissedRef = useRef(false); + + // Always mounted, not with `pickerOpen`: the flag outlives the panel (an + // armed dismissal closes it), and the disarming must catch the next gesture + // whether or not anything is still listening. Capture phase puts it ahead of + // the dismiss listener on the same event, so arming happens after disarming. + useEffect(() => { + function disarm() { + dismissedRef.current = false; + } + document.addEventListener("pointerdown", disarm, true); + return () => document.removeEventListener("pointerdown", disarm, true); + }, []); // Move focus before removing a focused picker row. Letting the dialog // repair focus afterwards can focus the input and open the panel again. @@ -155,7 +183,10 @@ export function DirectoryPickerInput({ useEffect(() => { if (!pickerOpen) return; function onPointerDown(e: PointerEvent) { - if (!rootRef.current?.contains(e.target as Node)) closePicker(); + if (!rootRef.current?.contains(e.target as Node)) { + dismissedRef.current = true; + closePicker(); + } } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { @@ -315,8 +346,19 @@ export function DirectoryPickerInput({ favorite.mutate({ path, on }); } - function openPicker() { + /** + * Opens the panel on a deliberate interaction with the field. A `focus` + * opens: keyboard Tab, or a label click when the input held no focus. (The + * one focus that is not deliberate, `closePicker`'s restore, already + * arrives behind `restoringFocusRef`.) A `click` that arrives with + * `dismissedRef` armed is not: its gesture began outside the picker and + * already closed the panel — the label's activation, forwarded to the input, + * is the case that can produce this click — and it must not undo the + * dismissal. + */ + function openPicker(source: "focus" | "click") { if (restoringFocusRef.current) return; + if (source === "click" && dismissedRef.current) return; setPickerPath(value || "~"); setPickerOpen(true); } @@ -365,8 +407,8 @@ export function DirectoryPickerInput({ id={id} value={value} onChange={(e) => onChange(e.target.value)} - onFocus={openPicker} - onClick={openPicker} + onFocus={() => openPicker("focus")} + onClick={() => openPicker("click")} placeholder={placeholder} />