From 6d097601fe89f7e028129c6f5ea22edeeedf4657 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 09:09:15 +0200 Subject: [PATCH 1/2] fix(formulus): Limiting keyboard re-popping up during text editing --- ...seKeyboardScrollClamp.integration.test.tsx | 29 +++++++- .../src/hooks/useKeyboardScrollClamp.ts | 68 +++++++++++++------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx index 709c07174..26494453e 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx @@ -7,6 +7,30 @@ import * as keyboardScroll from '../utils/keyboardScroll'; afterEach(() => cleanup()); describe('FormLayout keyboard scroll integration', () => { + it('does not re-reveal on visualViewport scroll after keyboard session ends', async () => { + const revealSpy = vi.spyOn(keyboardScroll, 'revealFieldIfNeeded'); + + render( + +
+ +
+
, + ); + + const input = screen.getByTestId('field'); + + fireEvent.focusIn(input, { bubbles: true }); + await new Promise(resolve => setTimeout(resolve, 150)); + revealSpy.mockClear(); + + window.visualViewport?.dispatchEvent(new Event('scroll')); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(revealSpy).not.toHaveBeenCalled(); + revealSpy.mockRestore(); + }); + it('does not re-reveal on input or layout resize while focused', async () => { const revealSpy = vi.spyOn(keyboardScroll, 'revealFieldIfNeeded'); @@ -22,13 +46,14 @@ describe('FormLayout keyboard scroll integration', () => { const input = screen.getByTestId('field'); fireEvent.focusIn(input, { bubbles: true }); - await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise(resolve => setTimeout(resolve, 250)); revealSpy.mockClear(); fireEvent.input(input, { target: { value: '5' }, bubbles: true }); scrollArea.appendChild(document.createElement('div')); + window.visualViewport?.dispatchEvent(new Event('resize')); - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); expect(revealSpy).not.toHaveBeenCalled(); revealSpy.mockRestore(); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 32ab90c64..65dc9a5b3 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef } from 'react'; -import { clampScrollTop, revealFieldIfNeeded } from '../utils/keyboardScroll'; +import { + clampScrollTop, + isFieldObscuredInContainer, + revealFieldIfNeeded, +} from '../utils/keyboardScroll'; /** Input types that should trigger scroll clamp on value change. */ export function isClampableInputType(type: string | undefined): boolean { @@ -35,27 +39,44 @@ const KEYBOARD_REVEAL_DELAY_MS = 100; /** * Clamps FormLayout scroll when the IME opens and reveals focused fields only - * when obscured after keyboard animation — never scrollIntoView on value change. + * during the initial keyboard-open window — never on value change or caret moves. */ export function useKeyboardScrollClamp() { const scrollRef = useRef(null); const focusedFieldRef = useRef(null); const revealTimerRef = useRef | null>(null); + /** True from focus until the IME settle window ends; blocks re-reveal while typing. */ + const keyboardRevealSessionRef = useRef(false); const clamp = useCallback(() => { const el = scrollRef.current; if (el) clampScrollTop(el); }, []); + const endKeyboardRevealSession = useCallback(() => { + keyboardRevealSessionRef.current = false; + }, []); + const tryRevealFocused = useCallback(() => { const container = scrollRef.current; const field = focusedFieldRef.current; if (!container || !field || !container.contains(field)) return; + revealFieldIfNeeded(container, field, { marginBottom: 24, marginTop: 8 }); clamp(); - }, [clamp]); + + const containerRect = container.getBoundingClientRect(); + const fieldRect = field.getBoundingClientRect(); + if ( + !isFieldObscuredInContainer(containerRect, fieldRect, 8, 24) + ) { + endKeyboardRevealSession(); + } + }, [clamp, endKeyboardRevealSession]); const scheduleReveal = useCallback(() => { + if (!keyboardRevealSessionRef.current) return; + if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null; @@ -66,11 +87,12 @@ export function useKeyboardScrollClamp() { tryRevealFocused(); revealTimerRef.current = setTimeout(() => { tryRevealFocused(); + endKeyboardRevealSession(); revealTimerRef.current = null; }, KEYBOARD_REVEAL_DELAY_MS); }); }); - }, [tryRevealFocused]); + }, [endKeyboardRevealSession, tryRevealFocused]); const runClampChain = useCallback(() => { requestAnimationFrame(() => { @@ -85,20 +107,26 @@ export function useKeyboardScrollClamp() { const vv = window.visualViewport; - const onViewportChange = () => { - if (focusedFieldRef.current) { + const onViewportResize = () => { + if (focusedFieldRef.current && keyboardRevealSessionRef.current) { scheduleReveal(); } else { - requestAnimationFrame(clamp); + runClampChain(); } }; + // Caret moves while typing fire visualViewport scroll on Android WebView — clamp only. + const onViewportScroll = () => { + runClampChain(); + }; + const onFocusIn = (event: FocusEvent) => { const target = event.target; if (!isFormFieldForScrollClamp(target)) return; if (!(target instanceof HTMLElement)) return; focusedFieldRef.current = target; + keyboardRevealSessionRef.current = true; scheduleReveal(); }; @@ -118,6 +146,7 @@ export function useKeyboardScrollClamp() { if (focusedFieldRef.current === target) { focusedFieldRef.current = null; } + endKeyboardRevealSession(); if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null; @@ -125,11 +154,6 @@ export function useKeyboardScrollClamp() { runClampChain(); }; - const onInputOrChange = (event: Event) => { - if (!isFormFieldForScrollClamp(event.target)) return; - runClampChain(); - }; - // Clamp only on content resize (e.g. value change re-render). Re-revealing here // caused a scroll gap above the keyboard on first keystroke in number fields. const resizeObserver = @@ -141,26 +165,28 @@ export function useKeyboardScrollClamp() { resizeObserver?.observe(el); - vv?.addEventListener('resize', onViewportChange); - vv?.addEventListener('scroll', onViewportChange); + vv?.addEventListener('resize', onViewportResize); + vv?.addEventListener('scroll', onViewportScroll); el.addEventListener('focusin', onFocusIn); el.addEventListener('focusout', onFocusOut); - el.addEventListener('input', onInputOrChange, true); - el.addEventListener('change', onInputOrChange, true); return () => { resizeObserver?.disconnect(); - vv?.removeEventListener('resize', onViewportChange); - vv?.removeEventListener('scroll', onViewportChange); + vv?.removeEventListener('resize', onViewportResize); + vv?.removeEventListener('scroll', onViewportScroll); el.removeEventListener('focusin', onFocusIn); el.removeEventListener('focusout', onFocusOut); - el.removeEventListener('input', onInputOrChange, true); - el.removeEventListener('change', onInputOrChange, true); if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); } }; - }, [clamp, runClampChain, scheduleReveal, tryRevealFocused]); + }, [ + clamp, + endKeyboardRevealSession, + runClampChain, + scheduleReveal, + tryRevealFocused, + ]); return scrollRef; } From a7a5fc50862650383e338a173cff9d2110f2fe18 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 11:03:40 +0200 Subject: [PATCH 2/2] chore: formatting --- formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 65dc9a5b3..7c7505460 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -67,9 +67,7 @@ export function useKeyboardScrollClamp() { const containerRect = container.getBoundingClientRect(); const fieldRect = field.getBoundingClientRect(); - if ( - !isFieldObscuredInContainer(containerRect, fieldRect, 8, 24) - ) { + if (!isFieldObscuredInContainer(containerRect, fieldRect, 8, 24)) { endKeyboardRevealSession(); } }, [clamp, endKeyboardRevealSession]);