FN-6211: prevent QuickEntry option taps from refocusing input
Prevents mobile option taps from re-opening the QuickEntry textarea keyboard. - suppresses textarea refocus briefly while QuickEntry action buttons handle touch interactions - only prevents default touch behavior when the textarea is already focused - adds mobile regression coverage for blurred action toggles, menus, attachments, refine, planning, subtasks, and save flows Files changed: .../dashboard/app/components/QuickEntryBox.tsx | 17 ++- .../components/__tests__/QuickEntryBox.test.tsx | 148 +++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-6211 Fusion-Task-Lineage: 9e20c4e3-8b19-4374-b492-745a92c7bb10
This commit is contained in:
@@ -105,6 +105,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const suppressRefocusRef = useRef(false);
|
||||
const justResetRef = useRef(false);
|
||||
const previousProjectIdRef = useRef(projectId);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
@@ -736,6 +737,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (suppressRefocusRef.current) {
|
||||
textareaRef.current?.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-expand on focus when autoExpand prop is true (default)
|
||||
if (autoExpand) {
|
||||
setIsExpanded(true);
|
||||
@@ -1487,15 +1493,24 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (!(target instanceof Element)) return;
|
||||
const button = target.closest("button");
|
||||
if (button && !button.disabled) {
|
||||
e.preventDefault();
|
||||
if (document.activeElement === textareaRef.current) {
|
||||
e.preventDefault();
|
||||
}
|
||||
touchButtonRef.current = button;
|
||||
suppressRefocusRef.current = true;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchButtonRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
suppressRefocusRef.current = false;
|
||||
}, 150);
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
touchButtonRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
suppressRefocusRef.current = false;
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -480,6 +480,10 @@ describe("QuickEntryBox", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled();
|
||||
});
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
fireEvent.focus(textarea);
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -708,6 +712,150 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("button focus — no refocus when textarea is blurred (FN-6211)", () => {
|
||||
async function renderBlurredMobileQuickEntry() {
|
||||
mockMobileViewport();
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
githubTrackingEnabledByDefault: true,
|
||||
} as any);
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
const result = renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
|
||||
expandQuickEntry();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled();
|
||||
});
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Adjust options without keyboard" } });
|
||||
textarea.blur();
|
||||
fireEvent.blur(textarea);
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
return { ...result, textarea, onPlanningMode, onSubtaskBreakdown };
|
||||
}
|
||||
|
||||
function fireCancelableTouchStart(target: Element) {
|
||||
const event = new Event("touchstart", { bubbles: true, cancelable: true });
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
fireEvent(target, event);
|
||||
return { preventDefaultSpy };
|
||||
}
|
||||
|
||||
async function touchActionButtonWithoutRefocus(button: Element, textarea: HTMLTextAreaElement) {
|
||||
const { preventDefaultSpy } = fireCancelableTouchStart(button);
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
await act(async () => {
|
||||
fireEvent(button, new Event("touchend", { bubbles: true, cancelable: true }));
|
||||
fireEvent.click(button);
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
}
|
||||
|
||||
async function assertBlurredButtonActionStillWorks(
|
||||
testId: string,
|
||||
helpers: Awaited<ReturnType<typeof renderBlurredMobileQuickEntry>>,
|
||||
attachClickSpy?: ReturnType<typeof vi.spyOn>,
|
||||
) {
|
||||
switch (testId) {
|
||||
case "quick-entry-fast-toggle":
|
||||
expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "true");
|
||||
break;
|
||||
case "quick-entry-github-toggle":
|
||||
expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "false");
|
||||
break;
|
||||
case "quick-entry-priority-button":
|
||||
expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy();
|
||||
break;
|
||||
case "quick-entry-deps":
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
break;
|
||||
case "quick-entry-models":
|
||||
expect(await screen.findByTestId("model-nested-menu")).toBeTruthy();
|
||||
break;
|
||||
case "quick-entry-node-button":
|
||||
expect(document.querySelector(".node-picker-dropdown")).toBeTruthy();
|
||||
break;
|
||||
case "quick-entry-agent-button":
|
||||
expect(document.querySelector(".agent-picker-dropdown")).toBeTruthy();
|
||||
break;
|
||||
case "quick-entry-attach":
|
||||
expect(attachClickSpy).toHaveBeenCalled();
|
||||
break;
|
||||
case "refine-button":
|
||||
expect(await screen.findByTestId("refine-clarify")).toBeTruthy();
|
||||
break;
|
||||
case "plan-button":
|
||||
expect(helpers.onPlanningMode).toHaveBeenCalledWith("Adjust options without keyboard");
|
||||
break;
|
||||
case "subtask-button":
|
||||
expect(helpers.onSubtaskBreakdown).toHaveBeenCalledWith("Adjust options without keyboard");
|
||||
break;
|
||||
case "quick-entry-save":
|
||||
await waitFor(() => {
|
||||
expect(helpers.props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled QuickEntry action test id: ${testId}`);
|
||||
}
|
||||
}
|
||||
|
||||
it.each(QUICK_ENTRY_ACTION_BUTTONS)(
|
||||
"does not refocus textarea when tapping %s with textarea blurred",
|
||||
async (_label, testId) => {
|
||||
const helpers = await renderBlurredMobileQuickEntry();
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const attachClickSpy = vi.spyOn(fileInput, "click");
|
||||
const button = screen.getByTestId(testId);
|
||||
|
||||
await touchActionButtonWithoutRefocus(button, helpers.textarea);
|
||||
await assertBlurredButtonActionStillWorks(testId, helpers, attachClickSpy);
|
||||
expect(document.activeElement).not.toBe(helpers.textarea);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not refocus textarea when selecting a priority option after blurred touch open", async () => {
|
||||
const { textarea } = await renderBlurredMobileQuickEntry();
|
||||
const priorityButton = screen.getByTestId("quick-entry-priority-button");
|
||||
|
||||
await touchActionButtonWithoutRefocus(priorityButton, textarea);
|
||||
const highOption = await screen.findByTestId("quick-entry-priority-option-high");
|
||||
await act(async () => {
|
||||
fireEvent.touchStart(highOption);
|
||||
fireEvent.touchEnd(highOption);
|
||||
fireEvent.click(highOption);
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(priorityButton.textContent).toContain("High");
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it("does not refocus textarea when selecting a dependency after blurred touch open", async () => {
|
||||
const { textarea } = await renderBlurredMobileQuickEntry();
|
||||
const depsButton = screen.getByTestId("quick-entry-deps");
|
||||
|
||||
await touchActionButtonWithoutRefocus(depsButton, textarea);
|
||||
const depItem = document.querySelector(".dep-dropdown-item");
|
||||
expect(depItem).toBeTruthy();
|
||||
await act(async () => {
|
||||
fireEvent.touchStart(depItem!);
|
||||
fireEvent.touchEnd(depItem!);
|
||||
fireEvent.click(depItem!);
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(depsButton.textContent).toContain("1 dep");
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
});
|
||||
|
||||
it("textarea spans full container width (FN-1608)", () => {
|
||||
mockDesktopViewport();
|
||||
renderQuickEntryBox({});
|
||||
|
||||
Reference in New Issue
Block a user