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} />