fix: keep mobile composer above soft keyboard

This commit is contained in:
chengyongru
2026-06-15 02:54:37 +08:00
committed by Xubin Ren
parent 946ed6690a
commit 052e7f1559
4 changed files with 235 additions and 7 deletions
+49
View File
@@ -134,6 +134,28 @@ function mockBlobUrls() {
});
}
function stubVisualViewport({
height,
offsetTop = 0,
}: {
height: number;
offsetTop?: number;
}) {
const target = new EventTarget();
vi.stubGlobal("visualViewport", {
width: 390,
height,
offsetTop,
offsetLeft: 0,
pageTop: offsetTop,
pageLeft: 0,
scale: 1,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target),
} as unknown as VisualViewport);
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
@@ -1124,6 +1146,33 @@ describe("ThreadComposer", () => {
});
});
it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => {
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
rect({ top: 120, bottom: 220, width: 390, height: 100 }),
);
Object.defineProperty(window, "innerHeight", {
value: 800,
configurable: true,
});
stubVisualViewport({ height: 300 });
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
slashCommands={COMMANDS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
await waitFor(() => {
const palette = screen.getByRole("listbox", { name: "Slash commands" });
expect(palette.className).toContain("bottom-full");
expect(palette).toHaveStyle({ maxHeight: "112px" });
});
});
it("dismisses the slash command palette on outside click", () => {
render(
<div>
+93
View File
@@ -29,6 +29,59 @@ interface ResizeObserverInstance {
disconnect: ReturnType<typeof vi.fn>;
}
function stubVisualViewport({
height,
innerHeight,
offsetTop = 0,
}: {
height: number;
innerHeight: number;
offsetTop?: number;
}) {
const originalInnerHeight = window.innerHeight;
const originalVisualViewport = window.visualViewport;
const target = new EventTarget();
const viewport = {
width: 390,
height,
offsetTop,
offsetLeft: 0,
pageTop: offsetTop,
pageLeft: 0,
scale: 1,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target),
} as unknown as VisualViewport;
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: innerHeight,
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: viewport,
});
return {
viewport,
restore: () => {
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: originalInnerHeight,
});
if (originalVisualViewport) {
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: originalVisualViewport,
});
} else {
Reflect.deleteProperty(window, "visualViewport");
}
},
};
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
@@ -129,6 +182,46 @@ describe("ThreadViewport", () => {
}
});
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<textarea aria-label="Message input" />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
const input = screen.getByLabelText("Message input");
act(() => {
input.focus();
fireEvent.focusIn(input);
});
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button.parentElement).toHaveStyle({ bottom: "512px" });
act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize"));
});
expect(scroller).toHaveStyle({ bottom: "320px" });
} finally {
visualViewport.restore();
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport