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:
gsxdsm
2026-05-29 19:06:36 -07:00
parent ff575f8b06
commit 129e17d16c
6 changed files with 223 additions and 4 deletions

View File

@@ -1025,7 +1025,7 @@ Don't create parallel button/form variants — add states (`:hover`, `:focus-vis
Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-width: 1024px`), 640px (compact), 480px (xs). Mobile overrides go in `@media (max-width: 768px)` blocks at the bottom of `styles.css` after base styles.
**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those.
**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar and executor footer stay pinned to page bottom and are covered by the keyboard (they do not shift upward).
**Touch targets:** Standing button-freeze directive supersedes per-button touch-target guidance. For non-button elements, primary controls (nav bar, FAB, tab action rows, modal CTAs, list-row tap targets, form controls) must be ≥36px on mobile. Secondary controls inside a card/list-row where the row itself is the tap target stay compact (2428px or small chips).

View File

@@ -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", () => {

View File

@@ -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");
});
});

View File

@@ -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');
};

View File

@@ -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);
});
});

View 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),
};
}