fix(ui): keep new task form above mobile keyboard

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 15:28:41 +00:00
1 parent 67001ec6eb
commit 838cdbb325
3 files changed
+157 -5

No files matched your search

+62 -2
View File
@@ -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<HTMLDivElement>("div")).find((element) =>
element.className.includes("max-h-(--new-issue-dialog-height)"),
);
const descriptionInput = container.querySelector<HTMLTextAreaElement>(
'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();
+83 -3
View File
@@ -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<VisualViewportLayout | null>(() =>
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<HTMLDivElement>(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<NewIssueDialogViewportStyle>(() => {
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 (
<Dialog
@@ -1277,7 +1357,7 @@ export function NewIssueDialog() {
<DialogContent
showCloseButton={false}
aria-describedby={undefined}
style={{ "--new-issue-dialog-height": MOBILE_DIALOG_HEIGHT } as CSSProperties}
style={dialogViewportStyle}
className={cn(
"flex h-(--new-issue-dialog-height) max-h-(--new-issue-dialog-height) flex-col gap-0 overflow-hidden p-0 sm:h-auto",
expanded
@@ -1398,7 +1478,7 @@ export function NewIssueDialog() {
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div ref={dialogBodyRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{/* Title */}
<div className="px-4 pt-4 pb-2">
<IssueTitleTextarea
+12
View File
@@ -195,6 +195,18 @@
--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-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
behind enableClassicTaskInterface).