Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,19 @@ function waitForAnimationFrame(): Promise<void> {
}

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(
<SelectableMessageProse>Selectable answer text</SelectableMessageProse>,
);

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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,10 +371,9 @@ export function SelectableMessageProse({
<div
ref={nodeRef}
className={className}
// The compact sidebar listens globally for a right-swipe from the main
// inset. A long-press text selection uses the same touch sequence, so
// keep sidebar swipe recognition out of selectable message prose.
data-no-sidebar-swipe
// Let compact-sidebar swipes begin over message prose, but give an
// expanded native text selection priority over the same touch sequence.
data-sidebar-swipe-selectable
>
{children}
</div>
Expand Down
90 changes: 89 additions & 1 deletion apps/app/src/components/ui/sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CompactViewportOverrideProvider isCompactViewport>
<SidebarProvider>
<Sidebar>Sidebar content</Sidebar>
<SidebarInset>
<div data-sidebar-swipe-selectable>Selectable message prose</div>
</SidebarInset>
</SidebarProvider>
</CompactViewportOverrideProvider>,
);
}

function OptionalSidebarProbe() {
const isShowing = useOptionalIsSidebarShowing();
return <div data-sidebar-showing={String(isShowing)} />;
Expand Down Expand Up @@ -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();
});
});
52 changes: 51 additions & 1 deletion apps/app/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type SidebarInsetSwipeSession = {
lastTimeMs: number;
velocityX: number;
isDragging: boolean;
selectionRoot: Element | null;
};

const sidebarMobileWidthStyle: SidebarMobileWidthStyle = {
Expand Down Expand Up @@ -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 {
Expand All @@ -170,6 +173,7 @@ function createSidebarInsetSwipeSession({
lastTimeMs: nowMs,
velocityX: 0,
isDragging: false,
selectionRoot,
};
}

Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -1002,6 +1034,7 @@ const SidebarInset = React.forwardRef<
id: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
selectionRoot: getSidebarSwipeSelectionRoot(event.target),
});

const removeListeners = () => {
Expand Down Expand Up @@ -1046,6 +1079,7 @@ const SidebarInset = React.forwardRef<
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
selectionRoot: getSidebarSwipeSelectionRoot(event.target),
});

const removeListeners = () => {
Expand All @@ -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();

@SawyerHood SawyerHood Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — A late text selection can leave the mobile drawer partly open.

A qualified move already opens the drawer and applies drag styles. This call removes the move and end listeners, but it does not close the drawer, settle it, or clear those styles. The browser reproduced the sequence touchstart → qualified touchmove → expanded selection → selectionchangetouchend; the panel stayed at translate3d(-56.25%, 0px, 0px) with transition: none.

Please settle or reset an active drag before removing its end listeners. Add a regression test with this event order and verify the drawer closes and clears all drag styles.

}
};

document.addEventListener("pointerdown", startPointerSwipe, {
capture: true,
passive: true,
Expand All @@ -1072,15 +1117,20 @@ const SidebarInset = React.forwardRef<
capture: true,
passive: true,
});
document.addEventListener("selectionchange", cancelSwipeForTextSelection);
return () => {
document.removeEventListener("pointerdown", startPointerSwipe, {
capture: true,
});
document.removeEventListener("touchstart", startTouchSwipe, {
capture: true,
});
document.removeEventListener(
"selectionchange",
cancelSwipeForTextSelection,
);
};
}, [startPointerSwipe, startTouchSwipe]);
}, [clearSwipeSession, startPointerSwipe, startTouchSwipe]);

const handleWheelSwipe = React.useCallback(
(event: WheelEvent) => {
Expand Down
Loading