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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user