FN-6217: prevent visibility-driven quick entry focus
Limit Quick Entry focus restoration to successful submissions instead of dashboard visibility changes. - Gate desktop focus restoration behind an in-component successful submit marker. - Remove the stale touch-driven suppressRefocus blur/timer path so touch actions do not force unexpected blurs. - Cover mount, remount, mobile, submit, duplicate-confirm, Escape, Plan, and Subtask focus behavior in QuickEntryBox tests. - Add a patch changeset for the published CLI package. Files changed: .changeset/FN-6217-quick-entry-focus.md | 5 + .../dashboard/app/components/QuickEntryBox.tsx | 36 ++--- .../components/__tests__/QuickEntryBox.test.tsx | 167 +++++++++++++++++++-- 3 files changed, 174 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-6217 Fusion-Task-Lineage: cf8c55c0-583e-42f5-98af-8f3406c86d8c
This commit is contained in:
5
.changeset/FN-6217-quick-entry-focus.md
Normal file
5
.changeset/FN-6217-quick-entry-focus.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Quick Entry no longer auto-focuses when the board or dashboard becomes visible.
|
||||||
@@ -105,8 +105,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const suppressRefocusRef = useRef(false);
|
|
||||||
const justResetRef = useRef(false);
|
const justResetRef = useRef(false);
|
||||||
|
const justSubmittedRef = useRef(false);
|
||||||
const previousProjectIdRef = useRef(projectId);
|
const previousProjectIdRef = useRef(projectId);
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||||||
@@ -321,17 +321,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}
|
}
|
||||||
}, [description, isExpanded, autoResize]);
|
}, [description, isExpanded, autoResize]);
|
||||||
|
|
||||||
// Restore focus after submission completes (when textarea is re-enabled)
|
// Restore focus after an in-component submission completes (when textarea is re-enabled).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSubmitting && description === "" && textareaRef.current) {
|
if (!justSubmittedRef.current || isSubmitting || description !== "" || !textareaRef.current) {
|
||||||
// Use setTimeout to ensure focus happens after React re-enables the textarea
|
return;
|
||||||
const focusTimeout = setTimeout(() => {
|
|
||||||
if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) {
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
return () => clearTimeout(focusTimeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
justSubmittedRef.current = false;
|
||||||
|
// Use setTimeout to ensure focus happens after React re-enables the textarea.
|
||||||
|
const focusTimeout = setTimeout(() => {
|
||||||
|
if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(focusTimeout);
|
||||||
}, [isSubmitting, description]);
|
}, [isSubmitting, description]);
|
||||||
|
|
||||||
// Clear dep search when dropdown closes
|
// Clear dep search when dropdown closes
|
||||||
@@ -541,6 +544,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
justSubmittedRef.current = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDescription(originalDescription);
|
setDescription(originalDescription);
|
||||||
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
|
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
|
||||||
@@ -737,11 +741,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
if (suppressRefocusRef.current) {
|
|
||||||
textareaRef.current?.blur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-expand on focus when autoExpand prop is true (default)
|
// Auto-expand on focus when autoExpand prop is true (default)
|
||||||
if (autoExpand) {
|
if (autoExpand) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
@@ -1497,20 +1496,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
touchButtonRef.current = button;
|
touchButtonRef.current = button;
|
||||||
suppressRefocusRef.current = true;
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={() => {
|
||||||
touchButtonRef.current = null;
|
touchButtonRef.current = null;
|
||||||
window.setTimeout(() => {
|
|
||||||
suppressRefocusRef.current = false;
|
|
||||||
}, 150);
|
|
||||||
}}
|
}}
|
||||||
onTouchCancel={() => {
|
onTouchCancel={() => {
|
||||||
touchButtonRef.current = null;
|
touchButtonRef.current = null;
|
||||||
window.setTimeout(() => {
|
|
||||||
suppressRefocusRef.current = false;
|
|
||||||
}, 150);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ function clickSave() {
|
|||||||
fireEvent.click(screen.getByTestId("quick-entry-save"));
|
fireEvent.click(screen.getByTestId("quick-entry-save"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushPendingTimers() {
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openPriorityMenu() {
|
function openPriorityMenu() {
|
||||||
fireEvent.click(screen.getByTestId("quick-entry-priority-button"));
|
fireEvent.click(screen.getByTestId("quick-entry-priority-button"));
|
||||||
}
|
}
|
||||||
@@ -347,26 +353,163 @@ describe("QuickEntryBox", () => {
|
|||||||
expect((textarea as HTMLTextAreaElement).rows).toBe(2);
|
expect((textarea as HTMLTextAreaElement).rows).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the quick-entry textarea on mount at desktop width", async () => {
|
describe("post-submission focus restoration (FN-6217)", () => {
|
||||||
mockDesktopViewport();
|
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
|
||||||
renderQuickEntryBox({});
|
mockDesktopViewport();
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
renderQuickEntryBox({});
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input");
|
||||||
|
|
||||||
await waitFor(() => {
|
await flushPendingTimers();
|
||||||
expect(document.activeElement).toBe(textarea);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not focus the quick-entry textarea on mount at mobile width", async () => {
|
|
||||||
const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
|
|
||||||
renderQuickEntryBox({});
|
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.activeElement).not.toBe(textarea);
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
innerWidthSpy.mockRestore();
|
it("does not auto-focus the quick-entry textarea when restoring a non-empty draft on desktop mount", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
localStorage.setItem(QUICK_ENTRY_STORAGE_KEY, "restored draft");
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(textarea.value).toBe("restored draft");
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-focus the quick-entry textarea on desktop remount or visibility restoration", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
const { unmount } = renderQuickEntryBox({});
|
||||||
|
let textarea = screen.getByTestId("quick-entry-input");
|
||||||
|
|
||||||
|
await flushPendingTimers();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
textarea = screen.getByTestId("quick-entry-input");
|
||||||
|
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the quick-entry textarea after a successful Enter submission on desktop", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
|
renderQuickEntryBox({ onCreate });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Create from Enter" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the quick-entry textarea after a successful Save-button submission on desktop", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
|
renderQuickEntryBox({ onCreate });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Create from Save" } });
|
||||||
|
clickSave();
|
||||||
|
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the quick-entry textarea only after duplicate-confirmed creation completes on desktop", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
|
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||||
|
{ id: "FN-456", title: "Duplicate", description: "desc", column: "todo", score: 0.7 },
|
||||||
|
]);
|
||||||
|
renderQuickEntryBox({ onCreate });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "maybe duplicate" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
|
||||||
|
await flushPendingTimers();
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create anyway" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(textarea.value).toBe(""));
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-focuses the quick-entry textarea on mobile, including after a successful submission", async () => {
|
||||||
|
mockMobileViewport();
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
|
renderQuickEntryBox({ onCreate });
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
|
|
||||||
|
await flushPendingTimers();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Mobile submission" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-focus after Escape clears a non-empty draft", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
renderQuickEntryBox({});
|
||||||
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
fireEvent.focus(textarea);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Clear me" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
|
expect(textarea.value).toBe("");
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-focus after Plan or Subtask handoff reset the form", async () => {
|
||||||
|
mockDesktopViewport();
|
||||||
|
const onPlanningMode = vi.fn();
|
||||||
|
const onSubtaskBreakdown = vi.fn();
|
||||||
|
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
|
||||||
|
let textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Plan this" } });
|
||||||
|
fireEvent.click(screen.getByTestId("plan-button"));
|
||||||
|
await flushPendingTimers();
|
||||||
|
expect(onPlanningMode).toHaveBeenCalledWith("Plan this");
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||||
|
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||||
|
await flushPendingTimers();
|
||||||
|
textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down");
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("button focus preservation (FN-6122)", () => {
|
describe("button focus preservation (FN-6122)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user