diff --git a/web/fizzy.js b/web/fizzy.js index efb669635..882a6bf57 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -142,15 +142,22 @@ export function keyboardInset(win, send, canvas = null, owns = () => true) { const vv = win.visualViewport; const viewport = win.document?.querySelector('meta[name="viewport"]')?.content ?? ""; const overlays = /(?:^|[,;])\s*interactive-widget\s*=\s*overlays-content\s*(?:[,;]|$)/i.test(viewport); + // Only phones and tablets have a soft keyboard. On a desktop, a window shrinking after + // focusin would otherwise read as one and pin the canvas height to the stale focusin rect. + const softKeyboard = win.matchMedia?.("(pointer: coarse) and (hover: none)").matches ?? true; // A resizing viewport and VK geometry can use different origins. Measure only the // visual viewport unless the page explicitly requests an overlay (or has no VV API). - const vk = overlays || !vv ? win.navigator?.virtualKeyboard : null; + const vk = softKeyboard && (overlays || !vv) ? win.navigator?.virtualKeyboard : null; + const keyboardTop = () => { + if (!softKeyboard) return Infinity; + if (!vk) return vv ? vv.height + vv.offsetTop : win.innerHeight; + const { y, height } = vk.boundingRect; + return height ? y ?? win.innerHeight - height : Infinity; + }; const update = () => { const current = rect(); const base = rest ?? current; - const edge = vk ? (vk.boundingRect.height ? vk.boundingRect.y ?? win.innerHeight - vk.boundingRect.height : Infinity) - : vv ? vv.height + vv.offsetTop : win.innerHeight; - const next = owns() ? Math.max(0, base.bottom - edge) : 0; + const next = owns() ? Math.max(0, base.bottom - keyboardTop()) : 0; // resizes-content pages shrink the CSS canvas as well. Keep the focused canvas's layout // height until the keyboard closes, so both dvui loops see the same backing-store size. if (canvas && next > 0 && current.height < base.height) { diff --git a/web/tests/embed.test.mjs b/web/tests/embed.test.mjs index abec757aa..5bf8b7f35 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -61,7 +61,7 @@ const document = Object.assign(new Target(), { const window = Object.assign(new Target(), { visualViewport: Object.assign(new Target(), { height: 100, offsetTop: 0 }), devicePixelRatio: 1, scrollX: 0, scrollY: 0, localStorage: new Map(), parent: null, - dispatchEvent() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), + dispatchEvent() {}, matchMedia: (q) => ({ matches: q === "(pointer: coarse) and (hover: none)", addEventListener() {} }), }); window.parent = window; Object.assign(globalThis, { diff --git a/web/tests/keyboard-inset.test.mjs b/web/tests/keyboard-inset.test.mjs index 29114a54c..ba4aacfa8 100644 --- a/web/tests/keyboard-inset.test.mjs +++ b/web/tests/keyboard-inset.test.mjs @@ -14,6 +14,20 @@ class Target { fire(type) { this.listeners[type]?.(); } } +// A desktop has no soft keyboard: a window shrinking after focusin is not an inset. +{ + const vv = Object.assign(new Target(), { height: 800, offsetTop: 0 }); + const win = Object.assign(new Target(), { navigator: {}, innerHeight: 800, visualViewport: vv, matchMedia: () => ({ matches: false }) }); + const canvas = { style: { height: '100%' }, getBoundingClientRect: () => ({ bottom: 800, height: 800 }) }; + const sent = []; + keyboardInset(win, (m) => sent.push(m), canvas); + win.fire('focusin'); + vv.height = win.innerHeight = 500; + win.fire('resize'); + assert.deepEqual(sent, []); + assert.equal(canvas.style.height, '100%'); +} + // Chrome on Android: the VirtualKeyboard API, with the browser told not to resize or pan. { const vk = Object.assign(new Target(), { overlaysContent: false, boundingRect: { height: 0 } });