feat(FN-3290): add changeset for modal keyboard isolation fix

Adds a patch changeset for FN-3290 documenting the modal keyboard isolation fix, ensuring the change is captured in the release changelog.

Fusion-Task-Id: FN-3290
This commit is contained in:
Fusion
2026-05-03 12:51:22 -07:00
committed by gsxdsm
parent 0dd60cb28d
commit b9b5c0873c
4 changed files with 301 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile dashboard layout offset after modal keyboard dismissal. Modal inputs no longer leak keyboard-open state into the underlying dashboard layout, preventing stale bottom-padding offsets.

View File

@@ -287,7 +287,11 @@ function AppInner() {
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
// Keyboard visibility controls both MobileNavBar rendering and whether // Keyboard visibility controls both MobileNavBar rendering and whether
// the project content reserves bottom padding for the mobile nav bar. // the project content reserves bottom padding for the mobile nav bar.
const mobileKeyboardOpen = isMobile && keyboardOpen; // When a modal is open, modal-local inputs can trigger the keyboard without
// affecting the underlying dashboard layout — the modal handles its own
// viewport. Without this guard, modal keyboard state leaks into the app-level
// layout, causing stale bottom-padding offsets after the keyboard closes.
const mobileKeyboardOpen = isMobile && keyboardOpen && !modalManager.anyModalOpen;
// App-level mailbox unread count state (used for header/mobile nav badges) // App-level mailbox unread count state (used for header/mobile nav badges)
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);

View File

@@ -471,6 +471,26 @@ vi.mock("../../hooks/useNodes", () => ({
})), })),
})); }));
// Mock useMobileKeyboard for modal keyboard isolation tests (FN-3290).
// Default: keyboard closed, matching real test-environment behavior.
const mockUseMobileKeyboard = vi.fn(() => ({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
// Mock useViewportMode so tests can simulate mobile viewport without
// depending on window.matchMedia in jsdom.
const mockUseViewportMode = vi.fn(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
getViewportMode: () => "desktop",
}));
import { App } from "../../App"; import { App } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api"; import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
@@ -557,6 +577,16 @@ beforeEach(() => {
totalCount: 0, totalCount: 0,
dismissedCount: 0, dismissedCount: 0,
})); }));
// Reset mobile keyboard and viewport mocks to defaults (desktop, no keyboard)
mockUseMobileKeyboard.mockReset();
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockUseViewportMode.mockReset();
mockUseViewportMode.mockReturnValue("desktop");
}); });
describe("App backend-unreachable first-run flow", () => { describe("App backend-unreachable first-run flow", () => {
@@ -2951,3 +2981,128 @@ describe("App auth token recovery dialog", () => {
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
}); });
}); });
describe("FN-3290: modal keyboard isolation for mobile dashboard layout", () => {
const originalLocation = window.location;
beforeEach(() => {
window.history.replaceState = vi.fn();
// Prevent onboarding modal from auto-opening
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
localStorage.removeItem("kb-dashboard-view-mode");
localStorage.removeItem(taskViewStorageKey());
});
it("removes project-content--with-mobile-nav when keyboard is open with no modal (mobile)", async () => {
mockUseViewportMode.mockReturnValue("mobile");
localStorage.setItem("kb-dashboard-view-mode", "project");
// Keyboard is open, no modal
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 250,
viewportHeight: 550,
viewportOffsetTop: 0,
keyboardOpen: true,
});
render(<App />);
await waitFor(() => {
expect(document.querySelector(".project-content")).toBeTruthy();
});
const wrapper = document.querySelector(".project-content");
// Without a modal, the keyboard-open state should remove the mobile nav padding
expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(false);
});
it("keeps project-content--with-mobile-nav when keyboard is open inside a modal (mobile)", async () => {
// Use deep link to open a task detail modal — avoids complex mobile overflow navigation
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-123"),
});
mockUseViewportMode.mockReturnValue("mobile");
localStorage.setItem("kb-dashboard-view-mode", "project");
// Keyboard is reported as open (as if a modal input has focus)
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 250,
viewportHeight: 550,
viewportOffsetTop: 0,
keyboardOpen: true,
});
render(<App />);
// Wait for task detail modal to open
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
});
await waitFor(() => {
expect(screen.getByText("Task FN-123")).toBeTruthy();
});
// The dashboard wrapper should STILL have project-content--with-mobile-nav
// because the keyboard-open state is gated by anyModalOpen.
const wrapper = document.querySelector(".project-content");
expect(wrapper).toBeTruthy();
expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(true);
});
it("removes mobile nav class when modal closes while keyboard stays open", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-456"),
});
mockUseViewportMode.mockReturnValue("mobile");
localStorage.setItem("kb-dashboard-view-mode", "project");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 250,
viewportHeight: 550,
viewportOffsetTop: 0,
keyboardOpen: true,
});
const { rerender } = render(<App />);
// Wait for task detail modal to open
await waitFor(() => {
expect(screen.getByText("Task FN-456")).toBeTruthy();
});
// With modal open, mobile nav class is preserved despite keyboard being open
let wrapper = document.querySelector(".project-content");
expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(true);
// Close the modal via close button
const closeBtn = document.querySelector(".modal-overlay.open .modal-close") as HTMLElement;
expect(closeBtn).toBeTruthy();
fireEvent.click(closeBtn);
rerender(<App />);
// Keyboard is still open, but modal is now closed — mobileKeyboardOpen becomes true,
// so the mobile nav class should be removed
await waitFor(() => {
wrapper = document.querySelector(".project-content");
expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(false);
});
});
});

View File

@@ -335,4 +335,140 @@ describe("useMobileKeyboard", () => {
input.remove(); input.remove();
}); });
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
// Focus the input and simulate keyboard opening
input.focus();
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.keyboardOpen).toBe(true);
expect(result.current.keyboardOverlap).toBe(324);
});
// Blur the input and restore viewport to baseline
input.blur();
Object.defineProperty(window, "innerHeight", {
value: 844,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 844,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.keyboardOverlap).toBe(0);
});
input.remove();
});
it("clears keyboardOpen when active input is removed from DOM (simulating modal close)", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
// Focus input and shrink viewport (keyboard appears)
input.focus();
Object.defineProperty(mockVV, "height", {
value: 824,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 5,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 829,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
});
// Simulate modal close: remove the focused input from DOM and restore viewport
// The focusout event fires when the element is removed
input.remove();
Object.defineProperty(mockVV, "height", {
value: 844,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 844,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.keyboardOverlap).toBe(0);
});
});
});
}); });