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..7c7505460 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,42 @@ 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 +85,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 +105,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 +144,7 @@ export function useKeyboardScrollClamp() {
if (focusedFieldRef.current === target) {
focusedFieldRef.current = null;
}
+ endKeyboardRevealSession();
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
@@ -125,11 +152,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 +163,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;
}