diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx index 923e35e3fd..88d0d2d3de 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.events.test.tsx @@ -58,14 +58,19 @@ function waitForAnimationFrame(): Promise { } describe("SelectableMessageProse", () => { - it("opts selectable prose out of the compact sidebar swipe gesture", () => { + it("keeps selectable prose available to the compact sidebar swipe gesture", () => { const { getByText } = render( Selectable answer text, ); expect( - getByText("Selectable answer text").closest("[data-no-sidebar-swipe]"), + getByText("Selectable answer text").closest( + "[data-sidebar-swipe-selectable]", + ), ).not.toBeNull(); + expect( + getByText("Selectable answer text").closest("[data-no-sidebar-swipe]"), + ).toBeNull(); }); it("reports a selection only after pointer release", async () => { diff --git a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx index 784293000b..7617b91134 100644 --- a/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx +++ b/apps/app/src/components/thread/timeline/SelectableMessageProse.tsx @@ -371,10 +371,9 @@ export function SelectableMessageProse({
{children}
diff --git a/apps/app/src/components/ui/sidebar.test.tsx b/apps/app/src/components/ui/sidebar.test.tsx index 2d7af40436..f0657be335 100644 --- a/apps/app/src/components/ui/sidebar.test.tsx +++ b/apps/app/src/components/ui/sidebar.test.tsx @@ -1,11 +1,63 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { renderToString } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { + Sidebar, + SidebarInset, SidebarProvider, SidebarTrigger, useOptionalIsSidebarShowing, } from "./sidebar"; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function createTouch(clientX: number, clientY: number): Touch { + return { identifier: 1, clientX, clientY } as Touch; +} + +function createTouchList(...touches: Touch[]): TouchList { + const touchList = { + length: touches.length, + item: (index: number) => touches[index] ?? null, + }; + touches.forEach((touch, index) => { + Object.defineProperty(touchList, index, { value: touch }); + }); + return touchList as unknown as TouchList; +} + +function fireTouch( + target: Element | Document | Window, + type: "touchstart" | "touchmove", + touch: Touch, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: createTouchList(touch) }, + changedTouches: { value: createTouchList(touch) }, + }); + fireEvent(target, event); +} + +function renderSelectableSwipeHarness() { + render( + + + Sidebar content + +
Selectable message prose
+
+
+
, + ); +} + function OptionalSidebarProbe() { const isShowing = useOptionalIsSidebarShowing(); return
; @@ -33,3 +85,39 @@ describe("SidebarTrigger", () => { expect(markup).not.toContain('aria-pressed="'); }); }); + +describe("mobile sidebar text-selection arbitration", () => { + it("opens from a right swipe that starts over selectable message prose", () => { + renderSelectableSwipeHarness(); + const prose = screen.getByText("Selectable message prose"); + + fireTouch(prose, "touchstart", createTouch(120, 160)); + fireTouch(window, "touchmove", createTouch(260, 164)); + + expect(document.querySelector('[data-sidebar="panel"]')).not.toBeNull(); + }); + + it("cancels a pending prose swipe when native text selection begins", () => { + let hasSelection = false; + let selectionNode: Node | null = null; + vi.spyOn(document, "getSelection").mockImplementation(() => + hasSelection + ? ({ + anchorNode: selectionNode, + focusNode: selectionNode, + isCollapsed: false, + } as Selection) + : null, + ); + renderSelectableSwipeHarness(); + const prose = screen.getByText("Selectable message prose"); + selectionNode = prose.firstChild; + + fireTouch(prose, "touchstart", createTouch(120, 160)); + hasSelection = true; + fireEvent(document, new Event("selectionchange")); + fireTouch(window, "touchmove", createTouch(260, 164)); + + expect(document.querySelector('[data-sidebar="panel"]')).toBeNull(); + }); +}); diff --git a/apps/app/src/components/ui/sidebar.tsx b/apps/app/src/components/ui/sidebar.tsx index f28cca3e1d..00d9ce9226 100644 --- a/apps/app/src/components/ui/sidebar.tsx +++ b/apps/app/src/components/ui/sidebar.tsx @@ -51,6 +51,7 @@ type SidebarInsetSwipeSession = { lastTimeMs: number; velocityX: number; isDragging: boolean; + selectionRoot: Element | null; }; const sidebarMobileWidthStyle: SidebarMobileWidthStyle = { @@ -152,11 +153,13 @@ function createSidebarInsetSwipeSession({ id, startX, startY, + selectionRoot, }: { kind: "pointer" | "touch"; id: number; startX: number; startY: number; + selectionRoot: Element | null; }): SidebarInsetSwipeSession { const nowMs = Date.now(); return { @@ -170,6 +173,7 @@ function createSidebarInsetSwipeSession({ lastTimeMs: nowMs, velocityX: 0, isDragging: false, + selectionRoot, }; } @@ -218,6 +222,26 @@ function isInsideHorizontalScrollRegion(target: Element): boolean { return false; } +function getSidebarSwipeSelectionRoot( + target: EventTarget | null, +): Element | null { + return target instanceof Element + ? target.closest("[data-sidebar-swipe-selectable]") + : null; +} + +function hasExpandedTextSelectionWithin(root: Element): boolean { + const selection = root.ownerDocument.getSelection(); + if (selection === null || selection.isCollapsed) { + return false; + } + + return ( + (selection.anchorNode !== null && root.contains(selection.anchorNode)) || + (selection.focusNode !== null && root.contains(selection.focusNode)) + ); +} + function shouldIgnoreSidebarSwipeTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) { return false; @@ -242,6 +266,14 @@ function shouldIgnoreSidebarSwipeTarget(target: EventTarget | null): boolean { return true; } + const selectionRoot = getSidebarSwipeSelectionRoot(target); + if ( + selectionRoot !== null && + hasExpandedTextSelectionWithin(selectionRoot) + ) { + return true; + } + return isInsideHorizontalScrollRegion(target); } @@ -1002,6 +1034,7 @@ const SidebarInset = React.forwardRef< id: touch.identifier, startX: touch.clientX, startY: touch.clientY, + selectionRoot: getSidebarSwipeSelectionRoot(event.target), }); const removeListeners = () => { @@ -1046,6 +1079,7 @@ const SidebarInset = React.forwardRef< id: event.pointerId, startX: event.clientX, startY: event.clientY, + selectionRoot: getSidebarSwipeSelectionRoot(event.target), }); const removeListeners = () => { @@ -1064,6 +1098,17 @@ const SidebarInset = React.forwardRef< ); React.useEffect(() => { + const cancelSwipeForTextSelection = () => { + const selectionRoot = swipeSessionRef.current?.selectionRoot; + if ( + selectionRoot !== null && + selectionRoot !== undefined && + hasExpandedTextSelectionWithin(selectionRoot) + ) { + clearSwipeSession(); + } + }; + document.addEventListener("pointerdown", startPointerSwipe, { capture: true, passive: true, @@ -1072,6 +1117,7 @@ const SidebarInset = React.forwardRef< capture: true, passive: true, }); + document.addEventListener("selectionchange", cancelSwipeForTextSelection); return () => { document.removeEventListener("pointerdown", startPointerSwipe, { capture: true, @@ -1079,8 +1125,12 @@ const SidebarInset = React.forwardRef< document.removeEventListener("touchstart", startTouchSwipe, { capture: true, }); + document.removeEventListener( + "selectionchange", + cancelSwipeForTextSelection, + ); }; - }, [startPointerSwipe, startTouchSwipe]); + }, [clearSwipeSession, startPointerSwipe, startTouchSwipe]); const handleWheelSwipe = React.useCallback( (event: WheelEvent) => {