修复移动端软键盘页面弹跳黑底
移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。 保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。 补充小程序 web-view 原生 page 浅色背景和对应样式测试。 更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user