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