FN-7363: prevent mobile task long-press text selection
Prevent Board task long-press gestures from selecting card text while preserving editing controls. - Disable native text selection and WebKit touch callouts on non-editing task cards. - Prevent touch/pen pointer-down defaults before the long-press context-menu timer starts. - Cover the mobile selection behavior with component and CSS regression tests and document the Board behavior. Files changed: .changeset/fn-7363-mobile-board-text-selection.md | 7 ++++ docs/dashboard-guide.md | 5 +-- packages/dashboard/app/components/TaskCard.css | 17 ++++++++++ packages/dashboard/app/components/TaskCard.tsx | 5 +++ .../app/components/__tests__/TaskCard.test.tsx | 39 ++++++++++++++++++++++ .../app/components/__tests__/board-mobile.test.tsx | 24 +++++++++++++ 6 files changed, 95 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7363 Fusion-Task-Lineage: c683ab3b-2caa-4b45-a455-0cc163fd52cc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7363-mobile-board-text-selection.md
Normal file
7
.changeset/fn-7363-mobile-board-text-selection.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Prevent mobile Board task long-press menus from selecting card text.
|
||||
category: fix
|
||||
dev: Suppresses WebKit/native selection for non-editing TaskCard surfaces while preserving edit textareas.
|
||||
@@ -139,11 +139,12 @@ Features:
|
||||
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
|
||||
- Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
|
||||
- Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task.
|
||||
<!-- FNXC:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
|
||||
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call.
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation.
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment. -->
|
||||
FNXC:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment.
|
||||
FNXC:TaskCardMobileSelection 2026-07-01-00:00: Mobile Board long-press is a task-action gesture, not a text-selection gesture; document that the native selection/copy callout is suppressed while normal card clicks and edit textareas keep their behavior. -->
|
||||
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
|
||||
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
|
||||
|
||||
@@ -50,10 +50,24 @@
|
||||
FNXC:TaskCardMobilePan 2026-06-13-19:51:
|
||||
Mobile board cards must allow horizontal kanban panning from every visible card surface, not only from gaps or progress-count text.
|
||||
The global mobile touch-action reset applies to descendants, and browsers intersect touch-action along the touched element's ancestor chain, so the whole non-editing card subtree must opt back into pan-x.
|
||||
|
||||
FNXC:TaskCardMobileSelection 2026-07-01-00:00:
|
||||
Mobile long-press opens the Board task context menu, so non-editing card text must not trigger native text selection or iOS copy callouts. Keep this scoped to the shared `.card` subtree so normal and worktree-grouped Board cards are covered while edit controls opt back into selectable text.
|
||||
*/
|
||||
.card:not(.card-editing),
|
||||
.card:not(.card-editing) * {
|
||||
touch-action: pan-x pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card.card-editing,
|
||||
.card.card-editing *,
|
||||
.card :is(input, textarea, select, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
/* Agent-active glow: animated border glow when an agent is actively working on a task.
|
||||
@@ -1205,6 +1219,9 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
|
||||
@@ -2019,6 +2019,11 @@ function TaskCardComponent({
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!hasContextMenuActions || e.pointerType === "mouse" || isInteractiveTarget(e.target)) return;
|
||||
/*
|
||||
FNXC:TaskCardMobileSelection 2026-07-01-00:00:
|
||||
Touch/pen long-press is reserved for the Board task context menu. Prevent the native selection/copy callout before the timer starts while leaving mouse right-click, keyboard menu access, and editable descendants on their normal paths.
|
||||
*/
|
||||
e.preventDefault();
|
||||
clearLongPressTimer();
|
||||
longPressStartRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
|
||||
@@ -461,6 +461,45 @@ describe("TaskCard", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses native text selection when touch long-press opens the board card context menu", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cleanupGeometry = mockBoardContextMenuGeometry();
|
||||
const onOpenDetail = vi.fn();
|
||||
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
|
||||
try {
|
||||
render(
|
||||
<div className="column" style={{ overflow: "hidden" }}>
|
||||
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
|
||||
<TaskCard
|
||||
task={makeTask({ title: "Long selectable title", column: "in-progress", status: "executing" as any })}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noop}
|
||||
onPauseTask={onPauseTask}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const title = screen.getByText("Long selectable title");
|
||||
const pointerDownWasNotCanceled = fireEvent.pointerDown(title, {
|
||||
pointerType: "touch",
|
||||
pointerId: 9,
|
||||
clientX: 180,
|
||||
clientY: 160,
|
||||
cancelable: true,
|
||||
});
|
||||
expect(pointerDownWasNotCanceled).toBe(false);
|
||||
|
||||
act(() => vi.advanceTimersByTime(550));
|
||||
|
||||
expectBoardContextMenuPortaled();
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
fireEvent.pointerUp(title, { pointerType: "touch", pointerId: 9, clientX: 180, clientY: 160 });
|
||||
} finally {
|
||||
cleanupGeometry();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels board card long-press when touch moves before the delay", () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
|
||||
@@ -245,6 +245,30 @@ describe("Board and Column mobile CSS", () => {
|
||||
});
|
||||
|
||||
describe("TaskCard mobile", () => {
|
||||
it("suppresses native selection and iOS callouts for non-editing Board card text", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
// The shared `.card` selector covers both Column-rendered cards and WorktreeGroup-rendered cards.
|
||||
expectRuleToContain(css, ".card:not(.card-editing)", "touch-action: pan-x pan-y;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing)", "user-select: none;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing)", "-webkit-user-select: none;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing)", "-webkit-touch-callout: none;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing) *", "touch-action: pan-x pan-y;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing) *", "user-select: none;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing) *", "-webkit-user-select: none;");
|
||||
expectRuleToContain(css, ".card:not(.card-editing) *", "-webkit-touch-callout: none;");
|
||||
});
|
||||
|
||||
it("keeps TaskCard edit textarea selectable while non-editing cards suppress selection", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expectRuleToContain(css, ".card-edit-desc-textarea", "user-select: text;");
|
||||
expectRuleToContain(css, ".card-edit-desc-textarea", "-webkit-user-select: text;");
|
||||
expectRuleToContain(css, ".card-edit-desc-textarea", "-webkit-touch-callout: default;");
|
||||
expectRuleToContain(css, ".card.card-editing", "user-select: text;");
|
||||
expectRuleToContain(css, ".card :is(input, textarea, select, [contenteditable=\"true\"])", "user-select: text;");
|
||||
});
|
||||
|
||||
it("sets .card-archive-btn opacity: 1 in the mobile media block", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
Reference in New Issue
Block a user