mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(ui): keep new task form above mobile keyboard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
67001ec6eb
commit
838cdbb325
3 files changed
+157
-5
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user