From 6b08681950e4cc1da3fa33258457270f63972a5f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 21 Oct 2026 23:03:15 -0400 Subject: [PATCH 1/8] fix(mosaic): keep sheet steady when moving between fields with the keyboard open --- .changeset/mosaic-sheet-keyboard-focus.md | 5 + .../dialog/__tests__/keyboard-focus.test.ts | 123 ++++++++++++++++++ .../mosaic/src/components/dialog/dialog.tsx | 2 + .../src/components/dialog/keyboard-focus.ts | 67 ++++++++++ .../src/components/dialog/keyboard-inset.ts | 4 +- 5 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 .changeset/mosaic-sheet-keyboard-focus.md create mode 100644 packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts create mode 100644 packages/mosaic/src/components/dialog/keyboard-focus.ts diff --git a/.changeset/mosaic-sheet-keyboard-focus.md b/.changeset/mosaic-sheet-keyboard-focus.md new file mode 100644 index 00000000000..a59d0aa6cb3 --- /dev/null +++ b/.changeset/mosaic-sheet-keyboard-focus.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Fix dialogs shown as a bottom sheet on iOS jumping when moving between fields with the on-screen keyboard open. The focused field now scrolls into view above the keyboard inside the sheet instead of panning the page. 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..b602cb88586 --- /dev/null +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { guardKeyboardFocus } from '../keyboard-focus'; + +class FakeViewport extends EventTarget { + height = 768; + offsetTop = 0; + scale = 1; +} + +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(); + container.remove(); + Reflect.deleteProperty(window, 'visualViewport'); + Reflect.deleteProperty(document.documentElement, 'clientHeight'); +}); + +describe('guardKeyboardFocus', () => { + it('moves focus between fields without the browser panning while the keyboard is up', () => { + const first = field(100); + const second = field(200); + container.append(first, second); + 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 first = field(100); + const second = field(200); + container.append(first, second); + first.focus(); + const focus = vi.spyOn(second, 'focus'); + + second.focus(); + + expect(focus).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledWith(); + }); + + 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('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' }); + }); +}); 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 () => {}; + } + + 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 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); + } + }; + + element.addEventListener('blur', onBlur, true); + element.addEventListener('focusin', onFocusIn); + viewport.addEventListener('resize', onResize); + return () => { + element.removeEventListener('blur', onBlur, true); + element.removeEventListener('focusin', onFocusIn); + viewport.removeEventListener('resize', onResize); + }; +} + +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 bottom = Math.min(bounds.bottom, viewport.offsetTop + viewport.height); + if (target.top >= bounds.top && target.bottom <= bottom) { + return; + } + const centered = scroller.scrollTop + target.top - bounds.top - ((bottom - bounds.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.ts b/packages/mosaic/src/components/dialog/keyboard-inset.ts index 24814dd3524..1721f84c2e0 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.ts @@ -36,7 +36,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; @@ -53,7 +53,7 @@ function measure(): number { } function publish(): void { - document.documentElement.style.setProperty(PROPERTY, `${measure()}px`); + document.documentElement.style.setProperty(PROPERTY, `${measureKeyboardInset()}px`); } function schedulePublish(): void { From 4044dce1b8a691429e67523d7df70136034c0eba Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 21 Oct 2026 23:03:33 -0400 Subject: [PATCH 2/8] chore(repo): use empty changeset --- .changeset/mosaic-sheet-keyboard-focus.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/mosaic-sheet-keyboard-focus.md b/.changeset/mosaic-sheet-keyboard-focus.md index a59d0aa6cb3..a845151cc84 100644 --- a/.changeset/mosaic-sheet-keyboard-focus.md +++ b/.changeset/mosaic-sheet-keyboard-focus.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Fix dialogs shown as a bottom sheet on iOS jumping when moving between fields with the on-screen keyboard open. The focused field now scrolls into view above the keyboard inside the sheet instead of panning the page. From 3b4114467b0128a197a1428b9ba27fc8d63ba599 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Oct 2026 07:46:12 -0400 Subject: [PATCH 3/8] fix(mosaic): keep the sheet inside the visible band when iOS pans for the keyboard --- .../src/components/dialog/dialog.styles.ts | 1 + .../components/dialog/keyboard-inset.test.ts | 21 +++++++++++++++++++ .../src/components/dialog/keyboard-inset.ts | 12 ++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) 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/keyboard-inset.test.ts b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts index 4908af21327..9f32f52cef3 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts @@ -18,6 +18,10 @@ function inset() { return document.documentElement.style.getPropertyValue('--_cl-keyboard-inset'); } +function offset() { + return document.documentElement.style.getPropertyValue('--_cl-keyboard-offset'); +} + function nextFrame() { return new Promise(resolve => requestAnimationFrame(resolve)); } @@ -57,6 +61,23 @@ describe('acquireKeyboardInset', () => { release(); }); + it('publishes how far the browser has panned the visible band while the keyboard is up', async () => { + const release = acquireKeyboardInset(); + viewport.offsetTop = 40; + viewport.dispatchEvent(new Event('scroll')); + await nextFrame(); + expect(offset()).toBe('0px'); + + viewport.height = 500; + viewport.offsetTop = 100; + viewport.dispatchEvent(new Event('resize')); + await nextFrame(); + expect(offset()).toBe('100px'); + + release(); + expect(offset()).toBe(''); + }); + it('drops a pending update when released', async () => { const release = acquireKeyboardInset(); viewport.height = 500; diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.ts b/packages/mosaic/src/components/dialog/keyboard-inset.ts index 1721f84c2e0..ea8ed7c2be7 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.ts @@ -14,9 +14,15 @@ * - 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; @@ -53,7 +59,10 @@ export function measureKeyboardInset(): number { } function publish(): void { - document.documentElement.style.setProperty(PROPERTY, `${measureKeyboardInset()}px`); + 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`); } function schedulePublish(): void { @@ -94,6 +103,7 @@ export function acquireKeyboardInset(): () => void { smallViewportProbe?.remove(); smallViewportProbe = null; document.documentElement.style.removeProperty(PROPERTY); + document.documentElement.style.removeProperty(OFFSET_PROPERTY); }; } From 398b20cd28d222cb25b1a3251cf9ada3958b7a42 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 22 Oct 2026 08:13:44 -0400 Subject: [PATCH 4/8] fix(mosaic): measure the keyboard against the overlay box and pin page scroll while it is up --- .../dialog/__tests__/keyboard-focus.test.ts | 24 +++++++++++++++++++ .../src/components/dialog/keyboard-focus.ts | 9 +++++++ .../components/dialog/keyboard-inset.test.ts | 22 ++++++++++++++++- .../src/components/dialog/keyboard-inset.ts | 9 ++++++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts index b602cb88586..60f0befec92 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts @@ -8,6 +8,7 @@ class FakeViewport extends EventTarget { scale = 1; } +const initialScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY'); let viewport: FakeViewport; let container: HTMLDivElement; let release: () => void; @@ -50,6 +51,10 @@ beforeEach(() => { afterEach(() => { release(); + vi.restoreAllMocks(); + if (initialScrollY) { + Object.defineProperty(window, 'scrollY', initialScrollY); + } container.remove(); Reflect.deleteProperty(window, 'visualViewport'); Reflect.deleteProperty(document.documentElement, 'clientHeight'); @@ -120,4 +125,23 @@ describe('guardKeyboardFocus', () => { expect(scroll.scrollTo).toHaveBeenCalledWith({ top: 520, behavior: 'smooth' }); }); + + 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/keyboard-focus.ts b/packages/mosaic/src/components/dialog/keyboard-focus.ts index eecc876ec4e..3c75ccab1c5 100644 --- a/packages/mosaic/src/components/dialog/keyboard-focus.ts +++ b/packages/mosaic/src/components/dialog/keyboard-focus.ts @@ -28,14 +28,23 @@ export function guardKeyboardFocus(element: HTMLElement): () => void { revealAboveKeyboard(active, element, viewport); } }; + 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('blur', onBlur, true); element.addEventListener('focusin', onFocusIn); viewport.addEventListener('resize', onResize); + window.addEventListener('scroll', onWindowScroll); return () => { element.removeEventListener('blur', onBlur, true); element.removeEventListener('focusin', onFocusIn); viewport.removeEventListener('resize', onResize); + window.removeEventListener('scroll', onWindowScroll); }; } diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts index 9f32f52cef3..be8f6cff2e1 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts @@ -12,6 +12,9 @@ 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() { @@ -29,11 +32,15 @@ function nextFrame() { 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; }, }); }); @@ -78,6 +85,19 @@ describe('acquireKeyboardInset', () => { expect(offset()).toBe(''); }); + it('measures the keyboard against the box the overlay fills, not the document height', async () => { + fixedBoxHeight = 852; + smallViewportHeight = 852; + const release = acquireKeyboardInset(); + + viewport.height = 500; + viewport.dispatchEvent(new Event('resize')); + await nextFrame(); + + expect(inset()).toBe('352px'); + release(); + }); + it('drops a pending update when released', async () => { const release = acquireKeyboardInset(); viewport.height = 500; diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.ts b/packages/mosaic/src/components/dialog/keyboard-inset.ts index ea8ed7c2be7..f0c04e99a8c 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.ts @@ -34,6 +34,7 @@ let frame = 0; // 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 @@ -55,7 +56,8 @@ export function measureKeyboardInset(): 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 { @@ -92,6 +94,9 @@ 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); @@ -102,6 +107,8 @@ export function acquireKeyboardInset(): () => void { frame = 0; smallViewportProbe?.remove(); smallViewportProbe = null; + fixedBoxProbe?.remove(); + fixedBoxProbe = null; document.documentElement.style.removeProperty(PROPERTY); document.documentElement.style.removeProperty(OFFSET_PROPERTY); }; From 7ee9ffd24472a1793bbeec28a5e37080ad95801b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Oct 2026 11:07:19 -0400 Subject: [PATCH 5/8] fix(mosaic): reveal the focused field once the keyboard padding exists, and only focus fields on a tap - publish the keyboard inset synchronously, so revealing the focused field on keyboard open scrolls with the padding already in place - a drag that ends on a text field no longer focuses it - drop the blur refocus; taps and the page scroll pin cover it - add touch tests and WebKit geometry tests for the sheet with a keyboard --- .../dialog/__tests__/keyboard-focus.test.ts | 27 ---- .../dialog/__tests__/keyboard-touch.test.ts | 135 ++++++++++++++++++ .../__tests__/sheet-keyboard.webkit.test.tsx | 123 ++++++++++++++++ .../src/components/dialog/keyboard-focus.ts | 12 -- .../components/dialog/keyboard-inset.test.ts | 29 ++-- .../src/components/dialog/keyboard-inset.ts | 21 +-- .../src/components/dialog/keyboard-touch.ts | 2 +- 7 files changed, 272 insertions(+), 77 deletions(-) create mode 100644 packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts create mode 100644 packages/mosaic/src/components/dialog/__tests__/sheet-keyboard.webkit.test.tsx diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts index 60f0befec92..f642fc35244 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts @@ -61,33 +61,6 @@ afterEach(() => { }); describe('guardKeyboardFocus', () => { - it('moves focus between fields without the browser panning while the keyboard is up', () => { - const first = field(100); - const second = field(200); - container.append(first, second); - 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 first = field(100); - const second = field(200); - container.append(first, second); - first.focus(); - const focus = vi.spyOn(second, 'focus'); - - second.focus(); - - expect(focus).toHaveBeenCalledTimes(1); - expect(focus).toHaveBeenCalledWith(); - }); - it('centers the focused field in the band above the keyboard once it opens', () => { const scroll = scroller(); const input = field(600); 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..b2cbdff96b3 --- /dev/null +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts @@ -0,0 +1,135 @@ +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) { + const point = { clientX: 10, clientY: y }; + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: type === 'touchend' ? [] : [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('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/keyboard-focus.ts b/packages/mosaic/src/components/dialog/keyboard-focus.ts index 3c75ccab1c5..b0e9b334193 100644 --- a/packages/mosaic/src/components/dialog/keyboard-focus.ts +++ b/packages/mosaic/src/components/dialog/keyboard-focus.ts @@ -7,16 +7,6 @@ export function guardKeyboardFocus(element: HTMLElement): () => void { return () => {}; } - 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 onFocusIn = (event: FocusEvent) => { if (opensKeyboard(event.target)) { revealAboveKeyboard(event.target, element, viewport); @@ -36,12 +26,10 @@ export function guardKeyboardFocus(element: HTMLElement): () => void { window.scrollTo({ ...pinned, behavior: 'instant' }); }; - element.addEventListener('blur', onBlur, true); element.addEventListener('focusin', onFocusIn); viewport.addEventListener('resize', onResize); window.addEventListener('scroll', onWindowScroll); return () => { - element.removeEventListener('blur', onBlur, true); element.removeEventListener('focusin', onFocusIn); viewport.removeEventListener('resize', onResize); window.removeEventListener('scroll', onWindowScroll); diff --git a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts index be8f6cff2e1..308f2712a2e 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.test.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.test.ts @@ -25,10 +25,6 @@ function offset() { return document.documentElement.style.getPropertyValue('--_cl-keyboard-offset'); } -function nextFrame() { - return new Promise(resolve => requestAnimationFrame(resolve)); -} - beforeEach(() => { viewport = new FakeViewport(); smallViewportHeight = 768; @@ -53,68 +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('publishes how far the browser has panned the visible band while the keyboard is up', 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')); - await nextFrame(); expect(offset()).toBe('0px'); viewport.height = 500; viewport.offsetTop = 100; viewport.dispatchEvent(new Event('resize')); - await nextFrame(); expect(offset()).toBe('100px'); release(); expect(offset()).toBe(''); }); - it('measures the keyboard against the box the overlay fills, not the document height', async () => { + 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')); - await nextFrame(); expect(inset()).toBe('352px'); release(); }); - it('drops a pending update when released', async () => { + it('stops publishing when released', () => { const release = acquireKeyboardInset(); - viewport.height = 500; - viewport.dispatchEvent(new Event('resize')); release(); - await nextFrame(); + 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 f0c04e99a8c..0ed7118eae5 100644 --- a/packages/mosaic/src/components/dialog/keyboard-inset.ts +++ b/packages/mosaic/src/components/dialog/keyboard-inset.ts @@ -29,7 +29,6 @@ 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. @@ -67,16 +66,6 @@ function publish(): void { document.documentElement.style.setProperty(OFFSET_PROPERTY, `${offset}px`); } -function schedulePublish(): void { - if (frame) { - return; - } - frame = requestAnimationFrame(() => { - frame = 0; - publish(); - }); -} - /** * Starts publishing the inset, refcounted so stacked dialogs share one set of listeners and the * last one to close tears them down. Returns a no-op where `visualViewport` is unavailable, which @@ -98,13 +87,11 @@ export function acquireKeyboardInset(): () => void { 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(); diff --git a/packages/mosaic/src/components/dialog/keyboard-touch.ts b/packages/mosaic/src/components/dialog/keyboard-touch.ts index 97c50f68d8e..52564eb9ec8 100644 --- a/packages/mosaic/src/components/dialog/keyboard-touch.ts +++ b/packages/mosaic/src/components/dialog/keyboard-touch.ts @@ -52,7 +52,7 @@ export function guardKeyboardTouch(element: HTMLElement): () => void { 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(); From 03f19d7ec2ebfe62da792a220a3d409fe7207a60 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Oct 2026 11:28:51 -0400 Subject: [PATCH 6/8] fix(mosaic): keep the sheet still when the keyboard arrows move between fields --- .../dialog/__tests__/keyboard-focus.test.ts | 31 +++++++++++++++++++ .../src/components/dialog/keyboard-focus.ts | 12 +++++++ 2 files changed, 43 insertions(+) diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts index f642fc35244..3228666624f 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts @@ -99,6 +99,37 @@ describe('guardKeyboardFocus', () => { 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(); diff --git a/packages/mosaic/src/components/dialog/keyboard-focus.ts b/packages/mosaic/src/components/dialog/keyboard-focus.ts index b0e9b334193..f8c1b9157ef 100644 --- a/packages/mosaic/src/components/dialog/keyboard-focus.ts +++ b/packages/mosaic/src/components/dialog/keyboard-focus.ts @@ -18,6 +18,16 @@ export function guardKeyboardFocus(element: HTMLElement): () => void { 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)) { @@ -27,10 +37,12 @@ export function guardKeyboardFocus(element: HTMLElement): () => void { }; 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); }; From 4c1d4ede53311bc9b9f77267cc8e2ae540c98e6c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Oct 2026 12:04:58 -0400 Subject: [PATCH 7/8] fix(mosaic): measure the reveal from the visible band's top and ignore taps that moved --- .../dialog/__tests__/keyboard-focus.test.ts | 14 ++++++++++++++ .../dialog/__tests__/keyboard-touch.test.ts | 11 +++++++++++ .../mosaic/src/components/dialog/keyboard-focus.ts | 5 +++-- .../mosaic/src/components/dialog/keyboard-touch.ts | 13 +++++++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts index 3228666624f..3935a3413a4 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-focus.test.ts @@ -85,6 +85,20 @@ describe('guardKeyboardFocus', () => { 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); diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts index b2cbdff96b3..6286267b6c3 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts @@ -60,6 +60,17 @@ describe('guardKeyboardTouch', () => { 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('submits on a tap of a submit button and keeps focus on the field', () => { const input = document.createElement('input'); const submit = document.createElement('button'); diff --git a/packages/mosaic/src/components/dialog/keyboard-focus.ts b/packages/mosaic/src/components/dialog/keyboard-focus.ts index f8c1b9157ef..a291db80dcd 100644 --- a/packages/mosaic/src/components/dialog/keyboard-focus.ts +++ b/packages/mosaic/src/components/dialog/keyboard-focus.ts @@ -55,11 +55,12 @@ function revealAboveKeyboard(field: HTMLElement, boundary: HTMLElement, viewport } 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 >= bounds.top && target.bottom <= bottom) { + if (target.top >= top && target.bottom <= bottom) { return; } - const centered = scroller.scrollTop + target.top - bounds.top - ((bottom - bounds.top) / 2 - target.height / 2); + 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', diff --git a/packages/mosaic/src/components/dialog/keyboard-touch.ts b/packages/mosaic/src/components/dialog/keyboard-touch.ts index 52564eb9ec8..7de63f0f894 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 = false; }; 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,7 +53,8 @@ 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(); From f51e8762de618de560e523a683ec881cf193988a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Oct 2026 12:26:23 -0400 Subject: [PATCH 8/8] fix(mosaic): treat a touch that gains a second finger as moved --- .../dialog/__tests__/keyboard-touch.test.ts | 15 +++++++++++++-- .../src/components/dialog/keyboard-touch.ts | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts index 6286267b6c3..e249cee6e6c 100644 --- a/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts +++ b/packages/mosaic/src/components/dialog/__tests__/keyboard-touch.test.ts @@ -16,11 +16,11 @@ function scroller(scrollTop: number) { return element; } -function touch(target: Element, type: string, y: number) { +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' ? [] : [point] }, + touches: { value: type === 'touchend' ? [] : Array.from({ length: fingers }, () => point) }, changedTouches: { value: [point] }, }); target.dispatchEvent(event); @@ -71,6 +71,17 @@ describe('guardKeyboardTouch', () => { 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'); diff --git a/packages/mosaic/src/components/dialog/keyboard-touch.ts b/packages/mosaic/src/components/dialog/keyboard-touch.ts index 7de63f0f894..d283b275d83 100644 --- a/packages/mosaic/src/components/dialog/keyboard-touch.ts +++ b/packages/mosaic/src/components/dialog/keyboard-touch.ts @@ -30,7 +30,7 @@ export function guardKeyboardTouch(element: HTMLElement): () => void { const onTouchStart = (event: TouchEvent) => { startX = event.touches[0]?.clientX ?? 0; startY = event.touches[0]?.clientY ?? 0; - moved = false; + moved = event.touches.length > 1; }; const onTouchMove = (event: TouchEvent) => { const touch = event.touches[0];