diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 0eb67618..0d5f1cad 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -753,6 +753,19 @@ describe("MarkdownEditor", () => { }); }); + it("marks the autocomplete portal as floating UI for modal pointer handling", async () => { + const handleChange = vi.fn(); + const { option, root } = await openMentionMenuFor(handleChange); + + const menu = option.closest("[data-paperclip-floating-ui]"); + expect(menu).toBeTruthy(); + expect(menu?.className).toContain("pointer-events-auto"); + + await act(async () => { + root.unmount(); + }); + }); + it("does not preventDefault on touchstart so the mention menu can scroll on mobile", async () => { const handleChange = vi.fn(); const { option, root } = await openMentionMenuFor(handleChange); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 0a0c5067..78ae521a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1241,7 +1241,8 @@ export const MarkdownEditor = forwardRef {mentionActive && filteredMentions.length > 0 && mentionMenuPosition && createPortal(
({ closeNewIssue: vi.fn(), })); +const dialogContentState = vi.hoisted(() => ({ + onPointerDownOutside: null as null | ((event: { + detail: { originalEvent: { target: EventTarget | null } }; + preventDefault: () => void; + }) => void), +})); + const companyState = vi.hoisted(() => ({ companies: [ { @@ -186,13 +193,16 @@ vi.mock("@/components/ui/dialog", () => ({ children, showCloseButton: _showCloseButton, onEscapeKeyDown: _onEscapeKeyDown, - onPointerDownOutside: _onPointerDownOutside, + onPointerDownOutside, ...props }: ComponentProps<"div"> & { showCloseButton?: boolean; onEscapeKeyDown?: (event: unknown) => void; onPointerDownOutside?: (event: unknown) => void; - }) =>
{children}
, + }) => { + dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside; + return
{children}
; + }, })); vi.mock("@/components/ui/button", () => ({ @@ -285,6 +295,7 @@ describe("NewIssueDialog", () => { dialogState.newIssueOpen = true; dialogState.newIssueDefaults = {}; dialogState.closeNewIssue.mockReset(); + dialogContentState.onPointerDownOutside = null; toastState.pushToast.mockReset(); mockIssuesApi.create.mockReset(); mockIssuesApi.upsertDocument.mockReset(); @@ -729,6 +740,27 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("allows editor autocomplete portal pointer events inside the modal", async () => { + const { root } = renderDialog(container); + await flush(); + + const menu = document.createElement("div"); + menu.setAttribute("data-paperclip-floating-ui", ""); + const option = document.createElement("button"); + menu.appendChild(option); + document.body.appendChild(menu); + const preventDefault = vi.fn(); + + dialogContentState.onPointerDownOutside?.({ + detail: { originalEvent: { target: option } }, + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); + it("warns when a sub-issue stops matching the parent workspace", async () => { mockProjectsApi.list.mockResolvedValue([ { diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 1ff5e772..4de20971 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1221,12 +1221,12 @@ export function NewIssueDialog() { } // Radix Dialog's modal DismissableLayer calls preventDefault() on // pointerdown events that originate outside the Dialog DOM tree. - // Popover portals render at the body level (outside the Dialog), so - // touch events on popover content get their default prevented — which - // kills scroll gesture recognition on mobile. Telling Radix "this - // event is handled" skips that preventDefault, restoring touch scroll. + // Popover and editor autocomplete portals render at the body level + // (outside the Dialog), so touch/click events on their content get + // their default prevented. Telling Radix "this event is handled" skips + // that preventDefault, restoring popover scroll and autocomplete taps. const target = event.detail.originalEvent.target as HTMLElement | null; - if (target?.closest("[data-radix-popper-content-wrapper]")) { + if (target?.closest("[data-radix-popper-content-wrapper], [data-paperclip-floating-ui]")) { event.preventDefault(); } }}