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:
5
.changeset/fn-3290-modal-keyboard-isolation.md
Normal file
5
.changeset/fn-3290-modal-keyboard-isolation.md
Normal 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.
|
||||
@@ -287,7 +287,11 @@ function AppInner() {
|
||||
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
// Keyboard visibility controls both MobileNavBar rendering and whether
|
||||
// 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)
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
|
||||
@@ -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 { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
||||
@@ -557,6 +577,16 @@ beforeEach(() => {
|
||||
totalCount: 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", () => {
|
||||
@@ -2951,3 +2981,128 @@ describe("App auth token recovery dialog", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,4 +335,140 @@ describe("useMobileKeyboard", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user