Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FormLayout showNavigation={false}>
<div style={{ height: 1200 }}>
<input data-testid="field" type="text" defaultValue="" />
</div>
</FormLayout>,
);

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');

Expand All @@ -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();
Expand Down
66 changes: 45 additions & 21 deletions formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<T extends HTMLElement>() {
const scrollRef = useRef<T | null>(null);
const focusedFieldRef = useRef<HTMLElement | null>(null);
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | 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;
Expand All @@ -66,11 +85,12 @@ export function useKeyboardScrollClamp<T extends HTMLElement>() {
tryRevealFocused();
revealTimerRef.current = setTimeout(() => {
tryRevealFocused();
endKeyboardRevealSession();
revealTimerRef.current = null;
}, KEYBOARD_REVEAL_DELAY_MS);
});
});
}, [tryRevealFocused]);
}, [endKeyboardRevealSession, tryRevealFocused]);

const runClampChain = useCallback(() => {
requestAnimationFrame(() => {
Expand All @@ -85,20 +105,26 @@ export function useKeyboardScrollClamp<T extends HTMLElement>() {

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();
};

Expand All @@ -118,18 +144,14 @@ export function useKeyboardScrollClamp<T extends HTMLElement>() {
if (focusedFieldRef.current === target) {
focusedFieldRef.current = null;
}
endKeyboardRevealSession();
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
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 =
Expand All @@ -141,26 +163,28 @@ export function useKeyboardScrollClamp<T extends HTMLElement>() {

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;
}
Loading