修复移动端软键盘页面弹跳黑底

移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。

保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。

补充小程序 web-view 原生 page 浅色背景和对应样式测试。

更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
This commit is contained in:
2026-06-07 18:17:23 +08:00
parent 56a9075582
commit 665f09f047
11 changed files with 304 additions and 115 deletions

View File

@@ -79,17 +79,19 @@ body {
-webkit-font-smoothing: antialiased;
}
html[data-mobile-keyboard-open='true'],
html[data-mobile-keyboard-open='true'] body,
html[data-mobile-keyboard-open='true'] #root {
background: var(
--platform-keyboard-exposed-fill,
linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)
);
}
.platform-viewport-shell {
height: var(--platform-layout-viewport-height, 100vh);
max-height: var(--platform-layout-viewport-height, 100vh);
min-height: var(--platform-layout-viewport-height, 100vh);
transform: translate3d(
0,
calc(-1 * var(--platform-keyboard-focus-offset, 0px)),
0
);
transform-origin: top center;
transition: transform 180ms ease;
}
@supports (height: 100dvh) {

View File

@@ -59,6 +59,30 @@ describe('index stylesheet unread dots', () => {
expect(css).toContain('::-webkit-scrollbar-thumb');
});
it('uses the platform fill for root background exposed by mobile keyboard shift', () => {
const css = readIndexCss();
const keyboardRootBlock = getCssBlock(
css,
"html[data-mobile-keyboard-open='true'],\nhtml[data-mobile-keyboard-open='true'] body,\nhtml[data-mobile-keyboard-open='true'] #root",
);
expect(keyboardRootBlock).toContain('--platform-keyboard-exposed-fill');
expect(keyboardRootBlock).toContain('#fffdf9');
expect(keyboardRootBlock).not.toContain('#0a0a0a');
});
it('does not globally transform the platform shell while the mobile keyboard is open', () => {
const css = readIndexCss();
const platformShellBlock = getCssBlock(
css,
'.platform-viewport-shell {\n height',
);
expect(platformShellBlock).toContain('--platform-layout-viewport-height');
expect(platformShellBlock).not.toContain('translate3d');
expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset');
});
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
const css = readIndexCss();

View File

@@ -1,12 +1,59 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
calculateMobileKeyboardFocusShift,
isEditableKeyboardTarget,
resolveMobileKeyboardExposedFill,
resolveMobileKeyboardState,
stabilizeMobileViewportKeyboardFocus,
} from './mobileViewportKeyboardFocus';
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const originalInnerHeight = window.innerHeight;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalVisualViewport = window.visualViewport;
function defineWindowValue<Key extends keyof Window>(key: Key, value: Window[Key]) {
Object.defineProperty(window, key, {
configurable: true,
value,
});
}
function defineNavigatorValue<Key extends keyof Navigator>(
key: Key,
value: Navigator[Key],
) {
Object.defineProperty(navigator, key, {
configurable: true,
value,
});
}
beforeEach(() => {
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
document.documentElement.removeAttribute('style');
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-mobile-keyboard-open');
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
document.documentElement.removeAttribute('style');
defineWindowValue('matchMedia', originalMatchMedia);
defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame);
defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame);
defineWindowValue('innerHeight', originalInnerHeight);
defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints);
defineWindowValue('visualViewport', originalVisualViewport);
});
describe('isEditableKeyboardTarget', () => {
it('matches controls that open the mobile keyboard', () => {
const input = document.createElement('input');
@@ -31,47 +78,125 @@ describe('isEditableKeyboardTarget', () => {
});
});
describe('calculateMobileKeyboardFocusShift', () => {
it('moves a bottom input above the visible keyboard area', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 720,
targetBottom: 770,
currentShift: 0,
margin: 20,
}),
).toBe(290);
describe('resolveMobileKeyboardExposedFill', () => {
it('uses the active platform shell fill for exposed mini-program keyboard space', () => {
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
></div>
`;
expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)');
});
it('does not move when the focused input is already visible', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 250,
targetBottom: 300,
currentShift: 0,
margin: 20,
}),
).toBe(0);
});
it('falls back to the light platform fill before the shell mounts', () => {
document.body.innerHTML = '';
it('caps movement to keyboard inset plus safety margin', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 790,
targetBottom: 860,
currentShift: 0,
margin: 20,
maxExtraShift: 20,
}),
).toBe(320);
expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9');
});
});
describe('resolveMobileKeyboardState', () => {
it('detects the keyboard inset without asking the app shell to shift', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 300,
});
});
it('stays closed when no editable target is focused', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
hasEditableTarget: false,
}),
).toEqual({
isOpen: false,
insetBottom: 0,
});
});
it('accounts for browser visual viewport panning without returning a shell offset', () => {
expect(
resolveMobileKeyboardState({
layoutHeight: 800,
visualTop: 120,
visualHeight: 500,
hasEditableTarget: true,
}),
).toEqual({
isOpen: true,
insetBottom: 180,
});
});
});
describe('stabilizeMobileViewportKeyboardFocus', () => {
it('marks H5 keyboard state without applying a global shell transform offset', () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div
class="platform-viewport-shell"
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
>
<input id="theme-input" />
</div>
`;
defineNavigatorValue('maxTouchPoints', 1);
defineWindowValue(
'matchMedia',
vi.fn().mockReturnValue({ matches: true }) as unknown as Window['matchMedia'],
);
defineWindowValue(
'requestAnimationFrame',
((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as Window['requestAnimationFrame'],
);
defineWindowValue(
'cancelAnimationFrame',
vi.fn() as unknown as Window['cancelAnimationFrame'],
);
defineWindowValue('innerHeight', 800);
defineWindowValue('visualViewport', {
height: 500,
offsetTop: 120,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as VisualViewport);
stabilizeMobileViewportKeyboardFocus();
document.getElementById('theme-input')?.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
vi.runAllTimers();
expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-focus-offset',
),
).toBe('0px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-inset-bottom',
),
).toBe('180px');
expect(
document.documentElement.style.getPropertyValue(
'--platform-keyboard-exposed-fill',
),
).toContain('rgb(255, 253, 249)');
});
});

View File

@@ -1,31 +1,43 @@
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
const FOCUS_MARGIN_PX = 18;
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill';
const KEYBOARD_EXPOSED_FILL_FALLBACK =
'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)';
type KeyboardFocusShiftInput = {
layoutHeight: number;
visualTop: number;
visualHeight: number;
targetTop: number;
targetBottom: number;
currentShift: number;
margin?: number;
maxExtraShift?: number;
hasEditableTarget: boolean;
threshold?: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function readVisualViewport() {
return typeof window !== 'undefined' ? window.visualViewport : undefined;
}
export function resolveMobileKeyboardExposedFill() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return KEYBOARD_EXPOSED_FILL_FALLBACK;
}
const platformShell = document.querySelector('.platform-viewport-shell');
if (!(platformShell instanceof HTMLElement)) {
return KEYBOARD_EXPOSED_FILL_FALLBACK;
}
const platformFill = window
.getComputedStyle(platformShell)
.getPropertyValue('--platform-body-fill')
.trim();
return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK;
}
function readLayoutViewportHeight() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
@@ -110,34 +122,22 @@ export function isEditableKeyboardTarget(
]).has(inputType);
}
export function calculateMobileKeyboardFocusShift({
export function resolveMobileKeyboardState({
layoutHeight,
visualTop,
visualHeight,
targetTop,
targetBottom,
currentShift,
margin = FOCUS_MARGIN_PX,
maxExtraShift = FOCUS_MARGIN_PX,
hasEditableTarget,
threshold = KEYBOARD_OPEN_THRESHOLD_PX,
}: KeyboardFocusShiftInput) {
const visualBottom = visualTop + visualHeight;
const safeTop = visualTop + margin;
const safeBottom = visualBottom - margin;
const unshiftedTargetTop = targetTop + currentShift;
const unshiftedTargetBottom = targetBottom + currentShift;
let nextShift = currentShift;
const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom));
const isOpen =
hasEditableTarget && layoutHeight - visualHeight > threshold;
if (unshiftedTargetBottom - nextShift > safeBottom) {
nextShift = unshiftedTargetBottom - safeBottom;
}
if (unshiftedTargetTop - nextShift < safeTop) {
nextShift = Math.max(0, unshiftedTargetTop - safeTop);
}
const keyboardInset = Math.max(0, layoutHeight - visualBottom);
const maxShift = keyboardInset + maxExtraShift;
return Math.round(clamp(nextShift, 0, Math.max(0, maxShift)));
return {
isOpen,
insetBottom: isOpen ? insetBottom : 0,
};
}
export function stabilizeMobileViewportKeyboardFocus() {
@@ -154,7 +154,6 @@ export function stabilizeMobileViewportKeyboardFocus() {
const root = document.documentElement;
const visualViewport = readVisualViewport();
let stableLayoutHeight = readLayoutViewportHeight();
let currentShift = 0;
let frameId = 0;
const setLayoutHeight = (nextHeight: number) => {
@@ -167,6 +166,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
if (isOpen) {
root.style.setProperty(
KEYBOARD_EXPOSED_FILL_VAR,
resolveMobileKeyboardExposedFill(),
);
root.dataset.mobileKeyboardOpen = 'true';
} else {
delete root.dataset.mobileKeyboardOpen;
@@ -178,9 +181,8 @@ export function stabilizeMobileViewportKeyboardFocus() {
);
};
const setFocusShift = (nextShift: number) => {
currentShift = Math.max(0, Math.round(nextShift));
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`);
const resetFocusShift = () => {
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px');
};
const readActiveTarget = () =>
@@ -193,15 +195,16 @@ export function stabilizeMobileViewportKeyboardFocus() {
const viewport = readVisualViewport();
const visualTop = viewport?.offsetTop ?? 0;
const visualHeight = viewport?.height ?? window.innerHeight;
const visualBottom = visualTop + visualHeight;
const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom);
const keyboardOpen =
Boolean(activeTarget) &&
stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX;
const keyboardState = resolveMobileKeyboardState({
layoutHeight: stableLayoutHeight,
visualTop,
visualHeight,
hasEditableTarget: Boolean(activeTarget),
});
if (!keyboardOpen || !activeTarget) {
if (!keyboardState.isOpen || !activeTarget) {
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
if (!activeTarget) {
setLayoutHeight(readLayoutViewportHeight());
@@ -209,19 +212,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
return;
}
// 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘
const targetRect = activeTarget.getBoundingClientRect();
const nextShift = calculateMobileKeyboardFocusShift({
layoutHeight: stableLayoutHeight,
visualTop,
visualHeight,
targetTop: targetRect.top,
targetBottom: targetRect.bottom,
currentShift,
});
setKeyboardState(true, keyboardInset);
setFocusShift(nextShift);
// 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性
// 这里只记录键盘状态、隐藏底部 dock并给可能露出的宿主区域补浅色背景。
setKeyboardState(true, keyboardState.insetBottom);
resetFocusShift();
};
const scheduleSync = () => {
@@ -243,14 +237,14 @@ export function stabilizeMobileViewportKeyboardFocus() {
setLayoutHeight(stableLayoutHeight);
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
window.addEventListener('resize', scheduleKeyboardAnimationSync);
window.addEventListener('orientationchange', () => {
setKeyboardState(false);
setFocusShift(0);
resetFocusShift();
window.setTimeout(() => {
setLayoutHeight(readLayoutViewportHeight());
scheduleSync();