From 40696aaf65f7e8705fa202f08c0ccb3e50b4cbf8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 01:25:26 +0000 Subject: [PATCH] fix(ui): resolve new-task visual viewport geometry Co-Authored-By: Paperclip --- .../new-issue-dialog-viewport.spec.ts | 169 ++++++++++++++++++ ui/src/components/NewIssueDialog.test.tsx | 11 ++ ui/src/components/NewIssueDialog.tsx | 12 +- ui/src/index.css | 10 +- 4 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 tests/storybook-visual/new-issue-dialog-viewport.spec.ts 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 00000000000..367fa977ee5 --- /dev/null +++ b/tests/storybook-visual/new-issue-dialog-viewport.spec.ts @@ -0,0 +1,169 @@ +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" }); + + const dialog = page.locator('[data-slot="dialog-content"]'); + await expect(dialog).toBeVisible(); + 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 7336ba234b5..11c4b6ba696 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -1155,6 +1155,10 @@ describe("NewIssueDialog", () => { }); 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 () => { @@ -1164,8 +1168,15 @@ describe("NewIssueDialog", () => { 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()); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 4277ee90fb8..82fcaefb7e9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -92,6 +92,8 @@ type VisualViewportLayout = { 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 { @@ -1317,14 +1319,22 @@ export function NewIssueDialog() { const currentWorkMode = workModeMetaFor(workMode); const CurrentWorkModeIcon = currentWorkMode.icon; const dialogViewportStyle = useMemo(() => { - if (!visualViewportLayout) return {}; + 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)", } : {}), }; diff --git a/ui/src/index.css b/ui/src/index.css index 3c5771a1d3b..69db1e889bc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -195,17 +195,13 @@ --sz-folder-sheet-max: 80dvh; --sz-tweak-panel-max: 70vh; /* Task Chat Redesign dev tweak panel scroll cap. */ - /* New-task dialog geometry follows the Visual Viewport when a mobile - software keyboard reduces or offsets the visible browser area. Runtime - measurements override the first two variables on the dialog element. */ + /* New-task dialog geometry inputs and gap tokens. The derived top/height + calculations live on the dialog element so runtime Visual Viewport + overrides participate in computed-value resolution. */ --new-issue-visual-viewport-height: 100dvh; --new-issue-visual-viewport-offset-top: 0px; --new-issue-dialog-top-gap: max(1rem, env(safe-area-inset-top)); --new-issue-dialog-bottom-gap: max(1rem, env(safe-area-inset-bottom)); - --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) - ); /* ──────────────────────────────────────────────────────────────────────── Motion tokens — chat-style task thread (default; classic legacy view sits