FN-5837: keep message composer visible when mobile keyboard opens

Ensure the message composer textarea scrolls into view during mobile keyboard interactions.

- extract a reusable textarea scroll helper guarded for missing scrollIntoView support
- subscribe to visualViewport resize for all compose modes (not only replies) and clean up listener with stable callback
- trigger textarea scrolling on focus to proactively keep the composer visible when soft keyboards open
- add tests covering visualViewport resize for new compose, existing reply flow, and focus-triggered scroll behavior

Files changed:
 .../dashboard/app/components/MessageComposer.tsx   | 20 ++++----
 .../components/__tests__/MessageComposer.test.tsx  | 56 ++++++++++++++++++++++
 2 files changed, 68 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-5837

Fusion-Task-Lineage: c9fb76a2-6c5a-4d5c-b8ad-8250ed470825
This commit is contained in:
gsxdsm
2026-06-01 10:17:52 -07:00
parent 130f6f1e9a
commit 1610cd5757
2 changed files with 68 additions and 8 deletions

View File

@@ -111,6 +111,13 @@ export function MessageComposer({
setToType("agent");
}, []);
const scrollTextareaIntoView = useCallback(() => {
if (typeof textareaRef.current?.scrollIntoView !== "function") {
return;
}
textareaRef.current.scrollIntoView({ block: "center", behavior: "auto" });
}, []);
useEffect(() => {
if (!replyContext) {
return;
@@ -120,19 +127,15 @@ export function MessageComposer({
}, [replyContext]);
useEffect(() => {
if (!replyContext || typeof window === "undefined" || window.visualViewport == null) {
if (typeof window === "undefined" || window.visualViewport == null) {
return;
}
const handleVisualViewportResize = () => {
textareaRef.current?.scrollIntoView({ block: "center", behavior: "auto" });
};
window.visualViewport.addEventListener("resize", handleVisualViewportResize);
window.visualViewport.addEventListener("resize", scrollTextareaIntoView);
return () => {
window.visualViewport?.removeEventListener("resize", handleVisualViewportResize);
window.visualViewport?.removeEventListener("resize", scrollTextareaIntoView);
};
}, [replyContext]);
}, [scrollTextareaIntoView]);
return (
<div className="message-composer" data-testid="message-composer">
@@ -207,6 +210,7 @@ export function MessageComposer({
placeholder="Type your message…"
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={scrollTextareaIntoView}
maxLength={MAX_CONTENT_LENGTH}
data-testid="message-composer-content"
/>

View File

@@ -193,6 +193,44 @@ describe("MessageComposer", () => {
expect(document.activeElement).toBe(screen.getByTestId("message-composer-content"));
});
it("scrolls textarea into view on visualViewport resize for a new compose", () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
let resizeHandler: (() => void) | undefined;
addEventListener.mockImplementation((event: string, handler: () => void) => {
if (event === "resize") {
resizeHandler = handler;
}
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
addEventListener,
removeEventListener,
},
writable: true,
});
if (!("scrollIntoView" in HTMLElement.prototype)) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => undefined,
writable: true,
});
}
const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => undefined);
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
expect(addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
resizeHandler?.();
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
scrollIntoViewSpy.mockRestore();
});
it("scrolls textarea into view on visualViewport resize when replying", () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
@@ -237,6 +275,24 @@ describe("MessageComposer", () => {
scrollIntoViewSpy.mockRestore();
});
it("scrolls textarea into view on focus", () => {
if (!("scrollIntoView" in HTMLElement.prototype)) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => undefined,
writable: true,
});
}
const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => undefined);
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
fireEvent.focus(screen.getByTestId("message-composer-content"));
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
scrollIntoViewSpy.mockRestore();
});
it("does not throw when visualViewport is unavailable", () => {
Object.defineProperty(window, "visualViewport", {
configurable: true,