feat(FN-830): add mobile keyboard-aware terminal positioning
- Implement visual viewport API to reposition terminal above on-screen keyboard - Add smooth CSS transition for terminal modal height changes on mobile - Add comprehensive regression tests for keyboard overlap and resize behavior - Remove stale test files (NewTaskModal, TaskForm) and clean up ActivityLogModal tests - Document mobile keyboard handling approach in dashboard README
This commit is contained in:
@@ -7,6 +7,28 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
||||
import type { FitAddon } from "@xterm/addon-fit";
|
||||
|
||||
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const hasTouchScreen =
|
||||
"ontouchstart" in window || (navigator as any).maxTouchPoints > 0;
|
||||
const isNarrow = window.innerWidth <= 768;
|
||||
return hasTouchScreen && isNarrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute how many CSS pixels the virtual keyboard covers from the bottom
|
||||
* of the layout viewport. Returns 0 on desktop or when visualViewport is
|
||||
* unavailable.
|
||||
*/
|
||||
function getKeyboardOverlap(): number {
|
||||
if (typeof window === "undefined" || !window.visualViewport) return 0;
|
||||
const vv = window.visualViewport;
|
||||
// The keyboard pushes the visual viewport up so that its bottom edge
|
||||
// no longer aligns with the layout viewport bottom.
|
||||
return Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||
}
|
||||
|
||||
interface TerminalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -33,6 +55,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const [xtermReady, setXtermReady] = useState(false);
|
||||
const [openGeneration, setOpenGeneration] = useState(0);
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
@@ -48,6 +71,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Track virtual keyboard overlap on mobile so the terminal entry area
|
||||
// stays visible above the keyboard. On desktop this is a no-op.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isMobileDevice()) return;
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
const update = () => setKeyboardOverlap(getKeyboardOverlap());
|
||||
|
||||
update(); // initial measurement
|
||||
vv.addEventListener("resize", update);
|
||||
vv.addEventListener("scroll", update);
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener("resize", update);
|
||||
vv.removeEventListener("scroll", update);
|
||||
setKeyboardOverlap(0);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Use the session management hook
|
||||
const {
|
||||
tabs,
|
||||
@@ -378,7 +422,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
onClick={handleOverlayClick}
|
||||
data-testid="terminal-modal-overlay"
|
||||
>
|
||||
<div className="modal terminal-modal" data-testid="terminal-modal">
|
||||
<div
|
||||
className="modal terminal-modal"
|
||||
data-testid="terminal-modal"
|
||||
style={
|
||||
keyboardOverlap > 0
|
||||
? { "--keyboard-overlap": `${keyboardOverlap}px` } as React.CSSProperties
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Header — on mobile (≤768px) flex-wrap stacks tabs and actions on separate rows;
|
||||
.terminal-title is hidden; action button labels are hidden (icons only) */}
|
||||
<div className="terminal-header">
|
||||
|
||||
@@ -819,3 +819,298 @@ describe("TerminalModal — mobile layout contract", () => {
|
||||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Virtual keyboard overlap handling ---
|
||||
describe("TerminalModal — virtual keyboard overlap handling", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSendInput = vi.fn();
|
||||
const mockResize = vi.fn();
|
||||
const mockReconnect = vi.fn();
|
||||
|
||||
const createMockTerminalState = (overrides = {}) => ({
|
||||
connectionStatus: "disconnected" as const,
|
||||
sendInput: mockSendInput,
|
||||
resize: mockResize,
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
onConnect: vi.fn(() => vi.fn()),
|
||||
onScrollback: vi.fn(() => vi.fn()),
|
||||
reconnect: mockReconnect,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultTab = {
|
||||
id: "tab-1",
|
||||
sessionId: "test-session-123",
|
||||
title: "bash",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const defaultSessionState = {
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
isReady: true,
|
||||
createTab: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
restartActiveTab: vi.fn(),
|
||||
};
|
||||
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedInnerWidth: typeof window.innerWidth;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||||
|
||||
// Stash originals
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore originals
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: savedInnerWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: savedOntouchstart,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: simulate a mobile device with a visualViewport.
|
||||
* The resize/scroll callbacks are captured so tests can fire them.
|
||||
*/
|
||||
function simulateMobileDevice(overlapPx: number) {
|
||||
// Touch device
|
||||
(window as any).ontouchstart = null; // truthy — "ontouchstart" in window → true
|
||||
|
||||
// Narrow viewport
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 375,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// visualViewport mock
|
||||
const listeners: Record<string, Array<() => void>> = {
|
||||
resize: [],
|
||||
scroll: [],
|
||||
};
|
||||
|
||||
const vvHeight = 300; // viewport shrunk by keyboard
|
||||
const vvOffsetTop = overlapPx > 0 ? 0 : 0; // typically 0 on modern mobile
|
||||
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: vvOffsetTop,
|
||||
offsetLeft: 0,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
if (listeners[event]) listeners[event].push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Override innerHeight to simulate keyboard overlap
|
||||
// keyboardOverlap = window.innerHeight - vv.offsetTop - vv.height
|
||||
// For overlapPx > 0: window.innerHeight = vv.offsetTop + vv.height + overlapPx
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: vvOffsetTop + vvHeight + overlapPx,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
it("does not apply --keyboard-overlap when not on a mobile device", async () => {
|
||||
// Desktop: no touch, wide viewport
|
||||
delete (window as any).ontouchstart;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 1440,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
// No --keyboard-overlap should be set (style should be undefined/empty)
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies --keyboard-overlap CSS variable when virtual keyboard is open on mobile", async () => {
|
||||
const { listeners } = simulateMobileDevice(250); // 250px keyboard overlap
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
const overlap = modal.style.getPropertyValue("--keyboard-overlap");
|
||||
expect(overlap).toBe("250px");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates --keyboard-overlap when keyboard height changes", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// Simulate keyboard shrinking (user swiped down partially)
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 300 + 0 + 100, // keyboardOverlap becomes 100
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("100px");
|
||||
});
|
||||
});
|
||||
|
||||
it("removes --keyboard-overlap when keyboard closes", async () => {
|
||||
const { listeners } = simulateMobileDevice(250);
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||||
});
|
||||
|
||||
// Keyboard closes → overlap becomes 0
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 300 + 0 + 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
// When overlap is 0, the style prop should be undefined (no CSS variable set)
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears overlap when modal closes", async () => {
|
||||
const { listeners } = simulateMobileDevice(250);
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||||
});
|
||||
|
||||
// Close the modal
|
||||
act(() => {
|
||||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||||
});
|
||||
|
||||
// Modal is no longer rendered
|
||||
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back gracefully when visualViewport is unavailable", async () => {
|
||||
// Mobile device but no visualViewport API (older browser)
|
||||
(window as any).ontouchstart = null;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 375,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
// No keyboard overlap applied since visualViewport is unavailable
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("registers and cleans up visualViewport listeners on mobile", async () => {
|
||||
const { mockVV } = simulateMobileDevice(250);
|
||||
|
||||
const { unmount } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
expect(mockVV.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||||
});
|
||||
|
||||
const resizeCalls = mockVV.addEventListener.mock.calls.filter(
|
||||
(c: any[]) => c[0] === "resize",
|
||||
);
|
||||
const scrollCalls = mockVV.addEventListener.mock.calls.filter(
|
||||
(c: any[]) => c[0] === "scroll",
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
// Cleanup should remove both listeners
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeCalls[0][1]);
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollCalls[0][1]);
|
||||
});
|
||||
|
||||
it("zero overlap on mobile with no keyboard does not set CSS variable", async () => {
|
||||
simulateMobileDevice(0); // no keyboard
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7420,6 +7420,14 @@ body {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* When a virtual keyboard is open, shrink the modal from the bottom so
|
||||
the terminal entry area (status bar, command prompt) remains visible
|
||||
above the keyboard. The JS component sets --keyboard-overlap to the
|
||||
number of pixels the keyboard covers. */
|
||||
.terminal-modal[style*="--keyboard-overlap"] {
|
||||
max-height: calc(100dvh - var(--keyboard-overlap, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user