diff --git a/apps/web/src/lib/selectionActions.test.ts b/apps/web/src/lib/selectionActions.test.ts index 56f51863b690..d677b2858771 100644 --- a/apps/web/src/lib/selectionActions.test.ts +++ b/apps/web/src/lib/selectionActions.test.ts @@ -24,7 +24,12 @@ function createSelectionSurface({ interactiveActions = false } = {}) { }); const element = Object.assign(new EventTarget(), { ownerDocument: document, - contains: (target: unknown): boolean => target === element, + contains: (target: unknown): boolean => target === element || target === buttonIcon, + closest: () => null, + }); + const button = new EventTarget(); + const buttonIcon = Object.assign(new EventTarget(), { + closest: (selector: string) => (selector === "button, [role=button]" ? button : null), }); const onSelection = vi.fn(); const onDismiss = vi.fn(); @@ -58,7 +63,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { const press = event("pointerdown", { button, isPrimary }); Object.defineProperty(press, "target", { value: target }); document.dispatchEvent(press); - if (target === element && !consumed) element.dispatchEvent(press); + if (element.contains(target) && !consumed) element.dispatchEvent(press); }; const focus = (target: EventTarget) => { document.activeElement = target; @@ -92,6 +97,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { view, document, element, + buttonIcon, actionElement, field, onSelection, @@ -177,6 +183,31 @@ describe("selection action gestures", () => { expect(surface.onSelection).not.toHaveBeenCalled(); }); + it.each([1, 2])( + "dismisses selection actions when a button is pressed after %i clicks", + (detail) => { + const surface = createSelectionSurface(); + surface.down(); + surface.up({ detail }); + surface.flush(); + surface.onSelection.mockClear(); + surface.onDismiss.mockClear(); + + surface.down({ target: surface.buttonIcon }); + surface.up(); + surface.change(); + surface.flush(1000); + expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); + expect(surface.onSelection).not.toHaveBeenCalled(); + + surface.down(); + surface.change(); + surface.up(); + surface.flush(); + expect(surface.onSelection).toHaveBeenCalledOnce(); + }, + ); + it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => { const surface = createSelectionSurface(); surface.down({ isPrimary: false }); diff --git a/apps/web/src/lib/selectionActions.ts b/apps/web/src/lib/selectionActions.ts index 91e6c2e6a10b..5954a770aa65 100644 --- a/apps/web/src/lib/selectionActions.ts +++ b/apps/web/src/lib/selectionActions.ts @@ -101,6 +101,10 @@ export function observeSelectionActions({ }; const onSelectionStart = (event: PointerEvent) => { if (!event.isPrimary) return; + const target = event.target as Element | null; + // Buttons can preserve an existing text selection while opening a menu. + // Their release must not reopen selection actions beside the button. + if (target?.closest("button, [role=button]")) return; // A surface may consume a press for a link or terminal mouse reporting. gestureActive = event.button === 0 && !event.defaultPrevented; dismissed = !gestureActive;