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:
@@ -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()}
|
||||
|
||||
@@ -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({});
|
||||
|
||||
Reference in New Issue
Block a user