From 86c74e8c304c69ffe8a5c2d57f591e19c35f3a1d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 12:16:45 -0800 Subject: [PATCH 1/2] fix(core): the keyboard height reads the whole keyboard while Safari pans to a focused field While Safari on iOS 26.2 pans the page to a focused field near the bottom, it reports innerHeight as the visual viewport's height (377 of 714 with the keyboard up). layoutViewportHeight read innerHeight, so the keyboard module measured 0, as if no keyboard were up, against AST-099 FR2 ("a page pan is never a smaller keyboard"). The root's client height keeps the layout viewport's height. At a visual-viewport scale of 1, where innerHeight equals the visual viewport's height and the root is as wide as the visual viewport, layoutViewportHeight now takes the larger of the two. Everywhere else it is unchanged: a zoomed-out page's scaled innerHeight and the root's unscaled height are in different units, a layout wider than the screen gives a root taller than the screen, and the scroll clamp reads as before. The README's ride formula now takes the keyboard's top from layoutViewportHeight(window), not innerHeight. --- .changeset/keyboard-height-safari-pan.md | 7 ++ docs/specs/AST-099-mobile-keyboard/spec.md | 2 +- packages/core/src/keyboard/README.md | 8 +- .../core/src/keyboard/keyboardHeight.test.ts | 50 ++++++++++++ .../core/src/utils/layoutViewport.test.ts | 79 +++++++++++++++++-- packages/core/src/utils/layoutViewport.ts | 29 ++++++- 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 .changeset/keyboard-height-safari-pan.md diff --git a/.changeset/keyboard-height-safari-pan.md b/.changeset/keyboard-height-safari-pan.md new file mode 100644 index 0000000000000..f5f93d03bc804 --- /dev/null +++ b/.changeset/keyboard-height-safari-pan.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The on-screen keyboard's height reads the whole keyboard while Safari on iOS pans the page to a focused field. Safari reports `innerHeight` as the visual viewport's height during that pan, so the height read 0, as if no keyboard were up. At a visual-viewport scale of 1, where `innerHeight` equals the visual viewport's height and the root is as wide as the visual viewport, the layout viewport's height now takes the root's client height where it is larger. + +@vjeux diff --git a/docs/specs/AST-099-mobile-keyboard/spec.md b/docs/specs/AST-099-mobile-keyboard/spec.md index 0e5e987c90671..a7ffb23aae6bc 100644 --- a/docs/specs/AST-099-mobile-keyboard/spec.md +++ b/docs/specs/AST-099-mobile-keyboard/spec.md @@ -379,7 +379,7 @@ that sets its own `translate`, to the app. | Contract | Verification | Representative states | Mutation or failure expectation | | -------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FR1 | Source boundary check over Core and Lab plus a shared test that drives one keyboard change | each listed consumer; a new keyboard-aware component | Two components reading different heights for one keyboard, or a component measuring by a rule other than FR2 | -| FR2 | Module tests driving visual-viewport resize and scroll events | keyboard shown; hidden; page panned with the keyboard shown; offset without a keyboard; page pinch-zoomed with and without the keyboard | A pan that lowers the reported height, or a height when no keyboard is shown | +| FR2 | Module tests driving visual-viewport resize and scroll events; Safari on iOS 26.2 in the Simulator | keyboard shown; hidden; page panned with the keyboard shown, by a scroll and by Safari's pan to a focused field (`innerHeight` at the visual viewport's height); offset without a keyboard; page pinch-zoomed with and without the keyboard | A pan that lowers the reported height, or a height when no keyboard is shown | | FR3 | Consumer tests of computed layout, plus a public-surface check | keyboard shown and hidden; composer, bottom search field, tab bar search, Tall sheet; no animation running; a ride animation running with the composer, a scroll view, and a header together | A consumer whose layout ignores the height, layout that is only correct after an animation, riding surfaces out of sync on one keyboard change, per-frame layout work during a ride, content hidden or unscrollable at the resting value, or a public CSS custom property, export, or prop for the height | | FR4 | Focus-route tests with document scroll spies | tap, previous and next buttons, Tab, programmatic focus, Done then tap, app resume while scroll-locked | A route where the browser scrolls the document to reveal the focused control, or a slipped scroll left in place; FR9's own reveal scroll by the field's nearest scroller is not a failure | | FR5 | Surface move tests | sheet drag between heights, drag to dismiss, close, with a focused field | The field stays focused after a move starts, or content jumps on the first frame | diff --git a/packages/core/src/keyboard/README.md b/packages/core/src/keyboard/README.md index c4bc77f9f97c0..58c3c69613b66 100644 --- a/packages/core/src/keyboard/README.md +++ b/packages/core/src/keyboard/README.md @@ -17,7 +17,11 @@ that times motion with it. Everything here is internal to `innerWidth` matching the visual viewport's width tells apart. A classic horizontal scrollbar, which `innerHeight` counts and the visual viewport does not, is taken out, so a desktop page wider than its window reads no - keyboard. BottomSheet's obstruction reads it too. + keyboard. While Safari pans the page to a focused field, it reports + `innerHeight` as the visual viewport's height; the root's client height + keeps the layout viewport's. At a scale of 1, where the two heights are + equal and the root is as wide as the visual viewport, the function takes the + larger. BottomSheet's obstruction reads it too. - **Where it measures (FR6, FR7, DEC-4):** `isKeyboardMeasurable(window)` is true for a top-level document with a visual viewport, a browser with its own keyboard geometry API (`navigator.virtualKeyboard`, Chrome on Android) @@ -90,7 +94,7 @@ that times motion with it. Everything here is internal to Layout follows the height (FR3), through a functional style on your own element, so it is right with no animation running; for an element docked at -the bottom, `lift = height > 0 ? max(0, restingBottom − (innerHeight − height)) : 0`. +the bottom, `lift = height > 0 ? max(0, restingBottom − (layoutViewportHeight(window) − height)) : 0`. An animation that rides the keyboard is optional polish on top, timed by the guide: diff --git a/packages/core/src/keyboard/keyboardHeight.test.ts b/packages/core/src/keyboard/keyboardHeight.test.ts index 81b6d5d69db89..e22e100b6adbe 100644 --- a/packages/core/src/keyboard/keyboardHeight.test.ts +++ b/packages/core/src/keyboard/keyboardHeight.test.ts @@ -130,6 +130,17 @@ describe('the layout viewport WebKit reports under a pinch zoom (FR2)', () => { expect(Math.abs(measureKeyboardHeight(window) - 337)).toBeLessThan(1); }); + // The root's client box. Safari keeps its height at the layout viewport's, + // and its width at the visual viewport's. + function rootHeight(clientHeight: number, clientWidth = 402) { + for (const [key, value] of Object.entries({clientHeight, clientWidth})) { + Object.defineProperty(document.documentElement, key, { + configurable: true, + value, + }); + } + } + it('follows the layout viewport WebKit clamps to the document after a scroll', () => { safari({ innerHeight: 666, @@ -138,9 +149,48 @@ describe('the layout viewport WebKit reports under a pinch zoom (FR2)', () => { scale: 1, offsetTop: 120, }); + rootHeight(714); expect(measureKeyboardHeight(window)).toBe(289); }); + it('reads no keyboard on a layout wider than the screen at a scale of 1', () => { + // A width=1024 page at initial-scale=1: the root spans the whole layout. + safari({innerHeight: 714, innerWidth: 402, height: 714, scale: 1}); + rootHeight(1819, 1024); + expect(measureKeyboardHeight(window)).toBe(0); + }); + + it('reads no keyboard on a zoomed-out page with none shown', () => { + // No width=device-width viewport: a 980 px layout viewport at a scale of + // 402 / 980. + safari({ + innerHeight: 1741, + innerWidth: 980, + height: 1741, + scale: 402 / 980, + }); + rootHeight(1741); + expect(measureKeyboardHeight(window)).toBeCloseTo(0); + }); + + it('reads the keyboard while Safari pans the page to a focused field, where innerHeight follows the visual viewport', () => { + safari({ + innerHeight: 377, + innerWidth: 402, + height: 377, + scale: 1, + offsetTop: 337, + }); + rootHeight(714); + expect(measureKeyboardHeight(window)).toBe(337); + }); + + it('reads none where the layout viewport truly shrinks for the keyboard, as under resizes-content', () => { + safari({innerHeight: 377, innerWidth: 402, height: 377, scale: 1}); + rootHeight(377); + expect(measureKeyboardHeight(window)).toBe(0); + }); + it('reads the same keyboard scrolled and pinch-zoomed', () => { safari({ innerHeight: 536, diff --git a/packages/core/src/utils/layoutViewport.test.ts b/packages/core/src/utils/layoutViewport.test.ts index 8a1c3f1456b08..f0aba4d7a7ee4 100644 --- a/packages/core/src/utils/layoutViewport.test.ts +++ b/packages/core/src/utils/layoutViewport.test.ts @@ -9,7 +9,9 @@ * classic scrollbar included. The function scales it back where WebKit * reports it in the zoomed visual viewport's px, as in Safari on iOS 26.2. * It leaves innerHeight as is where innerHeight ignores the zoom, or with no - * visual viewport. + * visual viewport. At a scale of 1 it keeps the root's client height while + * Safari pans and shrinks innerHeight to the visual viewport. It follows a + * true shrink, and ignores a root wider than the visual viewport. * @position Unit tests for utils/layoutViewport.ts */ @@ -20,16 +22,21 @@ function windowWith({ innerHeight, innerWidth, viewport, + rootHeight = innerHeight, + rootWidth = innerWidth, }: { innerHeight: number; innerWidth: number; viewport: {width: number; height: number; scale: number} | null; + rootHeight?: number; + rootWidth?: number; }): Window { - // A root as tall and as wide as the window: no horizontal scrollbar. + // A root with no horizontal scrollbar, as wide and as tall as the window + // unless a case says otherwise. const documentElement = { - clientHeight: innerHeight, - clientWidth: innerWidth, - scrollWidth: innerWidth, + clientHeight: rootHeight, + clientWidth: rootWidth, + scrollWidth: rootWidth, }; return { innerHeight, @@ -93,10 +100,72 @@ describe('layoutViewportHeight', () => { innerHeight: 666, innerWidth: 402, viewport: {width: 402, height: 377, scale: 1}, + rootHeight: 714, }); expect(layoutViewportHeight(win)).toBe(666); }); + it('keeps a zoomed-out page\u2019s height in the units of its scaled innerHeight', () => { + // Safari on iOS with no width=device-width viewport: a 980 px layout + // viewport at a scale of 402 / 980, and no keyboard. The root's client + // height is in unscaled px, so it is no floor here. + const scale = 402 / 980; + const win = windowWith({ + innerHeight: 1741, + innerWidth: 980, + viewport: {width: 980, height: 1741, scale}, + rootHeight: 1741, + }); + expect(layoutViewportHeight(win)).toBeCloseTo(1741 * scale); + }); + + it('keeps the root\u2019s height while Safari pans the page to a focused field', () => { + // Safari on iOS 26.2 with the keyboard up: innerHeight follows the + // visual viewport while it pans, and the root's client height does not. + const win = windowWith({ + innerHeight: 377, + innerWidth: 402, + viewport: {width: 402, height: 377, scale: 1}, + rootHeight: 714, + }); + expect(layoutViewportHeight(win)).toBe(714); + }); + + it('keeps innerHeight where the layout is wider than the screen at a scale of 1', () => { + // A width=1024 page at initial-scale=1 in a 412 × 839 viewport, with no + // keyboard: the root spans the whole 1024 px layout, and its height too. + const win = windowWith({ + innerHeight: 839, + innerWidth: 412, + viewport: {width: 412, height: 839, scale: 1}, + rootHeight: 2085, + rootWidth: 1024, + }); + expect(layoutViewportHeight(win)).toBe(839); + }); + + it('follows a layout viewport that truly shrinks for the keyboard, as under resizes-content', () => { + // Chrome under interactive-widget=resizes-content: innerHeight and the + // root shrink together. + const win = windowWith({ + innerHeight: 377, + innerWidth: 402, + viewport: {width: 402, height: 377, scale: 1}, + rootHeight: 377, + }); + expect(layoutViewportHeight(win)).toBe(377); + }); + + it('keeps innerHeight where it is taller than the root, as in Chrome on Android with its toolbar hidden', () => { + const win = windowWith({ + innerHeight: 760, + innerWidth: 412, + viewport: {width: 412, height: 760, scale: 1}, + rootHeight: 704, + }); + expect(layoutViewportHeight(win)).toBe(760); + }); + it('is innerHeight without a visual viewport', () => { const win = windowWith({innerHeight: 714, innerWidth: 402, viewport: null}); expect(layoutViewportHeight(win)).toBe(714); diff --git a/packages/core/src/utils/layoutViewport.ts b/packages/core/src/utils/layoutViewport.ts index 4072b179f46e1..e0615d0ab9d12 100644 --- a/packages/core/src/utils/layoutViewport.ts +++ b/packages/core/src/utils/layoutViewport.ts @@ -21,6 +21,12 @@ * viewport's height times its scale stay in layout px. So, read as is, a zoom * shrinks the layout viewport by the zoom, and the function scales the height * back. + * + * While Safari on iOS 26.2 pans the page to a focused field, it reports + * `innerHeight` as the visual viewport's height. With the keyboard up, that is + * 377 of 714. The root's client height stays 714. So at a scale of 1, where + * `innerHeight` equals the visual viewport's height, the function takes the + * larger of the two. The root must also be as wide as the visual viewport. */ export function layoutViewportHeight(win: Window): number { const viewport = win.visualViewport; @@ -40,7 +46,28 @@ export function layoutViewportHeight(win: Window): number { const fromLayout = Math.abs(win.innerWidth - width * scale); const height = fromVisual < fromLayout ? win.innerHeight * scale : win.innerHeight; - return height - horizontalScrollbarHeight(win); + const layout = height - horizontalScrollbarHeight(win); + // At a scale of 1, an `innerHeight` equal to the visual viewport's height + // can be Safari's pan. A layout wider than the screen gives a root taller + // than the screen too, so the root must span only the visual viewport. + const root = viewportRoot(win.document); + if ( + root != null && + Math.abs(scale - 1) < 0.001 && + Math.abs(win.innerHeight - viewport.height) < 1 && + Math.abs(root.clientWidth - width) < 1 + ) { + return Math.max(layout, root.clientHeight); + } + return layout; +} + +/** + * The element whose client box reports the viewport: the root in standards + * mode, the body in quirks mode. + */ +function viewportRoot(doc: Document): Element | null { + return doc.compatMode === 'BackCompat' ? doc.body : doc.documentElement; } /** From 7952b5f75f1fd968b206165d3951ec623fcbee00 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 14:30:06 -0800 Subject: [PATCH 2/2] fix(core): one keyboard report reads the visual viewport once The pan check in layoutViewportHeight read the visual viewport's height a second time in each keyboard measurement, and every reader that laid out against the keyboard measured the layout viewport again. The keyboard module now passes the height it read, and keeps the layout viewport's height with each report, which getKeyboardViewportHeight returns. ChatLayout's keyboard hook, useMobileKeyboard, the Layer's keyboard clearance and the keyboard target read it there. --- packages/core/src/Chat/useChatKeyboard.ts | 7 ++- .../src/Layer/useLayerKeyboardClearance.ts | 8 ++- packages/core/src/keyboard/README.md | 8 ++- .../core/src/keyboard/keyboardHeight.test.ts | 60 +++++++++++++++++++ packages/core/src/keyboard/keyboardHeight.ts | 59 ++++++++++++++---- packages/core/src/keyboard/keyboardTarget.ts | 10 ++-- .../core/src/keyboard/useMobileKeyboard.ts | 4 +- packages/core/src/utils/layoutViewport.ts | 11 +++- 8 files changed, 138 insertions(+), 29 deletions(-) diff --git a/packages/core/src/Chat/useChatKeyboard.ts b/packages/core/src/Chat/useChatKeyboard.ts index 3ee04a7637e0b..2d16b3c3a322b 100644 --- a/packages/core/src/Chat/useChatKeyboard.ts +++ b/packages/core/src/Chat/useChatKeyboard.ts @@ -23,6 +23,7 @@ import { claimTapFocus, findTextEntryControl, getKeyboardHeight, + getKeyboardViewportHeight, isTextEntryControl, refuseFocusReveal, subscribeKeyboardHeight, @@ -38,7 +39,6 @@ import { } from '../keyboard/keyboardTarget'; import type {KeyboardTransition} from '../keyboard/keyboardModel'; import {rideKeyboard} from '../keyboard/rideKeyboard'; -import {layoutViewportHeight} from '../utils/layoutViewport'; import {useInteractionModalityTrackingFor} from '../utils/interactionModality'; import {isCoarsePointer} from '../utils/coarsePointer'; import {useTouchPresentation} from '../utils/touchPresentation'; @@ -228,8 +228,9 @@ export function useChatKeyboard({ if (inset <= 0) { return 0; } - // In layout px, as the root's rect is, under a pinch zoom too. - const viewportBottom = layoutViewportHeight(win); + // In layout px, as the root's rect is, under a pinch zoom too. The + // keyboard module measured it with the keyboard height. + const viewportBottom = getKeyboardViewportHeight(win); const bottom = isSelfScrolling ? root.getBoundingClientRect().bottom : viewportBottom; diff --git a/packages/core/src/Layer/useLayerKeyboardClearance.ts b/packages/core/src/Layer/useLayerKeyboardClearance.ts index 884990908ca22..16f681bd191e1 100644 --- a/packages/core/src/Layer/useLayerKeyboardClearance.ts +++ b/packages/core/src/Layer/useLayerKeyboardClearance.ts @@ -35,7 +35,10 @@ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; // BottomSheet's keyboard hook. An import through it would put that hook's // on-demand code in every layered component. import {getKeyboardLayoutGuide} from '../keyboard/keyboardLayoutGuide'; -import {getKeyboardHeight} from '../keyboard/keyboardHeight'; +import { + getKeyboardHeight, + getKeyboardViewportHeight, +} from '../keyboard/keyboardHeight'; import {timing} from '../motion/uikit/timing'; import { keyboardClearanceCap, @@ -45,7 +48,6 @@ import { import {settledBox} from './settledBox'; import {awaitBrowserClick} from '../utils/browserClickWait'; import {subscribeInput} from '../utils/inputStream'; -import {layoutViewportHeight} from '../utils/layoutViewport'; export { keyboardClearanceCap, @@ -247,7 +249,7 @@ export function useLayerKeyboardClearance( } // In layout px, the box's and the keyboard's units: WebKit reports // `innerHeight` in the pinch-zoomed visual viewport's px. - const viewport = layoutViewportHeight(win); + const viewport = getKeyboardViewportHeight(win); const box = settledBox(layer); const height = keyboardClearanceCap(box, viewport, inset); if (height == null) { diff --git a/packages/core/src/keyboard/README.md b/packages/core/src/keyboard/README.md index 25dcdcb2b9795..60ebf8ffd2799 100644 --- a/packages/core/src/keyboard/README.md +++ b/packages/core/src/keyboard/README.md @@ -37,8 +37,10 @@ that times motion with it. Everything here is internal to resize, and with `{focus: true}` focus moves, whose input-stream subscriptions exist only while such a subscriber does) from one set of listeners per window, however many components subscribe, and `getKeyboardHeight(window)` gives a reader the - height that report measured. No other code in core listens to the visual - viewport for the keyboard. + height that report measured. `getKeyboardViewportHeight(window)` gives the + layout viewport's height that report measured it against, so a reader lays + out with no second read of the visual viewport. No other code in core + listens to the visual viewport for the keyboard. - **Focus (FR4):** `refuseFocusReveal(document, {claim, park, isActive})` delivers every focus move a surface claims with `preventScroll`, on the capture-phase `blur` before the browser's own focus step, so the browser @@ -174,7 +176,7 @@ that times motion with it. Everything here is internal to Layout follows the height (FR3), through a functional style on your own element, so it is right with no animation running; for an element docked at -the bottom, `lift = height > 0 ? max(0, restingBottom − (layoutViewportHeight(window) − height)) : 0`. +the bottom, `lift = height > 0 ? max(0, restingBottom − (getKeyboardViewportHeight(window) − height)) : 0`. An animation that rides the keyboard is optional polish on top, timed by the guide: diff --git a/packages/core/src/keyboard/keyboardHeight.test.ts b/packages/core/src/keyboard/keyboardHeight.test.ts index b9d3ef746f6c6..234e44242dcb7 100644 --- a/packages/core/src/keyboard/keyboardHeight.test.ts +++ b/packages/core/src/keyboard/keyboardHeight.test.ts @@ -36,6 +36,7 @@ import { watchRevealPan, type KeyboardHeightEvent, } from './keyboardHeight'; +import {coveredBy} from './keyboardTarget'; type FakeViewport = EventTarget & { height: number; @@ -193,6 +194,65 @@ describe('the layout viewport WebKit reports under a pinch zoom (FR2)', () => { expect(measureKeyboardHeight(window)).toBe(337); }); + it('reads the visual viewport height once while Safari pans, the pan check included (INV11)', () => { + safari({ + innerHeight: 377, + innerWidth: 402, + height: 377, + scale: 1, + offsetTop: 337, + }); + rootHeight(714); + let height = viewport.height; + let reads = 0; + Object.defineProperty(viewport, 'height', { + configurable: true, + get: () => { + reads += 1; + return height; + }, + set: (value: number) => { + height = value; + }, + }); + expect(measureKeyboardHeight(window)).toBe(337); + expect(reads).toBe(1); + }); + + it('reads the visual viewport height once per report while Safari pans, a reader laying out included (INV11)', () => { + safari({innerHeight: 714, innerWidth: 402, height: 714, scale: 1}); + rootHeight(714); + const covered: number[] = []; + const stop = subscribeKeyboardHeight(window, event => { + if (event.type === 'viewport') { + covered.push(coveredBy(700, event.height, 0, window)); + } + }); + let height = viewport.height; + let reads = 0; + Object.defineProperty(viewport, 'height', { + configurable: true, + get: () => { + reads += 1; + return height; + }, + set: (value: number) => { + height = value; + }, + }); + try { + vi.stubGlobal('innerHeight', 377); + reads = 0; + report({height: 377, offsetTop: 337}); + // A 337 px keyboard under a 714 px layout viewport covers 323 px of a + // bottom edge at 700. + expect(covered).toEqual([323]); + expect(reads).toBe(1); + } finally { + stop(); + } + }); + it('reads none where the layout viewport truly shrinks for the keyboard, as under resizes-content', () => { safari({innerHeight: 377, innerWidth: 402, height: 377, scale: 1}); rootHeight(377); diff --git a/packages/core/src/keyboard/keyboardHeight.ts b/packages/core/src/keyboard/keyboardHeight.ts index d3add41236e85..9e2828188fb4e 100644 --- a/packages/core/src/keyboard/keyboardHeight.ts +++ b/packages/core/src/keyboard/keyboardHeight.ts @@ -8,7 +8,8 @@ * resize, scroll and touch), and the layout viewport's height * @output The on-screen keyboard module: measureKeyboardHeight, * isKeyboardMeasurable, hasVirtualKeyboard, subscribeKeyboardHeight, - * getKeyboardHeight, isTextEntryControl, findTextEntryControl, + * getKeyboardHeight, getKeyboardViewportHeight, isTextEntryControl, + * findTextEntryControl, * refuseFocusReveal and watchRevealPan * @position Core's one source for the on-screen keyboard. It is internal to * core and never exported from the package. @@ -70,13 +71,29 @@ export function isKeyboardMeasurable(win: Window): boolean { * `isKeyboardMeasurable` is false. */ export function measureKeyboardHeight(win: Window): number { + return measureKeyboard(win).height; +} + +/** + * The keyboard height, and the layout viewport's height it is measured + * against (null where the keyboard is not measurable). It reads the visual + * viewport's height once. + */ +function measureKeyboard(win: Window): { + height: number; + viewportHeight: number | null; +} { const viewport = win.visualViewport; if (viewport == null || !isKeyboardMeasurable(win)) { - return 0; + return {height: 0, viewportHeight: null}; } - const height = - layoutViewportHeight(win) - viewport.height * (viewport.scale ?? 1); - return height > KEYBOARD_EPSILON ? Math.round(height * 100) / 100 : 0; + const visualHeight = viewport.height; + const viewportHeight = layoutViewportHeight(win, visualHeight); + const height = viewportHeight - visualHeight * (viewport.scale ?? 1); + return { + height: height > KEYBOARD_EPSILON ? Math.round(height * 100) / 100 : 0, + viewportHeight, + }; } /** A report from the keyboard module, in the order the window raised them. */ @@ -100,6 +117,8 @@ export type KeyboardHeightEvent = interface KeyboardStore { height: number; + /** The layout viewport's height that `height` was measured against. */ + viewportHeight: number | null; listeners: Set<(event: KeyboardHeightEvent) => void>; /** The subscribers that asked for focus moves too (the layout guide). */ focusListeners: Set<(event: KeyboardHeightEvent) => void>; @@ -114,6 +133,7 @@ function storeOf(win: Window): KeyboardStore { if (store == null) { store = { height: 0, + viewportHeight: null, listeners: new Set(), focusListeners: new Set(), detach: null, @@ -146,21 +166,28 @@ function isHideStep(win: Window, shown: number, height: number): boolean { function attach(win: Window, store: KeyboardStore): () => void { const viewport = win.visualViewport; - store.height = measureKeyboardHeight(win); + const first = measureKeyboard(win); + store.height = first.height; + store.viewportHeight = first.viewportHeight; // Set while a step of a hide waits. The store keeps the height it reported, // so every reader lays out once, when the keyboard comes to rest. let stepTimer: ReturnType | null = null; let stepHeight = 0; let stepLimit = 0; - const deliver = (height: number, timeStamp: number) => { + const deliver = ( + {height, viewportHeight}: ReturnType, + timeStamp: number, + ) => { store.height = height; + store.viewportHeight = viewportHeight; const report: KeyboardHeightEvent = {type: 'viewport', height, timeStamp}; for (const listener of [...store.listeners]) { listener(report); } }; const onViewport = (event: Event) => { - const height = measureKeyboardHeight(win); + const measured = measureKeyboard(win); + const {height} = measured; if (isHideStep(win, store.height, height)) { // A pan reports the same step again, and leaves the wait as it is. if ( @@ -180,7 +207,7 @@ function attach(win: Window, store: KeyboardStore): () => void { stepTimer = setTimeout( () => { stepTimer = null; - deliver(measureKeyboardHeight(win), win.performance.now()); + deliver(measureKeyboard(win), win.performance.now()); }, Math.min(HIDE_STEP_WAIT_MS, stepLimit - now), ); @@ -190,7 +217,7 @@ function attach(win: Window, store: KeyboardStore): () => void { clearTimeout(stepTimer); stepTimer = null; } - deliver(height, event.timeStamp); + deliver(measured, event.timeStamp); }; viewport?.addEventListener('resize', onViewport); viewport?.addEventListener('scroll', onViewport); @@ -295,6 +322,18 @@ export function getKeyboardHeight(win: Window): number { return store?.detach != null ? store.height : measureKeyboardHeight(win); } +/** + * The layout viewport's height, CSS px, that `getKeyboardHeight` was + * measured against. A reader that lays out against the keyboard reads both + * from the module, so a keyboard change reads the visual viewport once. + */ +export function getKeyboardViewportHeight(win: Window): number { + const store = stores.get(win); + return store?.detach != null && store.viewportHeight != null + ? store.viewportHeight + : layoutViewportHeight(win); +} + /** * Whether focusing `element` brings up a keyboard for typing text. A control * with `inputmode="none"` brings up none. diff --git a/packages/core/src/keyboard/keyboardTarget.ts b/packages/core/src/keyboard/keyboardTarget.ts index 4b5d41d1cff2b..793114c821370 100644 --- a/packages/core/src/keyboard/keyboardTarget.ts +++ b/packages/core/src/keyboard/keyboardTarget.ts @@ -15,7 +15,6 @@ import {animatorFor, type Animator} from '../motion/animator'; import {isCoarsePointer} from '../utils/coarsePointer'; import {subscribeInput} from '../utils/inputStream'; -import {layoutViewportHeight} from '../utils/layoutViewport'; import {scrollingAncestor} from '../utils/scrollingAncestor'; import { KEYBOARD_EPSILON, @@ -23,6 +22,7 @@ import { findTextEntryControl, focusAtPoint, getKeyboardHeight, + getKeyboardViewportHeight, isKeyboardMeasurable, isTextEntryControl, refuseFocusReveal, @@ -157,7 +157,7 @@ function dockOf( node: HTMLElement, view: Window, ): 'docked' | 'fills-viewport' | 'floating' { - const viewportBottom = layoutViewportHeight(view); + const viewportBottom = getKeyboardViewportHeight(view); const box = node.getBoundingClientRect(); if (box.height >= viewportBottom - KEYBOARD_EPSILON) { return 'fills-viewport'; @@ -285,7 +285,7 @@ export function coveredBy( offset: number, win: Window, ): number { - const covered = bottom + offset - (layoutViewportHeight(win) - height); + const covered = bottom + offset - (getKeyboardViewportHeight(win) - height); return height > 0 && covered > KEYBOARD_EPSILON ? covered : 0; } @@ -359,7 +359,7 @@ function revealDistance( shift = 0, ): number { const doc = field.ownerDocument; - const keyboardTop = layoutViewportHeight(win) - height; + const keyboardTop = getKeyboardViewportHeight(win) - height; let safeTop = 0; let safeBottom = height > KEYBOARD_EPSILON ? keyboardTop - offset : keyboardTop; @@ -590,7 +590,7 @@ function exceedsRoom( return ( height > KEYBOARD_EPSILON && container.getBoundingClientRect().height > - layoutViewportHeight(win) - height + KEYBOARD_EPSILON + getKeyboardViewportHeight(win) - height + KEYBOARD_EPSILON ); } diff --git a/packages/core/src/keyboard/useMobileKeyboard.ts b/packages/core/src/keyboard/useMobileKeyboard.ts index a9a3e889770be..9aaebd4683bfa 100644 --- a/packages/core/src/keyboard/useMobileKeyboard.ts +++ b/packages/core/src/keyboard/useMobileKeyboard.ts @@ -25,7 +25,6 @@ import { import {KEYBOARD_SPRING} from '../motion/uikit/springs'; import {deepActiveElement} from '../utils/flatTree'; import {subscribeInput} from '../utils/inputStream'; -import {layoutViewportHeight} from '../utils/layoutViewport'; import {isDocument, isNode} from '../utils/nodeType'; import {observeResize} from '../utils/sharedResizeObserver'; import {TOUCH_QUERY} from '../utils/touchPresentation'; @@ -33,6 +32,7 @@ import { KEYBOARD_EPSILON, findTextEntryControl, getKeyboardHeight, + getKeyboardViewportHeight, isKeyboardMeasurable, isTextEntryControl, refuseFocusReveal, @@ -108,7 +108,7 @@ interface KeyboardGeometry { } function getObstructionTop(): number { - return layoutViewportHeight(window) - getKeyboardHeight(window); + return getKeyboardViewportHeight(window) - getKeyboardHeight(window); } // Windows whose browser revealed a focused field itself right after the diff --git a/packages/core/src/utils/layoutViewport.ts b/packages/core/src/utils/layoutViewport.ts index e0615d0ab9d12..406723375becc 100644 --- a/packages/core/src/utils/layoutViewport.ts +++ b/packages/core/src/utils/layoutViewport.ts @@ -3,7 +3,7 @@ /** * @file layoutViewport.ts * @input A window: its innerHeight and innerWidth, its visual viewport's - * width and scale, and its document's root box + * width, scale and height, and its document's root box * @output layoutViewportHeight, the layout viewport's height in layout px * less a classic horizontal scrollbar, and __resetLayoutViewportForTest * @position Internal shared measurement, not re-exported from the utils @@ -27,8 +27,13 @@ * 377 of 714. The root's client height stays 714. So at a scale of 1, where * `innerHeight` equals the visual viewport's height, the function takes the * larger of the two. The root must also be as wide as the visual viewport. + * A caller that has read the visual viewport's height for the same layout + * passes it as `visualHeight`, so that one layout reads it once. */ -export function layoutViewportHeight(win: Window): number { +export function layoutViewportHeight( + win: Window, + visualHeight?: number, +): number { const viewport = win.visualViewport; const width = viewport?.width ?? 0; if (viewport == null || !(width > 0)) { @@ -54,7 +59,7 @@ export function layoutViewportHeight(win: Window): number { if ( root != null && Math.abs(scale - 1) < 0.001 && - Math.abs(win.innerHeight - viewport.height) < 1 && + Math.abs(win.innerHeight - (visualHeight ?? viewport.height)) < 1 && Math.abs(root.clientWidth - width) < 1 ) { return Math.max(layout, root.clientHeight);