FN-7922: fix jerky tablet drag on headerless floating-window handles
Ensures every draggable modal surface (terminal, artifacts viewer, and other FloatingWindow delegates) keeps touch-action: none on its drag handle so tablet touch-drag is captured by FloatingWindow's pointermove stream instead of being intersected by page pan, and backs the contract with tests. - Add touch-action: none to .artifacts-gallery-viewer-header, the headerless FloatingWindow's delegated drag handle, matching the other movable modal handles. - Add a FloatingWindow test asserting touch-action: none is present across every tablet movable-modal drag handle (terminal, artifacts, workflow editor, automation, mission interview, PR create, file browser, right dock, new task modal, quick chat FAB) and absent from broad tablet pan-y overrides. - Add a FloatingWindow test exercising the captured tablet touch-drag path for a headerless delegated handle (pointerdown/move/up with pointerType: "touch"). - Fix TerminalModal drag test to send pointerType: "touch" on pointerdown/move/up so it exercises the same tablet touch-drag contract. - Add changeset for the tablet drag fix. Files changed: .changeset/fn-7922-tablet-modal-drag.md | 7 ++ .../dashboard/app/components/ArtifactsGallery.css | 5 ++ .../components/__tests__/FloatingWindow.test.tsx | 76 +++++++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 6 +- 4 files changed, 90 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7922 Fusion-Task-Lineage: 0d63ced5-faa7-4362-a42d-884b4c889ebf Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7922-tablet-modal-drag.md
Normal file
7
.changeset/fn-7922-tablet-modal-drag.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix jerky tablet drag for the terminal and other movable modals.
|
||||||
|
category: fix
|
||||||
|
dev: Reassert drag-handle `touch-action: none` coverage for headerless floating-window delegates and test the tablet touch-drag contract across movable modal surfaces.
|
||||||
@@ -369,11 +369,16 @@ Viewers live inside the shared FloatingWindow (draggable by the viewer header, r
|
|||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ArtifactsGallery 2026-07-12-21:10:
|
||||||
|
The artifacts viewer is a headerless FloatingWindow, so this header is the delegated drag handle on tablet and coarse-pointer floating layouts. Keep its effective `touch-action: none` so a single-finger drag is delivered through FloatingWindow's captured pointermove stream instead of being intersected back into page pan; the mobile sheet override below still disables the desktop drag affordance at <=768px.
|
||||||
|
*/
|
||||||
.artifacts-gallery-viewer-header {
|
.artifacts-gallery-viewer-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifacts-gallery-viewer-header:active {
|
.artifacts-gallery-viewer-header:active {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { loadAllAppCss } from "../../test/cssFixture";
|
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
|
||||||
import { FloatingWindow } from "../FloatingWindow";
|
import { FloatingWindow } from "../FloatingWindow";
|
||||||
|
|
||||||
const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8");
|
const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8");
|
||||||
const allAppCss = loadAllAppCss();
|
const allAppCss = loadAllAppCss();
|
||||||
|
const stylesCss = loadStylesCss();
|
||||||
|
|
||||||
function cssRuleFor(css: string, selector: string): string {
|
function cssRuleFor(css: string, selector: string): string {
|
||||||
const start = css.indexOf(`${selector} {`);
|
const start = css.indexOf(`${selector} {`);
|
||||||
@@ -14,6 +15,15 @@ function cssRuleFor(css: string, selector: string): string {
|
|||||||
return css.slice(start, end);
|
return css.slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssRuleContaining(css: string, selector: string, declaration: string): string {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\ /g, "\\s+");
|
||||||
|
const matches = css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "g"));
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match[0].includes(declaration)) return match[0];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||||
Contract tests for the reusable non-blocking floating window:
|
Contract tests for the reusable non-blocking floating window:
|
||||||
@@ -83,6 +93,35 @@ describe("FloatingWindow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps every tablet movable-modal drag handle on the explicit touch-action none contract", () => {
|
||||||
|
const tabletStylesStart = stylesCss.indexOf("@media (min-width: 769px) and (max-width: 1024px)");
|
||||||
|
const mobileStylesStart = stylesCss.indexOf("@media (max-width: 768px)", tabletStylesStart);
|
||||||
|
expect(tabletStylesStart).toBeGreaterThan(-1);
|
||||||
|
expect(mobileStylesStart).toBeGreaterThan(tabletStylesStart);
|
||||||
|
|
||||||
|
const tabletBlock = stylesCss.slice(tabletStylesStart, mobileStylesStart);
|
||||||
|
expect(tabletBlock).not.toContain("* {");
|
||||||
|
expect(tabletBlock).not.toContain("touch-action: pan-y;");
|
||||||
|
|
||||||
|
for (const selector of [
|
||||||
|
".floating-window__header",
|
||||||
|
".floating-window--headerless .task-detail-content--embedded > .modal-header",
|
||||||
|
".chat-view--floating .view-header",
|
||||||
|
".floating-window--workflow-editor .wf-editor-header",
|
||||||
|
".floating-window--automation .automation-modal__drag-handle",
|
||||||
|
".floating-window--mission-interview .mission-interview-modal__drag-handle",
|
||||||
|
".floating-window--pr-create .pr-create-modal__drag-handle",
|
||||||
|
".file-browser-modal-header",
|
||||||
|
".artifacts-gallery-viewer-header",
|
||||||
|
".terminal-header--draggable",
|
||||||
|
".right-dock-expand-modal__header--draggable",
|
||||||
|
".new-task-modal__header--draggable",
|
||||||
|
".quick-chat-fab",
|
||||||
|
]) {
|
||||||
|
expect(cssRuleContaining(allAppCss, selector, "touch-action: none;"), selector).toContain("touch-action: none;");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("moves a visible-header window through the captured touch drag path", () => {
|
it("moves a visible-header window through the captured touch drag path", () => {
|
||||||
render(
|
render(
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
@@ -141,6 +180,41 @@ describe("FloatingWindow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves a headerless delegated handle through the captured tablet touch drag path", () => {
|
||||||
|
render(
|
||||||
|
<FloatingWindow
|
||||||
|
windowKey="artifacts-delegate"
|
||||||
|
title="Artifacts"
|
||||||
|
onClose={() => {}}
|
||||||
|
hideHeader
|
||||||
|
dragHandleSelector=".artifacts-gallery-viewer-header"
|
||||||
|
className="artifacts-gallery-window"
|
||||||
|
defaultSize={{ width: 320, height: 240 }}
|
||||||
|
defaultPosition={{ x: 90, y: 110 }}
|
||||||
|
minSize={{ width: 240, height: 180 }}
|
||||||
|
>
|
||||||
|
<div className="artifacts-gallery-viewer-header">Artifacts header</div>
|
||||||
|
<div aria-label="empty artifacts body" />
|
||||||
|
</FloatingWindow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const panel = screen.getByTestId("floating-window-artifacts-delegate");
|
||||||
|
const delegatedHeader = screen.getByText("Artifacts header");
|
||||||
|
const setPointerCapture = vi.fn();
|
||||||
|
const releasePointerCapture = vi.fn();
|
||||||
|
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setPointerCapture });
|
||||||
|
Object.defineProperty(panel, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
|
||||||
|
|
||||||
|
fireEvent.pointerDown(delegatedHeader, { pointerId: 23, pointerType: "touch", clientX: 120, clientY: 140 });
|
||||||
|
fireEvent.pointerMove(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
|
||||||
|
fireEvent.pointerUp(panel, { pointerId: 23, pointerType: "touch", clientX: 150, clientY: 170 });
|
||||||
|
|
||||||
|
expect(setPointerCapture).toHaveBeenCalledWith(23);
|
||||||
|
expect(releasePointerCapture).toHaveBeenCalledWith(23);
|
||||||
|
expect(panel.style.left).toBe("120px");
|
||||||
|
expect(panel.style.top).toBe("140px");
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => {
|
it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => {
|
||||||
expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00");
|
expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00");
|
||||||
expect(floatingWindowCss).toContain(".floating-window--task-detail {");
|
expect(floatingWindowCss).toContain(".floating-window--task-detail {");
|
||||||
|
|||||||
@@ -1089,9 +1089,9 @@ describe("TerminalModal", () => {
|
|||||||
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
|
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
|
||||||
header.setPointerCapture = vi.fn();
|
header.setPointerCapture = vi.fn();
|
||||||
header.releasePointerCapture = vi.fn();
|
header.releasePointerCapture = vi.fn();
|
||||||
fireEvent.pointerDown(header, { pointerId: 2, clientX: 100, clientY: 100 });
|
fireEvent.pointerDown(header, { pointerId: 2, pointerType: "touch", clientX: 100, clientY: 100 });
|
||||||
fireEvent.pointerMove(header, { pointerId: 2, clientX: 125, clientY: 135 });
|
fireEvent.pointerMove(header, { pointerId: 2, pointerType: "touch", clientX: 125, clientY: 135 });
|
||||||
fireEvent.pointerUp(header, { pointerId: 2 });
|
fireEvent.pointerUp(header, { pointerId: 2, pointerType: "touch" });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();
|
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();
|
||||||
|
|||||||
Reference in New Issue
Block a user