FN-6145: fix mobile quick-entry touch targets

Preserve quick-entry mobile touch behavior for Fast and Priority controls.

- treat action touch targets as generic Elements so nested SVG taps still resolve to their parent buttons
- add mobile touch-action styling for quick-entry action buttons to improve tap handling
- extend QuickEntryBox mobile tests to cover Fast toggle, Priority picker, and SVG touch targets

Files changed:
 packages/dashboard/app/components/QuickEntryBox.css     |   5 +
 packages/dashboard/app/components/QuickEntryBox.tsx     |   2 +-
 packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx    | 117 +++++++++++++++++----
 3 files changed, 102 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-6145

Fusion-Task-Lineage: 2af1c142-25ba-41a8-ba4f-c58e38cc870a
This commit is contained in:
gsxdsm
2026-06-09 17:50:46 -07:00
parent 4f8097000c
commit f26cfe0cb0
3 changed files with 102 additions and 22 deletions

View File

@@ -430,8 +430,13 @@
/* === Quick Entry Mobile Touch + Overflow Fixes (FN-1140) === */ /* === Quick Entry Mobile Touch + Overflow Fixes (FN-1140) === */
@media (max-width: 768px) { @media (max-width: 768px) {
.quick-entry-actions {
touch-action: manipulation;
}
.quick-entry-actions .btn { .quick-entry-actions .btn {
min-height: 36px; min-height: 36px;
touch-action: manipulation;
} }
.quick-entry-box .dep-dropdown { .quick-entry-box .dep-dropdown {

View File

@@ -1484,7 +1484,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
data-testid="quick-entry-actions" data-testid="quick-entry-actions"
onTouchStart={(e: React.TouchEvent) => { onTouchStart={(e: React.TouchEvent) => {
const target = e.target; const target = e.target;
if (!(target instanceof HTMLElement)) return; if (!(target instanceof Element)) return;
const button = target.closest("button"); const button = target.closest("button");
if (button && !button.disabled) { if (button && !button.disabled) {
e.preventDefault(); e.preventDefault();

View File

@@ -120,25 +120,28 @@ vi.mock("../../hooks/useNodes", () => ({
})); }));
// Mock lucide-react // Mock lucide-react
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => {
Link: () => null, const MockIcon = (props: any) => <svg aria-hidden="true" {...props} />;
Paperclip: () => null, return {
Brain: () => null, Link: MockIcon,
Lightbulb: () => null, Paperclip: MockIcon,
ListTree: () => null, Brain: MockIcon,
Sparkles: () => null, Lightbulb: MockIcon,
Save: () => null, ListTree: MockIcon,
X: () => null, Sparkles: MockIcon,
ChevronDown: () => null, Save: MockIcon,
ChevronUp: () => null, X: MockIcon,
ChevronRight: () => null, ChevronDown: MockIcon,
Bot: () => null, ChevronUp: MockIcon,
Server: () => null, ChevronRight: MockIcon,
Flag: () => null, Bot: MockIcon,
Github: () => null, Server: MockIcon,
Maximize2: () => null, Flag: MockIcon,
Minimize2: () => null, Github: MockIcon,
})); Maximize2: MockIcon,
Minimize2: MockIcon,
};
});
// Mock ModelSelectionModal (kept for backward compatibility - no longer directly rendered) // Mock ModelSelectionModal (kept for backward compatibility - no longer directly rendered)
vi.mock("../ModelSelectionModal", () => ({ vi.mock("../ModelSelectionModal", () => ({
@@ -479,14 +482,14 @@ describe("QuickEntryBox", () => {
return textarea; return textarea;
} }
function fireCancelableTouchStart(target: HTMLElement) { function fireCancelableTouchStart(target: Element) {
const event = new Event("touchstart", { bubbles: true, cancelable: true }); const event = new Event("touchstart", { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(event, "preventDefault"); const preventDefaultSpy = vi.spyOn(event, "preventDefault");
fireEvent(target, event); fireEvent(target, event);
return { preventDefaultSpy }; return { preventDefaultSpy };
} }
async function touchActionButton(button: HTMLElement) { async function touchActionButton(button: Element) {
const { preventDefaultSpy } = fireCancelableTouchStart(button); const { preventDefaultSpy } = fireCancelableTouchStart(button);
expect(preventDefaultSpy).toHaveBeenCalled(); expect(preventDefaultSpy).toHaveBeenCalled();
await act(async () => { await act(async () => {
@@ -496,6 +499,78 @@ describe("QuickEntryBox", () => {
}); });
} }
async function touchPriorityOption(option: Element) {
await act(async () => {
fireEvent.touchStart(option);
fireEvent.touchEnd(option);
fireEvent.click(option);
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
}
it("captures an SVG touch target inside the priority button and opens the picker", async () => {
await renderMobileQuickEntryWithEnabledActions();
const priorityButton = screen.getByTestId("quick-entry-priority-button");
const svg = priorityButton.querySelector("svg");
expect(svg).not.toBeNull();
const { preventDefaultSpy } = fireCancelableTouchStart(svg!);
expect(preventDefaultSpy).toHaveBeenCalled();
await act(async () => {
fireEvent(svg!, new Event("touchend", { bubbles: true, cancelable: true }));
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy();
});
it("toggles Fast pressed state via mobile touch", async () => {
await renderMobileQuickEntryWithEnabledActions();
const fastToggle = screen.getByTestId("quick-entry-fast-toggle");
expect(fastToggle.getAttribute("aria-pressed")).toBe("false");
await touchActionButton(fastToggle);
expect(fastToggle.getAttribute("aria-pressed")).toBe("true");
});
it("opens the priority picker via mobile touch", async () => {
await renderMobileQuickEntryWithEnabledActions();
await touchActionButton(screen.getByTestId("quick-entry-priority-button"));
expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy();
});
it("selects a priority option after mobile touch opens the picker", async () => {
await renderMobileQuickEntryWithEnabledActions();
const priorityButton = screen.getByTestId("quick-entry-priority-button");
await touchActionButton(priorityButton);
const highOption = await screen.findByTestId("quick-entry-priority-option-high");
await touchPriorityOption(highOption);
expect(priorityButton.textContent).toContain("High");
await waitFor(() => {
expect(screen.queryByTestId("quick-entry-priority-option-normal")).toBeNull();
});
});
it.each([
["Priority", "quick-entry-priority-button"],
["Models", "quick-entry-models"],
["Node", "quick-entry-node-button"],
["Agent", "quick-entry-agent-button"],
] as const)("captures SVG touches on the %s action button", async (_label, testId) => {
await renderMobileQuickEntryWithEnabledActions();
const button = screen.getByTestId(testId);
const svg = button.querySelector("svg");
expect(svg).not.toBeNull();
const { preventDefaultSpy } = fireCancelableTouchStart(svg!);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it.each(QUICK_ENTRY_ACTION_BUTTONS)("keeps textarea focused during mobile touch on %s", async (_label, testId) => { it.each(QUICK_ENTRY_ACTION_BUTTONS)("keeps textarea focused during mobile touch on %s", async (_label, testId) => {
await renderMobileQuickEntryWithEnabledActions(); await renderMobileQuickEntryWithEnabledActions();
const textarea = focusTextareaWithValue(`Mobile touch preserves focus for ${testId}`); const textarea = focusTextareaWithValue(`Mobile touch preserves focus for ${testId}`);