feat(FN-928): scroll terminal into view when mobile keyboard opens

- Detect mobile viewport resize in TerminalModal to infer keyboard open/close
- Use visualViewport API and smooth scroll to keep terminal visible on mobile
- Add tests for scroll behavior with mobile keyboard open/close scenarios
This commit is contained in:
gsxdsm
2026-04-04 21:21:32 -07:00
parent 035efb6e66
commit 8ad3a121b1
2 changed files with 56 additions and 1 deletions

View File

@@ -62,6 +62,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const hasInitialCommandRun = useRef<string | false>(false);
@@ -83,7 +84,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const vv = window.visualViewport;
if (!vv) return;
const update = () => setKeyboardOverlap(getKeyboardOverlap());
const update = () => {
const overlap = getKeyboardOverlap();
setKeyboardOverlap(overlap);
// Scroll the modal so the status bar (bottom edge) stays visible
// when the virtual keyboard pushes the viewport up.
if (overlap > 0 && modalRef.current?.scrollIntoView) {
modalRef.current.scrollIntoView({ block: "end", behavior: "smooth" });
}
};
update(); // initial measurement
vv.addEventListener("resize", update);
@@ -475,6 +484,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
data-testid="terminal-modal-overlay"
>
<div
ref={modalRef}
className="modal terminal-modal"
data-testid="terminal-modal"
style={

View File

@@ -1694,6 +1694,51 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
});
it("scrolls modal into view when keyboard opens on mobile", async () => {
const scrollIntoViewSpy = vi.fn();
const { listeners } = simulateMobileDevice(250);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Attach the spy to the rendered modal element
const modal = screen.getByTestId("terminal-modal");
modal.scrollIntoView = scrollIntoViewSpy;
// Trigger a resize event to re-run the update callback
act(() => {
for (const cb of listeners.resize) cb();
});
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "end", behavior: "smooth" });
});
it("does not scroll modal when keyboard overlap is zero", async () => {
const scrollIntoViewSpy = vi.fn();
const { listeners } = simulateMobileDevice(0); // no overlap
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
const modal = screen.getByTestId("terminal-modal");
modal.scrollIntoView = scrollIntoViewSpy;
// Trigger a resize event
act(() => {
for (const cb of listeners.resize) cb();
});
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
});
});
// --- Close/reopen regression tests ---