FN-5702: keep mobile footer and nav pinned when keyboard opens
Add viewport-offset handling so mobile bottom bars stay anchored while virtual keyboards resize the visual viewport. - add a shared viewportOffset utility to compute keyboard-related visual viewport compensation - update dashboard app bootstrap/index logic to apply compensation variables for pinned footer/nav layout - extend mobile keyboard layout coverage with dedicated viewport compensation and bottom-bar tests - document the keyboard/pinned-bar behavior update in dashboard guide Files changed: docs/dashboard-guide.md | 2 +- .../mobile-bottom-bars-keyboard-layout.test.ts | 12 ++- .../viewport-compensation-keyboard.test.ts | 24 ++++++ packages/dashboard/app/index.html | 39 +++++++++- .../app/utils/__tests__/viewportOffset.test.ts | 86 ++++++++++++++++++++++ packages/dashboard/app/utils/viewportOffset.ts | 64 ++++++++++++++++ 6 files changed, 223 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-5702 Fusion-Task-Lineage: be47ad0c-82df-4c38-9577-94c2eee49a0e
This commit is contained in:
@@ -6,7 +6,14 @@ const css = loadAllAppCss();
|
||||
describe("mobile bottom bars keyboard-open css contract", () => {
|
||||
it("mobile nav keyboard-open rule pins bottom to 0", () => {
|
||||
const match = css.match(/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m);
|
||||
expect(match?.[1] ?? "").toContain("bottom: 0");
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("bottom: 0");
|
||||
});
|
||||
|
||||
it("mobile nav with-footer keyboard-open selector also pins bottom to 0", () => {
|
||||
const match = css.match(/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m);
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("bottom: 0");
|
||||
});
|
||||
|
||||
it("mobile nav keyboard-open rule appears after with-footer rule", () => {
|
||||
@@ -18,7 +25,8 @@ describe("mobile bottom bars keyboard-open css contract", () => {
|
||||
|
||||
it("executor status bar keyboard-open rule pins bottom to 0", () => {
|
||||
const match = css.match(/\.executor-status-bar\.executor-status-bar--keyboard-open\s*\{([^}]*)\}/m);
|
||||
expect(match?.[1] ?? "").toContain("bottom: 0");
|
||||
expect(match).toBeTruthy();
|
||||
expect(match![1]).toContain("bottom: 0");
|
||||
});
|
||||
|
||||
it("executor status bar keyboard-open rule appears after mobile base bottom rule", () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("index.html viewport compensation keyboard guard", () => {
|
||||
const indexHtml = readFileSync(resolve(__dirname, "../index.html"), "utf8");
|
||||
|
||||
it("contains keyboard-focus predicate in inline compensation script", () => {
|
||||
expect(indexHtml).toContain("isKeyboardFocusableElement");
|
||||
expect(indexHtml).toContain("document.activeElement");
|
||||
expect(indexHtml).toContain("HTMLTextAreaElement");
|
||||
expect(indexHtml).toContain("HTMLInputElement");
|
||||
});
|
||||
|
||||
it("contains pinch-zoom scale guard", () => {
|
||||
expect(indexHtml).toContain("vvScale <= 1.01");
|
||||
});
|
||||
|
||||
it("contains viewportOffset.ts sync marker and keyboard shrink clamp", () => {
|
||||
expect(indexHtml).toContain("Keep in sync with packages/dashboard/app/utils/viewportOffset.ts");
|
||||
expect(indexHtml).toContain("keyboardShrink");
|
||||
expect(indexHtml).toContain("rawBottomOffset - keyboardShrink");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,27 @@
|
||||
// `bottom: var(--icb-bottom-offset)` etc. Healthy browsers see 0px.
|
||||
(function () {
|
||||
if (typeof window === 'undefined') return;
|
||||
var baselineViewportHeight = null;
|
||||
var NON_TEXT_INPUT_TYPES = {
|
||||
checkbox: true,
|
||||
radio: true,
|
||||
button: true,
|
||||
submit: true,
|
||||
reset: true,
|
||||
file: true,
|
||||
range: true,
|
||||
color: true,
|
||||
hidden: true,
|
||||
};
|
||||
var isKeyboardFocusableElement = function (el) {
|
||||
if (!el) return false;
|
||||
if (el instanceof HTMLTextAreaElement) return true;
|
||||
if (el instanceof HTMLInputElement) {
|
||||
var type = (el.type || '').toLowerCase();
|
||||
return !NON_TEXT_INPUT_TYPES[type];
|
||||
}
|
||||
return el instanceof HTMLElement && el.isContentEditable;
|
||||
};
|
||||
var apply = function () {
|
||||
var vv = window.visualViewport;
|
||||
var de = document.documentElement;
|
||||
@@ -37,8 +58,24 @@
|
||||
var vvH = vv ? Math.round(vv.height) : innerH;
|
||||
var vvOffTop = vv ? vv.offsetTop : 0;
|
||||
var vvOffLeft = vv ? vv.offsetLeft : 0;
|
||||
var vvScale = vv ? vv.scale : 1;
|
||||
var rightOffset = Math.max(0, innerW - vvOffLeft - vvW);
|
||||
var bottomOffset = Math.max(0, innerH - vvOffTop - vvH);
|
||||
var rawBottomOffset = Math.max(0, innerH - vvOffTop - vvH);
|
||||
var activeElementIsKeyboardFocusable = isKeyboardFocusableElement(document.activeElement);
|
||||
if (!activeElementIsKeyboardFocusable) {
|
||||
baselineViewportHeight = baselineViewportHeight === null ? vvH : Math.max(baselineViewportHeight, vvH);
|
||||
}
|
||||
|
||||
// Keep in sync with packages/dashboard/app/utils/viewportOffset.ts.
|
||||
// Ignore keyboard-driven visualViewport shrink (iOS) so fixed mobile
|
||||
// bars stay pinned to page bottom and are covered by keyboard.
|
||||
var bottomOffset = rawBottomOffset;
|
||||
if (activeElementIsKeyboardFocusable && vvScale <= 1.01 && baselineViewportHeight !== null) {
|
||||
var keyboardShrink = Math.max(0, baselineViewportHeight - vvH);
|
||||
if (keyboardShrink > 0) {
|
||||
bottomOffset = Math.max(0, rawBottomOffset - keyboardShrink);
|
||||
}
|
||||
}
|
||||
de.style.setProperty('--icb-right-offset', rightOffset + 'px');
|
||||
de.style.setProperty('--icb-bottom-offset', bottomOffset + 'px');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeIcbOffsets } from "../viewportOffset";
|
||||
|
||||
describe("computeIcbOffsets", () => {
|
||||
it("returns zero offsets for healthy viewport", () => {
|
||||
const offsets = computeIcbOffsets({
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
vvWidth: 390,
|
||||
vvHeight: 844,
|
||||
vvOffsetTop: 0,
|
||||
vvOffsetLeft: 0,
|
||||
vvScale: 1,
|
||||
activeElementIsKeyboardFocusable: false,
|
||||
baselineViewportHeight: 844,
|
||||
});
|
||||
|
||||
expect(offsets).toEqual({ rightOffset: 0, bottomOffset: 0 });
|
||||
});
|
||||
|
||||
it("clamps iOS keyboard shrink to keep bottom offset pinned", () => {
|
||||
const offsets = computeIcbOffsets({
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
vvWidth: 390,
|
||||
vvHeight: 520,
|
||||
vvOffsetTop: 0,
|
||||
vvOffsetLeft: 0,
|
||||
vvScale: 1,
|
||||
activeElementIsKeyboardFocusable: true,
|
||||
baselineViewportHeight: 844,
|
||||
});
|
||||
|
||||
expect(offsets.bottomOffset).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves pinch-zoom offset compensation", () => {
|
||||
const offsets = computeIcbOffsets({
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
vvWidth: 280,
|
||||
vvHeight: 600,
|
||||
vvOffsetTop: 0,
|
||||
vvOffsetLeft: 0,
|
||||
vvScale: 1.2,
|
||||
activeElementIsKeyboardFocusable: true,
|
||||
baselineViewportHeight: 844,
|
||||
});
|
||||
|
||||
expect(offsets.bottomOffset).toBeGreaterThan(0);
|
||||
expect(offsets.rightOffset).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves Android ICB-stuck compensation without focus", () => {
|
||||
const offsets = computeIcbOffsets({
|
||||
innerWidth: 430,
|
||||
innerHeight: 932,
|
||||
vvWidth: 412,
|
||||
vvHeight: 850,
|
||||
vvOffsetTop: 0,
|
||||
vvOffsetLeft: 0,
|
||||
vvScale: 1,
|
||||
activeElementIsKeyboardFocusable: false,
|
||||
baselineViewportHeight: 932,
|
||||
});
|
||||
|
||||
expect(offsets.bottomOffset).toBe(82);
|
||||
expect(offsets.rightOffset).toBe(18);
|
||||
});
|
||||
|
||||
it("does not clamp unfocused viewport shrink as keyboard", () => {
|
||||
const offsets = computeIcbOffsets({
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
vvWidth: 390,
|
||||
vvHeight: 760,
|
||||
vvOffsetTop: 0,
|
||||
vvOffsetLeft: 0,
|
||||
vvScale: 1,
|
||||
activeElementIsKeyboardFocusable: false,
|
||||
baselineViewportHeight: 844,
|
||||
});
|
||||
|
||||
expect(offsets.bottomOffset).toBe(84);
|
||||
});
|
||||
});
|
||||
64
packages/dashboard/app/utils/viewportOffset.ts
Normal file
64
packages/dashboard/app/utils/viewportOffset.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const NON_TEXT_INPUT_TYPES = new Set([
|
||||
"checkbox",
|
||||
"radio",
|
||||
"button",
|
||||
"submit",
|
||||
"reset",
|
||||
"file",
|
||||
"range",
|
||||
"color",
|
||||
"hidden",
|
||||
]);
|
||||
|
||||
export interface ComputeIcbOffsetsInput {
|
||||
innerWidth: number;
|
||||
innerHeight: number;
|
||||
vvWidth: number;
|
||||
vvHeight: number;
|
||||
vvOffsetTop: number;
|
||||
vvOffsetLeft: number;
|
||||
vvScale: number;
|
||||
activeElementIsKeyboardFocusable: boolean;
|
||||
baselineViewportHeight: number | null;
|
||||
}
|
||||
|
||||
export interface IcbOffsets {
|
||||
rightOffset: number;
|
||||
bottomOffset: number;
|
||||
}
|
||||
|
||||
export function isKeyboardFocusableInputType(type: string | null | undefined): boolean {
|
||||
if (!type) return true;
|
||||
return !NON_TEXT_INPUT_TYPES.has(type.toLowerCase());
|
||||
}
|
||||
|
||||
export function computeIcbOffsets(input: ComputeIcbOffsetsInput): IcbOffsets {
|
||||
const {
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
vvWidth,
|
||||
vvHeight,
|
||||
vvOffsetTop,
|
||||
vvOffsetLeft,
|
||||
vvScale,
|
||||
activeElementIsKeyboardFocusable,
|
||||
baselineViewportHeight,
|
||||
} = input;
|
||||
|
||||
const rightOffset = Math.max(0, innerWidth - vvOffsetLeft - vvWidth);
|
||||
const rawBottomOffset = Math.max(0, innerHeight - vvOffsetTop - vvHeight);
|
||||
|
||||
if (!activeElementIsKeyboardFocusable || vvScale > 1.01 || baselineViewportHeight == null) {
|
||||
return { rightOffset, bottomOffset: rawBottomOffset };
|
||||
}
|
||||
|
||||
const keyboardShrink = Math.max(0, baselineViewportHeight - vvHeight);
|
||||
if (keyboardShrink <= 0) {
|
||||
return { rightOffset, bottomOffset: rawBottomOffset };
|
||||
}
|
||||
|
||||
return {
|
||||
rightOffset,
|
||||
bottomOffset: Math.max(0, rawBottomOffset - keyboardShrink),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user