diff --git a/tests/storybook-visual/new-issue-dialog-viewport.spec.ts b/tests/storybook-visual/new-issue-dialog-viewport.spec.ts new file mode 100644 index 0000000000..b5d154f0b6 --- /dev/null +++ b/tests/storybook-visual/new-issue-dialog-viewport.spec.ts @@ -0,0 +1,177 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +const STORY_ID = "product-dialogs-modals--new-issue-prefilled"; + +type ViewportCase = { + name: string; + width: number; + layoutHeight: number; + visualHeight: number; + offsetTop: number; +}; + +const VIEWPORT_CASES: ViewportCase[] = [ + { name: "mobile", width: 390, layoutHeight: 844, visualHeight: 408, offsetTop: 120 }, + { name: "tablet", width: 820, layoutHeight: 1180, visualHeight: 780, offsetTop: 120 }, + { name: "desktop with an on-screen keyboard", width: 1440, layoutHeight: 900, visualHeight: 600, offsetTop: 80 }, +]; + +async function installVisualViewport(page: Page, layoutHeight: number) { + await page.addInitScript(({ initialHeight }) => { + class TestVisualViewport extends EventTarget { + width = window.innerWidth; + height = initialHeight; + offsetLeft = 0; + offsetTop = 0; + pageLeft = 0; + pageTop = 0; + scale = 1; + } + + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: new TestVisualViewport(), + }); + }, { initialHeight: layoutHeight }); +} + +async function renderDialog(page: Page, viewport: ViewportCase) { + await page.setViewportSize({ width: viewport.width, height: viewport.layoutHeight }); + await installVisualViewport(page, viewport.layoutHeight); + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story`, { waitUntil: "load" }); + await page.waitForFunction(() => { + const body = document.body; + return body.classList.contains("sb-show-main") || body.classList.contains("sb-show-errordisplay"); + }); + expect( + await page.locator(".sb-show-errordisplay").count(), + `story ${STORY_ID} threw during render`, + ).toBe(0); + + const dialog = page.locator('[data-slot="dialog-content"]'); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("button", { name: "Create Task" })).toBeEnabled(); + return dialog; +} + +async function constrainVisualViewport(page: Page, viewport: ViewportCase) { + await page.evaluate(({ height, offsetTop }) => { + const visualViewport = window.visualViewport as VisualViewport & { + height: number; + offsetTop: number; + }; + visualViewport.height = height; + visualViewport.offsetTop = offsetTop; + visualViewport.dispatchEvent(new Event("resize")); + }, { height: viewport.visualHeight, offsetTop: viewport.offsetTop }); +} + +async function expectHitTarget(locator: Locator, visibleTop: number, visibleBottom: number) { + const result = await locator.evaluate((element, band) => { + const rect = element.getBoundingClientRect(); + const pointTarget = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + return { + top: rect.top, + bottom: rect.bottom, + hit: pointTarget === element || element.contains(pointTarget), + visibleTop: band.visibleTop, + visibleBottom: band.visibleBottom, + }; + }, { visibleTop, visibleBottom }); + + expect(result.top).toBeGreaterThanOrEqual(result.visibleTop); + expect(result.bottom).toBeLessThanOrEqual(result.visibleBottom); + expect(result.hit).toBe(true); +} + +for (const viewport of VIEWPORT_CASES) { + test(`keeps the new-task dialog inside the constrained visual viewport at ${viewport.name} width`, async ({ page }) => { + const dialog = await renderDialog(page, viewport); + const descriptionEditor = dialog.locator('.paperclip-mdxeditor-content[contenteditable="true"]'); + await expect(descriptionEditor).toBeVisible(); + await descriptionEditor.focus(); + + await constrainVisualViewport(page, viewport); + + const visibleTop = viewport.offsetTop; + const visibleBottom = viewport.offsetTop + viewport.visualHeight; + await expect.poll(async () => dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom }; + })).toEqual({ + top: viewport.offsetTop + 16, + bottom: viewport.offsetTop + viewport.visualHeight - 16, + }); + + const dialogGeometry = await dialog.evaluate((element) => { + const style = getComputedStyle(element); + return { + heightVariable: style.getPropertyValue("--new-issue-dialog-height").trim(), + rootHeightVariable: getComputedStyle(document.documentElement) + .getPropertyValue("--new-issue-dialog-height") + .trim(), + translate: style.translate, + }; + }); + expect(dialogGeometry.heightVariable).toContain(`${viewport.visualHeight}px`); + expect(dialogGeometry.heightVariable).not.toContain("100dvh"); + expect(dialogGeometry.heightVariable).not.toBe(dialogGeometry.rootHeightVariable); + expect(dialogGeometry.rootHeightVariable).toBe(""); + expect(dialogGeometry.translate).toBe("-50%"); + + const closeButton = dialog.locator("button").filter({ hasText: "×" }); + const createButton = page.getByRole("button", { name: "Create Task" }); + await expectHitTarget(closeButton, visibleTop, visibleBottom); + await expectHitTarget(createButton, visibleTop, visibleBottom); + + const scrollRegion = dialog.locator(".overflow-y-auto.overscroll-contain"); + const scrollMetrics = await scrollRegion.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + overflowY: getComputedStyle(element).overflowY, + })); + expect(scrollMetrics.overflowY).toBe("auto"); + expect(scrollMetrics.scrollHeight).toBeGreaterThanOrEqual(scrollMetrics.clientHeight); + if (viewport.width === 390) { + expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); + } + + const editorGeometry = await descriptionEditor.evaluate((element, scrollSelector) => { + const editorRect = element.getBoundingClientRect(); + const scrollRect = element.closest(scrollSelector)!.getBoundingClientRect(); + return { + editorTop: editorRect.top, + editorBottom: editorRect.bottom, + scrollTop: scrollRect.top, + scrollBottom: scrollRect.bottom, + }; + }, ".overflow-y-auto.overscroll-contain"); + expect(editorGeometry.editorTop).toBeGreaterThanOrEqual(editorGeometry.scrollTop); + expect(editorGeometry.editorBottom).toBeLessThanOrEqual(editorGeometry.scrollBottom); + + for (const name of ["CodexCoder", "Board UI"]) { + const selector = scrollRegion.getByRole("button", { name }); + await selector.scrollIntoViewIfNeeded(); + await expectHitTarget(selector, visibleTop, visibleBottom); + } + }); +} + +test("leaves unconstrained desktop positioning to the dialog primitive", async ({ page }) => { + const viewport: ViewportCase = { + name: "unconstrained desktop", + width: 1440, + layoutHeight: 900, + visualHeight: 900, + offsetTop: 0, + }; + const dialog = await renderDialog(page, viewport); + + const inlineStyle = await dialog.evaluate((element) => ({ + top: element.style.top, + height: element.style.height, + translate: element.style.translate, + })); + expect(inlineStyle).toEqual({ top: "", height: "", translate: "" }); + expect(await dialog.evaluate((element) => getComputedStyle(element).translate)).toBe("-50% -50%"); +}); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index a41c886b2a..11c4b6ba69 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,65 @@ 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(); + + expect(dialogContent?.style.top).toBe(""); + expect(dialogContent?.style.height).toBe(""); + expect(dialogContent?.style.translate).toBe(""); + + 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.getPropertyValue("--new-issue-dialog-top")).toBe( + "calc(var(--new-issue-visual-viewport-offset-top) + var(--new-issue-dialog-top-gap))", + ); + expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-height")).toBe( + "calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))", + ); + expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); + expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)"); + 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 8c88565239..82fcaefb7e 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -82,8 +82,58 @@ 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; + "--new-issue-dialog-top"?: string; + "--new-issue-dialog-height"?: 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 +465,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 +1318,44 @@ export function NewIssueDialog() { ); const currentWorkMode = workModeMetaFor(workMode); const CurrentWorkModeIcon = currentWorkMode.icon; + const dialogViewportStyle = useMemo(() => { + const dialogGeometry = { + "--new-issue-dialog-top": + "calc(var(--new-issue-visual-viewport-offset-top) + var(--new-issue-dialog-top-gap))", + "--new-issue-dialog-height": + "calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))", + }; + if (!visualViewportLayout) return dialogGeometry; + return { + ...dialogGeometry, + "--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)", + translate: "var(--pct-neg-50)", + } + : {}), + }; + }, [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 */}