Keep mobile bottom chrome pinned or hidden during keyboard focus transitions, including landscape phone layouts. - track keyboard-focus intent before visual viewport metrics settle - clamp focused iOS viewport offsets so fixed bars remain behind the keyboard - cover runtime bootstrap, landscape integration, nav, focus, and offset behavior - document the mobile keyboard layout contract and add a patch changeset Files changed: .changeset/fn-9201-mobile-nav-keyboard-lift.md | 7 ++ docs/dashboard-guide.md | 4 +- packages/dashboard/app/App.tsx | 43 ++++++++-- ...ile-nav-keyboard-landscape.integration.test.tsx | 91 ++++++++++++++++++++ .../__tests__/icb-inline-script.runtime.test.ts | 74 ++++++++++++++++ .../mobile-bottom-bars-keyboard-layout.test.ts | 15 +++- .../viewport-compensation-keyboard.test.ts | 5 +- .../__tests__/MobileNavBar.keyboard-lift.test.tsx | 65 ++++++++++++++ .../__tests__/useKeyboardFocusPending.test.tsx | 99 ++++++++++++++++++++++ .../dashboard/app/hooks/useKeyboardFocusPending.ts | 43 ++++++++++ packages/dashboard/app/index.html | 12 ++- .../utils/__tests__/mobileBarKeyboardFlags.test.ts | 22 +++++ .../app/utils/__tests__/viewportOffset.test.ts | 32 +++++++ .../dashboard/app/utils/mobileBarKeyboardFlags.ts | 10 ++- packages/dashboard/app/utils/viewportOffset.ts | 8 ++ 15 files changed, 510 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-9201 Fusion-Task-Lineage: 4b3b01d3-8968-432b-9537-a63ee6bbde31 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
75 lines
3.7 KiB
TypeScript
75 lines
3.7 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const html = readFileSync(resolve(__dirname, "../index.html"), "utf8");
|
|
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
|
.map((match) => match[1])
|
|
.filter((source) => source.includes("--icb-bottom-offset") && source.includes("viewportOffset.ts"));
|
|
if (scripts.length !== 1) throw new Error(`Expected one ICB script, found ${scripts.length}`);
|
|
const source = scripts[0];
|
|
|
|
function setWindowNumber(name: "innerWidth" | "innerHeight", value: number) {
|
|
Object.defineProperty(window, name, { configurable: true, value });
|
|
}
|
|
function installViewport(height: number, scale = 1) {
|
|
const target = new EventTarget();
|
|
const viewport = {
|
|
width: 390, height, offsetTop: 0, offsetLeft: 0, scale,
|
|
addEventListener: target.addEventListener.bind(target),
|
|
removeEventListener: target.removeEventListener.bind(target),
|
|
dispatchEvent: target.dispatchEvent.bind(target),
|
|
};
|
|
Object.defineProperty(window, "visualViewport", { configurable: true, value: viewport });
|
|
return viewport;
|
|
}
|
|
function runScript() { new Function(source)(); }
|
|
|
|
describe("inline ICB compensation", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
document.documentElement.style.removeProperty("--icb-bottom-offset");
|
|
document.documentElement.style.removeProperty("--icb-right-offset");
|
|
setWindowNumber("innerWidth", 390); setWindowNumber("innerHeight", 844);
|
|
});
|
|
afterEach(() => { vi.useRealTimers(); document.body.replaceChildren(); });
|
|
|
|
it("does not lift bars for a focused null-baseline viewport", () => {
|
|
installViewport(508); const field = document.createElement("textarea"); document.body.append(field); field.focus();
|
|
runScript();
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
|
|
});
|
|
|
|
it("does not retain a stale baseline after focus", () => {
|
|
const viewport = installViewport(754); runScript();
|
|
const field = document.createElement("textarea"); document.body.append(field); field.focus();
|
|
(viewport as { height: number }).height = 508;
|
|
viewport.dispatchEvent(new Event("resize"));
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
|
|
});
|
|
|
|
it("safely publishes zero offsets without visualViewport, including focus and timeout tails", () => {
|
|
const windowAdd = vi.spyOn(window, "addEventListener");
|
|
Object.defineProperty(window, "visualViewport", { configurable: true, value: undefined });
|
|
const field = document.createElement("textarea"); document.body.append(field); field.focus();
|
|
expect(runScript).not.toThrow();
|
|
expect(windowAdd).toHaveBeenCalledWith("resize", expect.any(Function));
|
|
window.dispatchEvent(new Event("resize"));
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
|
|
expect(document.documentElement.style.getPropertyValue("--icb-right-offset")).toBe("0px");
|
|
vi.advanceTimersByTime(3000);
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
|
|
expect(document.documentElement.style.getPropertyValue("--icb-right-offset")).toBe("0px");
|
|
windowAdd.mockRestore();
|
|
});
|
|
|
|
|
|
it("preserves unfocused and pinch-zoom compensation", () => {
|
|
installViewport(508); runScript();
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("336px");
|
|
document.body.replaceChildren(); const field = document.createElement("textarea"); document.body.append(field); field.focus();
|
|
installViewport(508, 1.5); runScript();
|
|
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("336px");
|
|
});
|
|
});
|