diff --git a/.changeset/mosaic-sheet-keyboard-focus.md b/.changeset/mosaic-sheet-keyboard-focus.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-sheet-keyboard-focus.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts
new file mode 100644
index 00000000000..3935a3413a4
--- /dev/null
+++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { guardKeyboardFocus } from '../keyboard-focus';
+
+class FakeViewport extends EventTarget {
+ height = 768;
+ offsetTop = 0;
+ scale = 1;
+}
+
+const initialScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY');
+let viewport: FakeViewport;
+let container: HTMLDivElement;
+let release: () => void;
+
+function rect(top: number, height: number): DOMRect {
+ return new DOMRect(0, top, 320, height);
+}
+
+function openKeyboard() {
+ viewport.height = 400;
+ viewport.dispatchEvent(new Event('resize'));
+}
+
+function scroller() {
+ const element = document.createElement('div');
+ element.style.overflowY = 'auto';
+ Object.defineProperties(element, {
+ clientHeight: { value: 768 },
+ scrollHeight: { value: 1400 },
+ });
+ element.getBoundingClientRect = () => rect(0, 768);
+ element.scrollTo = vi.fn();
+ return element;
+}
+
+function field(top: number) {
+ const input = document.createElement('input');
+ input.getBoundingClientRect = () => rect(top, 40);
+ return input;
+}
+
+beforeEach(() => {
+ viewport = new FakeViewport();
+ Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport });
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 768 });
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ release = guardKeyboardFocus(container);
+});
+
+afterEach(() => {
+ release();
+ vi.restoreAllMocks();
+ if (initialScrollY) {
+ Object.defineProperty(window, 'scrollY', initialScrollY);
+ }
+ container.remove();
+ Reflect.deleteProperty(window, 'visualViewport');
+ Reflect.deleteProperty(document.documentElement, 'clientHeight');
+});
+
+describe('guardKeyboardFocus', () => {
+ it('centers the focused field in the band above the keyboard once it opens', () => {
+ const scroll = scroller();
+ const input = field(600);
+ scroll.append(input);
+ container.append(scroll);
+ input.focus();
+
+ openKeyboard();
+
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 420, behavior: 'smooth' });
+ });
+
+ it('leaves a field that is already visible above the keyboard where it is', () => {
+ const scroll = scroller();
+ const input = field(100);
+ scroll.append(input);
+ container.append(scroll);
+ input.focus();
+
+ openKeyboard();
+
+ expect(scroll.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('reveals a field that sits above the visible band when the page is panned', () => {
+ const scroll = scroller();
+ scroll.scrollTop = 500;
+ const input = field(100);
+ scroll.append(input);
+ container.append(scroll);
+ input.focus();
+ viewport.offsetTop = 200;
+
+ openKeyboard();
+
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 220, behavior: 'smooth' });
+ });
+
+ it('centers the next field when focus moves with the keyboard up', () => {
+ const scroll = scroller();
+ const first = field(100);
+ const second = field(700);
+ scroll.append(first, second);
+ container.append(scroll);
+ first.focus();
+ openKeyboard();
+
+ second.focus();
+
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 520, behavior: 'smooth' });
+ });
+
+ it('moves focus between fields without the browser panning while the keyboard is up', () => {
+ const scroll = scroller();
+ const first = field(100);
+ const second = field(200);
+ scroll.append(first, second);
+ container.append(scroll);
+ first.focus();
+ openKeyboard();
+ const focus = vi.spyOn(second, 'focus');
+
+ second.focus();
+
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
+ expect(document.activeElement).toBe(second);
+ });
+
+ it('leaves focus to the browser while the keyboard is down', () => {
+ const scroll = scroller();
+ const first = field(100);
+ const second = field(200);
+ scroll.append(first, second);
+ container.append(scroll);
+ first.focus();
+ const focus = vi.spyOn(second, 'focus');
+
+ second.focus();
+
+ expect(focus).toHaveBeenCalledTimes(1);
+ expect(focus).toHaveBeenCalledWith();
+ });
+
+ it('puts the page back when the browser scrolls it to reveal a field with the keyboard up', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ openKeyboard();
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 120 });
+
+ window.dispatchEvent(new Event('scroll'));
+
+ expect(scrollTo).toHaveBeenCalledWith({ left: 0, top: 0, behavior: 'instant' });
+ });
+
+ it('leaves page scrolling alone while the keyboard is down', () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 120 });
+
+ window.dispatchEvent(new Event('scroll'));
+
+ expect(scrollTo).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts
new file mode 100644
index 00000000000..e249cee6e6c
--- /dev/null
+++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { guardKeyboardTouch } from '../keyboard-touch';
+
+let container: HTMLDivElement;
+let release: () => void;
+
+function scroller(scrollTop: number) {
+ const element = document.createElement('div');
+ element.style.overflowY = 'auto';
+ Object.defineProperties(element, {
+ clientHeight: { value: 300 },
+ scrollHeight: { value: 900 },
+ scrollTop: { value: scrollTop },
+ });
+ return element;
+}
+
+function touch(target: Element, type: string, y: number, fingers = 1) {
+ const point = { clientX: 10, clientY: y };
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperties(event, {
+ touches: { value: type === 'touchend' ? [] : Array.from({ length: fingers }, () => point) },
+ changedTouches: { value: [point] },
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ release = guardKeyboardTouch(container);
+});
+
+afterEach(() => {
+ release();
+ container.remove();
+});
+
+describe('guardKeyboardTouch', () => {
+ it('focuses a text field on tap', () => {
+ const input = document.createElement('input');
+ container.append(input);
+
+ touch(input, 'touchstart', 100);
+ touch(input, 'touchend', 102);
+
+ expect(input).toHaveFocus();
+ });
+
+ it('leaves a text field alone when a drag ends on it', () => {
+ const input = document.createElement('input');
+ container.append(input);
+
+ touch(input, 'touchstart', 100);
+ touch(input, 'touchmove', 300);
+ touch(input, 'touchend', 300);
+
+ expect(input).not.toHaveFocus();
+ });
+
+ it('leaves a text field alone when a drag returns to where it started', () => {
+ const input = document.createElement('input');
+ container.append(input);
+
+ touch(input, 'touchstart', 100);
+ touch(input, 'touchmove', 150);
+ touch(input, 'touchend', 102);
+
+ expect(input).not.toHaveFocus();
+ });
+
+ it('leaves a text field alone when a second finger joins the touch', () => {
+ const input = document.createElement('input');
+ container.append(input);
+
+ touch(input, 'touchstart', 100);
+ touch(input, 'touchstart', 100, 2);
+ touch(input, 'touchend', 101);
+
+ expect(input).not.toHaveFocus();
+ });
+
+ it('submits on a tap of a submit button and keeps focus on the field', () => {
+ const input = document.createElement('input');
+ const submit = document.createElement('button');
+ submit.type = 'submit';
+ const onClick = vi.fn();
+ submit.addEventListener('click', onClick);
+ container.append(input, submit);
+ input.focus();
+
+ touch(submit, 'touchstart', 100);
+ const end = touch(submit, 'touchend', 101);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(end.defaultPrevented).toBe(true);
+ expect(input).toHaveFocus();
+ });
+
+ it('leaves a submit tap to the browser when no field has focus', () => {
+ const submit = document.createElement('button');
+ submit.type = 'submit';
+ container.append(submit);
+
+ touch(submit, 'touchstart', 100);
+ const end = touch(submit, 'touchend', 101);
+
+ expect(end.defaultPrevented).toBe(false);
+ });
+
+ it('cancels a drag that nothing under it can scroll', () => {
+ const block = document.createElement('div');
+ container.append(block);
+
+ touch(block, 'touchstart', 100);
+ const move = touch(block, 'touchmove', 150);
+
+ expect(move.defaultPrevented).toBe(true);
+ });
+
+ it('lets a drag through when an ancestor can scroll that way', () => {
+ const scroll = scroller(200);
+ const block = document.createElement('div');
+ scroll.append(block);
+ container.append(scroll);
+
+ touch(block, 'touchstart', 100);
+ const move = touch(block, 'touchmove', 150);
+
+ expect(move.defaultPrevented).toBe(false);
+ });
+
+ it('cancels a drag past the end an ancestor has already reached', () => {
+ const scroll = scroller(0);
+ const block = document.createElement('div');
+ scroll.append(block);
+ container.append(scroll);
+
+ touch(block, 'touchstart', 100);
+ const move = touch(block, 'touchmove', 150);
+
+ expect(move.defaultPrevented).toBe(true);
+ });
+
+ it('lets a slider take its own drag', () => {
+ const slider = document.createElement('input');
+ slider.type = 'range';
+ container.append(slider);
+
+ touch(slider, 'touchstart', 100);
+ const move = touch(slider, 'touchmove', 150);
+
+ expect(move.defaultPrevented).toBe(false);
+ });
+});
diff --git a/packages/mosaic/src/components/dialog/__tests__/sheet-keyboard.webkit.test.tsx b/packages/mosaic/src/components/dialog/__tests__/sheet-keyboard.webkit.test.tsx
new file mode 100644
index 00000000000..420604a7d84
--- /dev/null
+++ b/packages/mosaic/src/components/dialog/__tests__/sheet-keyboard.webkit.test.tsx
@@ -0,0 +1,123 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { Card } from '../../card';
+import { Dialog } from '../dialog';
+
+const KEYBOARD = 336;
+
+class FakeViewport extends EventTarget {
+ width = window.innerWidth;
+ height = window.innerHeight;
+ offsetTop = 0;
+ offsetLeft = 0;
+ scale = 1;
+}
+
+let viewport: FakeViewport;
+
+beforeEach(async () => {
+ await page.viewport(375, 667);
+ viewport = new FakeViewport();
+ Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport });
+});
+
+afterEach(() => {
+ cleanup();
+ Reflect.deleteProperty(window, 'visualViewport');
+});
+
+describe('sheet with the on-screen keyboard in WebKit', () => {
+ it.each([0, 150])('keeps the title and the actions reachable in the visible band (pan %ipx)', async pan => {
+ render();
+ await settle();
+
+ await keyboard({ open: true, pan });
+
+ expectReachable();
+ });
+
+ it('keeps the title and the actions reachable when the content grows with the keyboard up', async () => {
+ const { rerender } = render();
+ await settle();
+ await keyboard({ open: true, pan: 150 });
+
+ rerender();
+ await settle();
+
+ expectReachable();
+ });
+
+ it('scrolls the focused field above the keyboard once it opens', async () => {
+ render();
+ await settle();
+ const field = screen.getByRole('textbox', { name: 'New' });
+ field.focus({ preventScroll: true });
+
+ await keyboard({ open: true });
+
+ await expect.poll(() => field.getBoundingClientRect().bottom).toBeLessThanOrEqual(band().bottom);
+ expect(field.getBoundingClientRect().top).toBeGreaterThanOrEqual(band().top);
+ });
+
+ it('leaves the sheet alone when a pinch zoom shrinks the visible band', async () => {
+ render();
+ await settle();
+
+ viewport.scale = 2;
+ await keyboard({ open: true });
+
+ expect(getComputedStyle(document.documentElement).getPropertyValue('--_cl-keyboard-inset')).toBe('0px');
+ });
+});
+
+function Sheet({ spacer }: { spacer: number }) {
+ return (
+
+
+
+
+ Change password
+
+
+
+
+
+
+
+
+ );
+}
+
+async function keyboard({ open, pan = 0 }: { open: boolean; pan?: number }) {
+ viewport.height = window.innerHeight - (open ? KEYBOARD : 0);
+ viewport.offsetTop = pan;
+ viewport.dispatchEvent(new Event('resize'));
+ viewport.dispatchEvent(new Event('scroll'));
+ await settle();
+}
+
+async function settle() {
+ await new Promise(requestAnimationFrame);
+ await new Promise(requestAnimationFrame);
+ await Promise.all(document.getAnimations().map(animation => animation.finished));
+}
+
+function band() {
+ return { top: viewport.offsetTop, bottom: viewport.offsetTop + viewport.height };
+}
+
+function expectReachable() {
+ const popup = document.querySelector('.cl-dialog-popup');
+ expect(popup).toBeInstanceOf(HTMLElement);
+ if (!(popup instanceof HTMLElement)) {
+ return;
+ }
+ popup.scrollTop = 0;
+ expect(screen.getByText('Change password').getBoundingClientRect().top).toBeGreaterThanOrEqual(band().top);
+ popup.scrollTop = popup.scrollHeight;
+ expect(screen.getByRole('button', { name: 'Save' }).getBoundingClientRect().bottom).toBeLessThanOrEqual(
+ band().bottom,
+ );
+}
diff --git a/packages/mosaic/src/components/dialog/dialog.styles.ts b/packages/mosaic/src/components/dialog/dialog.styles.ts
index 40a3d88c315..835010e2796 100644
--- a/packages/mosaic/src/components/dialog/dialog.styles.ts
+++ b/packages/mosaic/src/components/dialog/dialog.styles.ts
@@ -146,6 +146,7 @@ export const styles = stylex.create({
// regardless of order, so this wins without depending on argument order. Falls back to `0px`,
// so it is inert until `acquireKeyboardInset` has something to report.
paddingBlockEnd: 'calc(var(--_cl-dialog-inset) + var(--_cl-keyboard-inset, 0px))',
+ paddingBlockStart: 'calc(var(--_cl-dialog-inset) + var(--_cl-keyboard-offset, 0px))',
// A grid item's automatic minimum would otherwise hold this to its content and defeat the
// definite row `viewportVariants.profile` pins.
minHeight: 0,
diff --git a/packages/mosaic/src/components/dialog/dialog.tsx b/packages/mosaic/src/components/dialog/dialog.tsx
index 6e792c4bab1..32646f80cc6 100644
--- a/packages/mosaic/src/components/dialog/dialog.tsx
+++ b/packages/mosaic/src/components/dialog/dialog.tsx
@@ -30,6 +30,7 @@ import {
viewportCompactPlacements,
viewportVariants,
} from './dialog.styles';
+import { guardKeyboardFocus } from './keyboard-focus';
import { acquireKeyboardInset } from './keyboard-inset';
import { guardKeyboardTouch } from './keyboard-touch';
@@ -314,6 +315,7 @@ function Viewport({
const trackRef = React.useRef(null);
React.useEffect(() => acquireKeyboardInset(), []);
React.useEffect(() => (trackRef.current ? guardKeyboardTouch(trackRef.current) : undefined), []);
+ React.useEffect(() => (trackRef.current ? guardKeyboardFocus(trackRef.current) : undefined), []);
return (
void {
+ const viewport = window.visualViewport;
+ if (!viewport) {
+ return () => {};
+ }
+
+ const onFocusIn = (event: FocusEvent) => {
+ if (opensKeyboard(event.target)) {
+ revealAboveKeyboard(event.target, element, viewport);
+ }
+ };
+ const onResize = () => {
+ const active = document.activeElement;
+ if (opensKeyboard(active) && element.contains(active)) {
+ revealAboveKeyboard(active, element, viewport);
+ }
+ };
+ let redirecting = false;
+ const onBlur = (event: FocusEvent) => {
+ const next = event.relatedTarget;
+ if (redirecting || !opensKeyboard(next) || !element.contains(next) || measureKeyboardInset() === 0) {
+ return;
+ }
+ redirecting = true;
+ next.focus({ preventScroll: true });
+ redirecting = false;
+ };
+ const pinned = { left: window.scrollX, top: window.scrollY };
+ const onWindowScroll = () => {
+ if (measureKeyboardInset() === 0 || (window.scrollX === pinned.left && window.scrollY === pinned.top)) {
+ return;
+ }
+ window.scrollTo({ ...pinned, behavior: 'instant' });
+ };
+
+ element.addEventListener('focusin', onFocusIn);
+ element.addEventListener('blur', onBlur, true);
+ viewport.addEventListener('resize', onResize);
+ window.addEventListener('scroll', onWindowScroll);
+ return () => {
+ element.removeEventListener('focusin', onFocusIn);
+ element.removeEventListener('blur', onBlur, true);
+ viewport.removeEventListener('resize', onResize);
+ window.removeEventListener('scroll', onWindowScroll);
+ };
+}
+
+function revealAboveKeyboard(field: HTMLElement, boundary: HTMLElement, viewport: VisualViewport): void {
+ const scroller = scrollParent(field, boundary);
+ if (!scroller || measureKeyboardInset() === 0) {
+ return;
+ }
+ const bounds = scroller.getBoundingClientRect();
+ const target = field.getBoundingClientRect();
+ const top = Math.max(bounds.top, viewport.offsetTop);
+ const bottom = Math.min(bounds.bottom, viewport.offsetTop + viewport.height);
+ if (target.top >= top && target.bottom <= bottom) {
+ return;
+ }
+ const centered = scroller.scrollTop + target.top - top - ((bottom - top) / 2 - target.height / 2);
+ scroller.scrollTo({
+ top: Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, centered)),
+ behavior: 'smooth',
+ });
+}
+
+function scrollParent(field: HTMLElement, boundary: HTMLElement): HTMLElement | null {
+ for (let node = field.parentElement; node && boundary.contains(node); node = node.parentElement) {
+ if (/(auto|scroll)/.test(getComputedStyle(node).overflowY) && node.scrollHeight > node.clientHeight) {
+ return node;
+ }
+ }
+ return null;
+}
diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts
index 4908af21327..308f2712a2e 100644
--- a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts
+++ b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts
@@ -12,24 +12,31 @@ let viewport: FakeViewport;
// What a fixed `100svh` box measures: Safari keeps it at the full height, Chrome on iOS shrinks it
// to the visible band as the keyboard opens.
let smallViewportHeight = 768;
+// What a fixed box from the top edge to the bottom edge measures: the box the dialog's overlay
+// fills, which iOS can make taller than the document's client height.
+let fixedBoxHeight = 768;
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
function inset() {
return document.documentElement.style.getPropertyValue('--_cl-keyboard-inset');
}
-function nextFrame() {
- return new Promise(resolve => requestAnimationFrame(resolve));
+function offset() {
+ return document.documentElement.style.getPropertyValue('--_cl-keyboard-offset');
}
beforeEach(() => {
viewport = new FakeViewport();
smallViewportHeight = 768;
+ fixedBoxHeight = 768;
Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get(this: HTMLElement) {
- return this.style.height === '100svh' ? smallViewportHeight : 0;
+ if (this.style.height === '100svh') {
+ return smallViewportHeight;
+ }
+ return this.style.position === 'fixed' && this.style.bottom === '0px' ? fixedBoxHeight : 0;
},
});
});
@@ -42,38 +49,61 @@ afterEach(() => {
});
describe('acquireKeyboardInset', () => {
- it('publishes the latest inset once per frame', async () => {
+ it('publishes the inset as soon as the visible band changes', () => {
const release = acquireKeyboardInset();
expect(inset()).toBe('0px');
viewport.height = 500;
viewport.dispatchEvent(new Event('resize'));
+ expect(inset()).toBe('268px');
+
viewport.offsetTop = 100;
viewport.dispatchEvent(new Event('scroll'));
- expect(inset()).toBe('0px');
-
- await nextFrame();
expect(inset()).toBe('168px');
release();
});
- it('drops a pending update when released', async () => {
+ it('publishes how far the browser has panned the visible band while the keyboard is up', () => {
+ const release = acquireKeyboardInset();
+ viewport.offsetTop = 40;
+ viewport.dispatchEvent(new Event('scroll'));
+ expect(offset()).toBe('0px');
+
+ viewport.height = 500;
+ viewport.offsetTop = 100;
+ viewport.dispatchEvent(new Event('resize'));
+ expect(offset()).toBe('100px');
+
+ release();
+ expect(offset()).toBe('');
+ });
+
+ it('measures the keyboard against the box the overlay fills, not the document height', () => {
+ fixedBoxHeight = 852;
+ smallViewportHeight = 852;
const release = acquireKeyboardInset();
+
viewport.height = 500;
viewport.dispatchEvent(new Event('resize'));
+
+ expect(inset()).toBe('352px');
release();
+ });
- await nextFrame();
+ it('stops publishing when released', () => {
+ const release = acquireKeyboardInset();
+ release();
+
+ viewport.height = 500;
+ viewport.dispatchEvent(new Event('resize'));
expect(inset()).toBe('');
});
- it('leaves the inset at zero when the browser resizes for the keyboard itself', async () => {
+ it('leaves the inset at zero when the browser resizes for the keyboard itself', () => {
const release = acquireKeyboardInset();
viewport.height = 500;
smallViewportHeight = 500;
viewport.dispatchEvent(new Event('resize'));
-
- await nextFrame();
expect(inset()).toBe('0px');
release();
});
diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.ts b/packages/mosaic/src/components/dialog/keyboard-inset.ts
index 24814dd3524..0ed7118eae5 100644
--- a/packages/mosaic/src/components/dialog/keyboard-inset.ts
+++ b/packages/mosaic/src/components/dialog/keyboard-inset.ts
@@ -14,20 +14,26 @@
* - A sheet stays flush to the bottom edge and pads its popup by it instead, so the sheet reaches
* down to the keyboard with no gap where iOS floats its address bar, which `visualViewport`
* counts as part of the keyboard.
+ *
+ * While the keyboard is up, iOS can also pan the visual viewport down to reveal a field, which
+ * moves the top of the visible band below the top of the overlay. That pan is published as
+ * `--_cl-keyboard-offset`, and the track pads its top edge by it, so the whole dialog stays inside
+ * the band and scrolls within it rather than leaving its top out of reach above the screen.
*/
const PROPERTY = '--_cl-keyboard-inset';
+const OFFSET_PROPERTY = '--_cl-keyboard-offset';
// Keyboard-sized changes only: browser toolbars collapsing and expanding stay below this.
const KEYBOARD_THRESHOLD = 60;
let listeners = 0;
let detach: (() => void) | null = null;
-let frame = 0;
// A fixed `100svh` box. Chrome on iOS shrinks `svh` to the keyboard before it resizes the layout
// viewport to match, so a box that already fits the visible band means the browser is moving fixed
// content itself, and an inset would lift the dialog twice. Safari leaves `svh` alone.
let smallViewportProbe: HTMLElement | null = null;
+let fixedBoxProbe: HTMLElement | null = null;
/**
* The gap between the bottom of the layout viewport and the bottom of the visual viewport — which
@@ -36,7 +42,7 @@ let smallViewportProbe: HTMLElement | null = null;
* `offsetTop` is part of it because iOS scrolls the visual viewport to bring a focused field into
* view, so the visible band moves as well as shrinks.
*/
-function measure(): number {
+export function measureKeyboardInset(): number {
const viewport = window.visualViewport;
if (!viewport) {
return 0;
@@ -49,21 +55,15 @@ function measure(): number {
if (smallViewportProbe && smallViewportProbe.offsetHeight - viewport.height <= KEYBOARD_THRESHOLD) {
return 0;
}
- return Math.max(0, Math.round(document.documentElement.clientHeight - (viewport.height + viewport.offsetTop)));
+ const layoutHeight = fixedBoxProbe?.offsetHeight ?? document.documentElement.clientHeight;
+ return Math.max(0, Math.round(layoutHeight - (viewport.height + viewport.offsetTop)));
}
function publish(): void {
- document.documentElement.style.setProperty(PROPERTY, `${measure()}px`);
-}
-
-function schedulePublish(): void {
- if (frame) {
- return;
- }
- frame = requestAnimationFrame(() => {
- frame = 0;
- publish();
- });
+ const inset = measureKeyboardInset();
+ const offset = inset > 0 ? Math.round(Math.max(0, window.visualViewport?.offsetTop ?? 0)) : 0;
+ document.documentElement.style.setProperty(PROPERTY, `${inset}px`);
+ document.documentElement.style.setProperty(OFFSET_PROPERTY, `${offset}px`);
}
/**
@@ -83,17 +83,21 @@ export function acquireKeyboardInset(): () => void {
smallViewportProbe.style.cssText =
'position:fixed;top:0;height:100svh;width:0;visibility:hidden;pointer-events:none';
document.body.appendChild(smallViewportProbe);
+ fixedBoxProbe = document.createElement('div');
+ fixedBoxProbe.style.cssText = 'position:fixed;top:0;bottom:0;width:0;visibility:hidden;pointer-events:none';
+ document.body.appendChild(fixedBoxProbe);
publish();
- viewport.addEventListener('resize', schedulePublish);
- viewport.addEventListener('scroll', schedulePublish);
+ viewport.addEventListener('resize', publish);
+ viewport.addEventListener('scroll', publish);
detach = () => {
- viewport.removeEventListener('resize', schedulePublish);
- viewport.removeEventListener('scroll', schedulePublish);
- cancelAnimationFrame(frame);
- frame = 0;
+ viewport.removeEventListener('resize', publish);
+ viewport.removeEventListener('scroll', publish);
smallViewportProbe?.remove();
smallViewportProbe = null;
+ fixedBoxProbe?.remove();
+ fixedBoxProbe = null;
document.documentElement.style.removeProperty(PROPERTY);
+ document.documentElement.style.removeProperty(OFFSET_PROPERTY);
};
}
diff --git a/packages/mosaic/src/components/dialog/keyboard-touch.ts b/packages/mosaic/src/components/dialog/keyboard-touch.ts
index 97c50f68d8e..d283b275d83 100644
--- a/packages/mosaic/src/components/dialog/keyboard-touch.ts
+++ b/packages/mosaic/src/components/dialog/keyboard-touch.ts
@@ -1,5 +1,7 @@
import { canScrollToward, opensKeyboard } from '../../primitives/utils';
+const TAP_SLOP = 10;
+
/** A native slider, or anything that opts out of the browser's panning to take the drag itself. */
function handlesOwnDrag(element: Element): boolean {
if (element instanceof HTMLInputElement && element.type === 'range') {
@@ -24,17 +26,23 @@ function handlesOwnDrag(element: Element): boolean {
export function guardKeyboardTouch(element: HTMLElement): () => void {
let startX = 0;
let startY = 0;
+ let moved = false;
const onTouchStart = (event: TouchEvent) => {
startX = event.touches[0]?.clientX ?? 0;
startY = event.touches[0]?.clientY ?? 0;
+ moved = event.touches.length > 1;
};
const onTouchMove = (event: TouchEvent) => {
const touch = event.touches[0];
- if (event.touches.length > 1 || !touch || !event.cancelable) {
+ if (!touch) {
return;
}
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
+ moved ||= event.touches.length > 1 || Math.hypot(deltaX, deltaY) >= TAP_SLOP;
+ if (event.touches.length > 1 || !event.cancelable) {
+ return;
+ }
for (let node = event.target instanceof Element ? event.target : null; node; node = node.parentElement) {
if (handlesOwnDrag(node) || canScrollToward(node, deltaX, deltaY)) {
return;
@@ -45,14 +53,15 @@ export function guardKeyboardTouch(element: HTMLElement): () => void {
const onTouchEnd = (event: TouchEvent) => {
const target = event.target;
const touch = event.changedTouches[0];
- const isTap = touch !== undefined && Math.hypot(touch.clientX - startX, touch.clientY - startY) < 10;
+ const isTap =
+ touch !== undefined && !moved && Math.hypot(touch.clientX - startX, touch.clientY - startY) < TAP_SLOP;
const submit = target instanceof Element ? target.closest('button[type="submit"]') : null;
if (isTap && submit instanceof HTMLButtonElement && opensKeyboard(document.activeElement)) {
event.preventDefault();
submit.click();
return;
}
- if (!opensKeyboard(target) || target === document.activeElement || target.matches(':disabled')) {
+ if (!isTap || !opensKeyboard(target) || target === document.activeElement || target.matches(':disabled')) {
return;
}
event.preventDefault();