feat(FN-2661): add keyboard-aware mobile chat layouts
- Add a shared useMobileKeyboard hook that tracks visualViewport overlap on mobile browsers - Apply keyboard overlap CSS variables to QuickChatFAB so fullscreen mobile panels resize above the virtual keyboard - Apply the same keyboard-aware sizing to ChatView mobile thread layout and auto-scroll messages when keyboard opens - Expand ChatView, QuickChatFAB, and hook test coverage for mobile keyboard detection and viewport-resize behavior
This commit is contained in:
@@ -768,6 +768,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-thread[style*="--keyboard-overlap"] {
|
||||
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
||||
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
|
||||
}
|
||||
|
||||
/* On mobile, the "New Chat" affordance moves to a full-width pinned
|
||||
footer. The desktop top-header button is hidden to free the screen
|
||||
space for the search field and session list. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -28,6 +28,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -512,6 +513,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
const { keyboardOverlap, viewportHeight } = useMobileKeyboard({
|
||||
enabled: isMobile && !!activeSession,
|
||||
});
|
||||
|
||||
const threadKeyboardStyle: CSSProperties =
|
||||
keyboardOverlap > 0
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||
@@ -560,6 +572,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamingText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyboardOverlap <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, [keyboardOverlap]);
|
||||
|
||||
// Close context menu on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
@@ -1206,7 +1231,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
)}
|
||||
|
||||
{/* Thread */}
|
||||
<div className="chat-thread">
|
||||
<div className="chat-thread" style={threadKeyboardStyle}>
|
||||
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
||||
{(activeSession || !isMobile) && (
|
||||
<div className="chat-thread-header">
|
||||
|
||||
@@ -370,10 +370,10 @@
|
||||
top: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important;
|
||||
height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) !important;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
z-index: 1100;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -19,6 +20,7 @@ import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } fr
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
interface QuickChatFABProps {
|
||||
projectId?: string;
|
||||
@@ -609,6 +611,16 @@ export function QuickChatFAB({
|
||||
}
|
||||
: setInternalOpen;
|
||||
|
||||
const { keyboardOverlap, viewportHeight } = useMobileKeyboard({ enabled: isOpen });
|
||||
|
||||
const keyboardPanelStyle: CSSProperties =
|
||||
keyboardOverlap > 0
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
@@ -1205,6 +1217,7 @@ export function QuickChatFAB({
|
||||
right: position.x,
|
||||
bottom: panelY,
|
||||
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
|
||||
...keyboardPanelStyle,
|
||||
}}
|
||||
>
|
||||
{shouldApplyDesktopPanelSize && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* new chat dialog, and input handling.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
@@ -1840,6 +1840,72 @@ describe("ChatView sidebar structure", () => {
|
||||
});
|
||||
|
||||
describe("ChatView mobile behavior", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedInnerHeight: number;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
|
||||
beforeEach(() => {
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerHeight = window.innerHeight;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: savedInnerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: savedOntouchstart,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function mockMobileVisualViewport({
|
||||
innerHeight,
|
||||
vvHeight,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
vvHeight: number;
|
||||
}) {
|
||||
(window as any).ontouchstart = null;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: innerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const listeners: Record<string, Array<() => void>> = {
|
||||
resize: [],
|
||||
scroll: [],
|
||||
};
|
||||
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event]?.push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
function ensureMatchMedia() {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
@@ -1940,6 +2006,135 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
expect(thread).toBeInTheDocument();
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 844,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: scrolls messages container to bottom when keyboard opens", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 800,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
expect(messagesContainer).toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(messagesContainer, "scrollHeight", {
|
||||
value: 900,
|
||||
configurable: true,
|
||||
});
|
||||
messagesContainer.scrollTop = 0;
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(messagesContainer.scrollTop).toBe(900);
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: does not subscribe to keyboard tracking without active session", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: null,
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVV.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("desktop mode: renders thread header even without active session (shows empty state)", () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
try {
|
||||
@@ -2053,4 +2248,8 @@ describe("ChatView mobile CSS contract", () => {
|
||||
it("mobile does not override assistant render toggle visibility", () => {
|
||||
expect(mobileRuleNotContains(".chat-message-render-toggle", "display: inline-flex")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile includes keyboard-aware chat-thread height rule", () => {
|
||||
expect(css).toMatch(/\.chat-thread\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]*--vv-height/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { Agent, ChatSession } from "../../api";
|
||||
import * as apiModule from "../../api";
|
||||
import { useAgents } from "../../hooks/useAgents";
|
||||
@@ -1525,6 +1525,164 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile keyboard overlap", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedInnerWidth: number;
|
||||
let savedInnerHeight: number;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
|
||||
beforeEach(() => {
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedInnerHeight = window.innerHeight;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: savedInnerWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: savedInnerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: savedOntouchstart,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function mockMobileVisualViewport({
|
||||
innerHeight,
|
||||
vvHeight,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
vvHeight: number;
|
||||
}) {
|
||||
(window as any).ontouchstart = null;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 375,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: innerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const listeners: Record<string, Array<() => void>> = {
|
||||
resize: [],
|
||||
scroll: [],
|
||||
};
|
||||
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event]?.push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const panel = await screen.findByTestId("quick-chat-panel");
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears keyboard overlap CSS variable when keyboard closes", async () => {
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const panel = await screen.findByTestId("quick-chat-panel");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("200px");
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not subscribe to keyboard tracking while panel is closed", async () => {
|
||||
const { mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} open={false} onOpenChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-chat-panel")).toBeNull();
|
||||
});
|
||||
|
||||
expect(mockVV.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Drag-related tests
|
||||
describe("draggable behavior", () => {
|
||||
const localStorageMock = {
|
||||
|
||||
216
packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts
Normal file
216
packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { _resetInitialViewportHeight, useMobileKeyboard } from "../useMobileKeyboard";
|
||||
|
||||
describe("useMobileKeyboard", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedInnerWidth: number;
|
||||
let savedInnerHeight: number;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
let savedMaxTouchPoints: number;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedInnerHeight = window.innerHeight;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
savedMaxTouchPoints = navigator.maxTouchPoints;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: savedVisualViewport,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: savedInnerWidth,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: savedInnerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "ontouchstart", {
|
||||
value: savedOntouchstart,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "maxTouchPoints", {
|
||||
value: savedMaxTouchPoints,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setupMobileVisualViewport({
|
||||
innerHeight,
|
||||
vvHeight,
|
||||
vvOffsetTop = 0,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
vvHeight: number;
|
||||
vvOffsetTop?: number;
|
||||
}) {
|
||||
(window as any).ontouchstart = null;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", {
|
||||
value: 5,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 375,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: innerHeight,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const listeners: Record<string, Array<() => void>> = {
|
||||
resize: [],
|
||||
scroll: [],
|
||||
};
|
||||
|
||||
const mockVV = {
|
||||
width: 375,
|
||||
height: vvHeight,
|
||||
offsetTop: vvOffsetTop,
|
||||
offsetLeft: 0,
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event]?.push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
value: mockVV,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return { listeners, mockVV };
|
||||
}
|
||||
|
||||
it("keeps keyboardOverlap at 0 when not on mobile", async () => {
|
||||
delete (window as any).ontouchstart;
|
||||
Object.defineProperty(navigator, "maxTouchPoints", {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 1280,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(0);
|
||||
expect(result.current.viewportHeight).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("updates overlap when visualViewport resize fires on mobile", async () => {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 800,
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(200);
|
||||
expect(result.current.viewportHeight).toBe(600);
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 800,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 700,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(100);
|
||||
expect(result.current.viewportHeight).toBe(700);
|
||||
});
|
||||
});
|
||||
|
||||
it("unsubscribes listeners and resets state when disabled", async () => {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 760,
|
||||
vvHeight: 600,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useMobileKeyboard({ enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(160);
|
||||
expect(result.current.viewportHeight).toBe(600);
|
||||
});
|
||||
|
||||
const resizeListener = listeners.resize[0];
|
||||
const scrollListener = listeners.scroll[0];
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(0);
|
||||
expect(result.current.viewportHeight).toBeNull();
|
||||
});
|
||||
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeListener);
|
||||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollListener);
|
||||
});
|
||||
|
||||
it("uses iOS Safari fallback when innerHeight shrinks with visualViewport", async () => {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(0);
|
||||
expect(result.current.viewportHeight).toBeNull();
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(324);
|
||||
expect(result.current.viewportHeight).toBe(520);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
packages/dashboard/app/hooks/useMobileKeyboard.ts
Normal file
93
packages/dashboard/app/hooks/useMobileKeyboard.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** 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.maxTouchPoints > 0;
|
||||
const isNarrow = window.innerWidth <= 768;
|
||||
return hasTouchScreen && isNarrow;
|
||||
}
|
||||
|
||||
/** Cached initial viewport height before any keyboard opened. */
|
||||
let _initialViewportHeight: number | null = null;
|
||||
|
||||
/** Returns the viewport height at page load (before any keyboard opens). */
|
||||
function getInitialViewportHeight(): number {
|
||||
if (_initialViewportHeight === null) {
|
||||
_initialViewportHeight = window.innerHeight;
|
||||
}
|
||||
return _initialViewportHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Strategy:
|
||||
* - Primary: window.innerHeight - vv.offsetTop - vv.height
|
||||
* Works on Chrome Android where window.innerHeight stays at full height.
|
||||
* - Fallback: initial viewport height - vv.height - vv.offsetTop
|
||||
* Works on iOS Safari where window.innerHeight shrinks with the keyboard.
|
||||
*/
|
||||
function getKeyboardOverlap(): number {
|
||||
if (typeof window === "undefined" || !window.visualViewport) return 0;
|
||||
const vv = window.visualViewport;
|
||||
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||
if (chromeOverlap > 0) return chromeOverlap;
|
||||
|
||||
const initialHeight = getInitialViewportHeight();
|
||||
const gap = initialHeight - vv.offsetTop - vv.height;
|
||||
return gap >= 30 && gap > 80 ? gap : 0;
|
||||
}
|
||||
|
||||
/** Reset cached initial viewport height. Exported for tests only. */
|
||||
export function _resetInitialViewportHeight(): void {
|
||||
_initialViewportHeight = null;
|
||||
}
|
||||
|
||||
interface UseMobileKeyboardOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useMobileKeyboard(
|
||||
{ enabled = true }: UseMobileKeyboardOptions = {},
|
||||
): { keyboardOverlap: number; viewportHeight: number | null } {
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) {
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const overlap = getKeyboardOverlap();
|
||||
setKeyboardOverlap(overlap);
|
||||
setViewportHeight(overlap > 0 ? vv.height : null);
|
||||
};
|
||||
|
||||
update();
|
||||
vv.addEventListener("resize", update);
|
||||
vv.addEventListener("scroll", update);
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener("resize", update);
|
||||
vv.removeEventListener("scroll", update);
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { keyboardOverlap, viewportHeight };
|
||||
}
|
||||
Reference in New Issue
Block a user