FN-6128: preserve textarea focus on mobile quick-entry buttons

Prevent mobile quick-entry touches from stealing focus from the task textarea.

- intercept touch interactions on quick-entry action buttons before the browser refocuses them
- restore textarea focus after touch-triggered quick-entry actions complete
- avoid autofocus in the deps search input when the textarea is already focused
- add mobile regression coverage across the full quick-entry action button surface, including disabled buttons

Files changed:
 packages/dashboard/app/components/QuickEntryBox.tsx     |  32 +++++-
 packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx    | 123 +++++++++++++++++++++
 2 files changed, 153 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6128

Fusion-Task-Lineage: 598e6ba5-6e83-426c-9bf2-dd2ac4215092
This commit is contained in:
gsxdsm
2026-06-09 14:51:22 -07:00
parent 5f1fe3f57b
commit e78203e7fb
2 changed files with 153 additions and 2 deletions

View File

@@ -104,6 +104,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
const justResetRef = useRef(false);
const previousProjectIdRef = useRef(projectId);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -1478,7 +1479,34 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
>
{/* All quick-create actions behind single disclosure toggle */}
{showExpandedControls && !isSubmitting && (
<div className="quick-entry-actions" data-testid="quick-entry-actions">
<div
className="quick-entry-actions"
data-testid="quick-entry-actions"
onTouchStart={(e: React.TouchEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest("button");
if (button && !button.disabled) {
e.preventDefault();
touchButtonRef.current = button;
}
}}
onTouchEnd={() => {
const button = touchButtonRef.current;
touchButtonRef.current = null;
if (button && !button.disabled) {
button.click();
window.setTimeout(() => {
window.setTimeout(() => {
textareaRef.current?.focus({ preventScroll: true });
}, 0);
}, 0);
}
}}
onTouchCancel={() => {
touchButtonRef.current = null;
}}
>
<button
type="button"
className="btn btn-sm"
@@ -1642,7 +1670,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<input
className="dep-dropdown-search"
placeholder={t("tasks.searchTasksPlaceholder", "Search tasks…")}
autoFocus
autoFocus={typeof document === "undefined" || document.activeElement !== textareaRef.current}
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}

View File

@@ -243,6 +243,39 @@ function mockDesktopViewport() {
}));
}
function mockMobileViewport() {
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn((query: string) => ({
matches: query.includes("max-width") || query.includes("768"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
const QUICK_ENTRY_ACTION_BUTTONS = [
["Plan", "plan-button"],
["Subtask", "subtask-button"],
["Refine", "refine-button"],
["Deps", "quick-entry-deps"],
["Attach", "quick-entry-attach"],
["Models", "quick-entry-models"],
["Node", "quick-entry-node-button"],
["Agent", "quick-entry-agent-button"],
["Priority", "quick-entry-priority-button"],
["Fast", "quick-entry-fast-toggle"],
["GitHub", "quick-entry-github-toggle"],
["Save", "quick-entry-save"],
] as const;
describe("QuickEntryBox", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -410,6 +443,96 @@ describe("QuickEntryBox", () => {
});
});
describe("button focus preservation — mobile touch (FN-6128)", () => {
async function renderMobileQuickEntryWithEnabledActions(props = {}) {
mockMobileViewport();
vi.mocked(fetchSettings).mockResolvedValueOnce({
githubTrackingEnabledByDefault: true,
} as any);
renderQuickEntryBox(props);
expandQuickEntry();
await waitFor(() => {
expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled();
});
}
function focusTextareaWithValue(value: string) {
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
textarea.focus();
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value } });
textarea.focus();
expect(document.activeElement).toBe(textarea);
return textarea;
}
function fireCancelableTouchStart(target: HTMLElement) {
const event = new Event("touchstart", { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
fireEvent(target, event);
return { preventDefaultSpy };
}
async function touchActionButton(button: HTMLElement) {
const { preventDefaultSpy } = fireCancelableTouchStart(button);
expect(preventDefaultSpy).toHaveBeenCalled();
await act(async () => {
fireEvent(button, new Event("touchend", { bubbles: true, cancelable: true }));
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
}
it.each(QUICK_ENTRY_ACTION_BUTTONS)("keeps textarea focused during mobile touch on %s", async (_label, testId) => {
await renderMobileQuickEntryWithEnabledActions();
const textarea = focusTextareaWithValue(`Mobile touch preserves focus for ${testId}`);
const button = screen.getByTestId(testId);
expect(screen.getByTestId("quick-entry-actions").contains(button)).toBe(true);
const { preventDefaultSpy } = fireCancelableTouchStart(button);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(document.activeElement).toBe(textarea);
await act(async () => {
fireEvent(button, new Event("touchend", { bubbles: true, cancelable: true }));
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
expect(document.activeElement).toBe(textarea);
});
it("does not fire disabled button actions via touch", async () => {
const onPlanningMode = vi.fn();
mockMobileViewport();
renderQuickEntryBox({ onPlanningMode });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
textarea.focus();
expect(document.activeElement).toBe(textarea);
const planButton = screen.getByTestId("plan-button");
expect(planButton).toBeDisabled();
const { preventDefaultSpy } = fireCancelableTouchStart(planButton);
expect(preventDefaultSpy).not.toHaveBeenCalled();
fireEvent(planButton, new Event("touchend", { bubbles: true, cancelable: true }));
expect(onPlanningMode).not.toHaveBeenCalled();
expect(document.activeElement).toBe(textarea);
});
it("preserves textarea focus for the complete quick-entry actions surface", async () => {
await renderMobileQuickEntryWithEnabledActions();
const actionsContainer = screen.getByTestId("quick-entry-actions");
const buttons = Array.from(actionsContainer.querySelectorAll("button"));
expect(buttons).toHaveLength(QUICK_ENTRY_ACTION_BUTTONS.length);
for (const button of buttons) {
const textarea = focusTextareaWithValue(`Full mobile surface focus for ${button.dataset.testid ?? button.textContent}`);
await touchActionButton(button);
expect(document.activeElement).toBe(textarea);
}
});
});
it("textarea spans full container width (FN-1608)", () => {
mockDesktopViewport();
renderQuickEntryBox({});