Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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,
type InteractionModality,
Expand Down Expand Up @@ -230,8 +230,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