diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index a41c886b2a5..7336ba234b5 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -318,10 +318,14 @@ function renderDialog(container: HTMLDivElement) { describe("NewIssueDialog", () => { let container: HTMLDivElement; let originalResizeObserver: typeof ResizeObserver | undefined; + let originalVisualViewportDescriptor: PropertyDescriptor | undefined; + let originalInnerHeightDescriptor: PropertyDescriptor | undefined; beforeEach(() => { vi.useRealTimers(); originalResizeObserver = globalThis.ResizeObserver; + originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport"); + originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight"); globalThis.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} @@ -366,6 +370,16 @@ describe("NewIssueDialog", () => { afterEach(() => { globalThis.ResizeObserver = originalResizeObserver!; + if (originalVisualViewportDescriptor) { + Object.defineProperty(window, "visualViewport", originalVisualViewportDescriptor); + } else { + Reflect.deleteProperty(window, "visualViewport"); + } + if (originalInnerHeightDescriptor) { + Object.defineProperty(window, "innerHeight", originalInnerHeightDescriptor); + } else { + Reflect.deleteProperty(window, "innerHeight"); + } document.body.innerHTML = ""; }); @@ -1095,8 +1109,6 @@ describe("NewIssueDialog", () => { ); expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)"); expect(dialogContent?.className).toContain("overflow-hidden"); - expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-top)"); - expect(dialogContent?.getAttribute("style")).toContain("env(safe-area-inset-bottom)"); const titleInput = container.querySelector('textarea[placeholder="Task title"]'); const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); @@ -1111,6 +1123,54 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("tracks the mobile visual viewport and keeps the focused editor visible above the keyboard", async () => { + const visualViewport = new EventTarget() as EventTarget & { + height: number; + offsetTop: number; + }; + visualViewport.height = 844; + visualViewport.offsetTop = 0; + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: visualViewport, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 844, + }); + + const { root } = renderDialog(container); + await flush(); + + const dialogContent = Array.from(container.querySelectorAll("div")).find((element) => + element.className.includes("max-h-(--new-issue-dialog-height)"), + ); + const descriptionInput = container.querySelector( + 'textarea[aria-label="Add description..."]', + ); + const scrollIntoView = vi.fn(); + Object.defineProperty(descriptionInput!, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + descriptionInput?.focus(); + + visualViewport.height = 420; + visualViewport.offsetTop = 24; + await act(async () => { + visualViewport.dispatchEvent(new Event("resize")); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px"); + expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-offset-top")).toBe("24px"); + expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); + expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + + act(() => root.unmount()); + }); + it("hides the priority chip and mobile priority option (PAP-411)", async () => { const { root } = renderDialog(container); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 8c88565239c..4277ee90fb8 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -82,8 +82,56 @@ import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSe const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; -const MOBILE_DIALOG_HEIGHT = "calc(100dvh - max(1rem, env(safe-area-inset-top)) - max(1rem, env(safe-area-inset-bottom)))"; +type VisualViewportLayout = { + height: number; + offsetTop: number; + constrained: boolean; +}; + +type NewIssueDialogViewportStyle = CSSProperties & { + "--new-issue-visual-viewport-height"?: string; + "--new-issue-visual-viewport-offset-top"?: string; +}; + +function readVisualViewportLayout(): VisualViewportLayout | null { + if (typeof window === "undefined" || !window.visualViewport) return null; + const { height, offsetTop } = window.visualViewport; + return { + height, + offsetTop, + constrained: height < window.innerHeight, + }; +} + +function useVisualViewportLayout(enabled: boolean) { + const [layout, setLayout] = useState(() => + enabled ? readVisualViewportLayout() : null, + ); + + useEffect(() => { + if (!enabled) { + setLayout(null); + return; + } + + const viewport = window.visualViewport; + if (!viewport) return; + + const updateLayout = () => setLayout(readVisualViewportLayout()); + updateLayout(); + viewport.addEventListener("resize", updateLayout); + viewport.addEventListener("scroll", updateLayout); + window.addEventListener("resize", updateLayout); + return () => { + viewport.removeEventListener("resize", updateLayout); + viewport.removeEventListener("scroll", updateLayout); + window.removeEventListener("resize", updateLayout); + }; + }, [enabled]); + + return layout; +} interface IssueDraft { title: string; @@ -415,6 +463,8 @@ const IssueDescriptionEditor = memo(function IssueDescriptionEditor({ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); + const visualViewportLayout = useVisualViewportLayout(newIssueOpen); + const dialogBodyRef = useRef(null); const { companies, selectedCompanyId, selectedCompany } = useCompany(); const workModeOptions = useMemo(() => workModeMetaList(), []); const statuses = useMemo(() => buildStatusOptions(), []); @@ -1266,6 +1316,36 @@ export function NewIssueDialog() { ); const currentWorkMode = workModeMetaFor(workMode); const CurrentWorkModeIcon = currentWorkMode.icon; + const dialogViewportStyle = useMemo(() => { + if (!visualViewportLayout) return {}; + return { + "--new-issue-visual-viewport-height": `${visualViewportLayout.height}px`, + "--new-issue-visual-viewport-offset-top": `${visualViewportLayout.offsetTop}px`, + ...(visualViewportLayout.constrained + ? { + top: "var(--new-issue-dialog-top)", + height: "var(--new-issue-dialog-height)", + } + : {}), + }; + }, [visualViewportLayout]); + + useEffect(() => { + if (!visualViewportLayout?.constrained) return; + const focusedElement = document.activeElement; + if ( + !(focusedElement instanceof HTMLElement) + || !dialogBodyRef.current?.contains(focusedElement) + || typeof focusedElement.scrollIntoView !== "function" + ) { + return; + } + + const frame = window.requestAnimationFrame(() => { + focusedElement.scrollIntoView({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [visualViewportLayout]); return ( -
+
{/* Title */}