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 });
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user