fix(ui): resolve new-task visual viewport geometry

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 15:28:41 +00:00
1 parent 838cdbb325
commit 40696aaf65
4 files changed
+194 -8

No files matched your search

@@ -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%");
});
+11
View File
@@ -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());
+11 -1
View File
@@ -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<NewIssueDialogViewportStyle>(() => {
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)",
}
: {}),
};
+3 -7
View File
@@ -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