Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/keyboard-height-safari-pan.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/specs/AST-099-mobile-keyboard/spec.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions packages/core/src/Chat/useChatKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
claimTapFocus,
findTextEntryControl,
getKeyboardHeight,
getKeyboardViewportHeight,
isTextEntryControl,
refuseFocusReveal,
subscribeKeyboardHeight,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/Layer/useLayerKeyboardClearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/keyboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -33,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
Expand Down Expand Up @@ -170,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 − (innerHeight − 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:

Expand Down
110 changes: 110 additions & 0 deletions packages/core/src/keyboard/keyboardHeight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
watchRevealPan,
type KeyboardHeightEvent,
} from './keyboardHeight';
import {coveredBy} from './keyboardTarget';

type FakeViewport = EventTarget & {
height: number;
Expand Down Expand Up @@ -138,6 +139,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,
Expand All @@ -146,9 +158,107 @@ 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 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);
expect(measureKeyboardHeight(window)).toBe(0);
});

it('reads the same keyboard scrolled and pinch-zoomed', () => {
safari({
innerHeight: 536,
Expand Down
59 changes: 49 additions & 10 deletions packages/core/src/keyboard/keyboardHeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand All @@ -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>;
Expand All @@ -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,
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
let stepHeight = 0;
let stepLimit = 0;
const deliver = (height: number, timeStamp: number) => {
const deliver = (
{height, viewportHeight}: ReturnType<typeof measureKeyboard>,
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 (
Expand All @@ -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),
);
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading