Skip to content

Commit 620bd85

Browse files
authored
fix(formulus): attempted fix of IME keyboard stretching the formplayer vertically (#656)
1 parent 65e48b4 commit 620bd85

8 files changed

Lines changed: 134 additions & 6 deletions

File tree

formulus-formplayer/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="utf-8" />
55
<link rel="icon" href="/favicon.ico" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-visual" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=overlays-content" />
77
<meta name="theme-color" content="#000000" />
88
<meta name="description" content="Formulus Form Player" />
99
<link rel="apple-touch-icon" href="/logo192.png" />

formulus-formplayer/src/components/FormLayout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { ReactNode, useCallback } from 'react';
22
import { Box, Paper } from '@mui/material';
33
import { Button } from '@ode/components/react-web';
4+
import { useKeyboardScrollClamp } from '../hooks/useKeyboardScrollClamp';
45

56
/** Keeps a submit control in the DOM so mobile keyboards can trigger the primary action (Go / Send / →). */
67
const visuallyHiddenSubmitStyle: React.CSSProperties = {
@@ -92,6 +93,8 @@ const FormLayout: React.FC<FormLayoutProps> = ({
9293
keyboardSubmitAction,
9394
contentBottomPadding = 0,
9495
}) => {
96+
const scrollRef = useKeyboardScrollClamp<HTMLDivElement>();
97+
9598
const handleFormSubmit = useCallback(
9699
(event: React.FormEvent<HTMLFormElement>) => {
97100
event.preventDefault();
@@ -103,6 +106,8 @@ const FormLayout: React.FC<FormLayoutProps> = ({
103106

104107
const scrollArea = (
105108
<Box
109+
ref={scrollRef}
110+
data-testid="formplayer-scroll-area"
106111
sx={theme => ({
107112
flex: 1,
108113
minHeight: 0,
@@ -111,6 +116,7 @@ const FormLayout: React.FC<FormLayoutProps> = ({
111116
overflowY: 'auto',
112117
overflowX: 'hidden',
113118
WebkitOverflowScrolling: 'touch',
119+
backgroundColor: 'background.default',
114120
// Base breathing room + caller-provided extra so the last fields can
115121
// scroll clear of the nav bar / on-screen keyboard.
116122
paddingBottom: `calc(${theme.spacing(2)} + ${contentBottomPadding}px)`,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { clampScrollTop } from './useKeyboardScrollClamp';
3+
4+
describe('clampScrollTop', () => {
5+
it('does not change scrollTop when within range', () => {
6+
const el = {
7+
scrollHeight: 500,
8+
clientHeight: 300,
9+
scrollTop: 100,
10+
} as HTMLElement;
11+
clampScrollTop(el);
12+
expect(el.scrollTop).toBe(100);
13+
});
14+
15+
it('clamps scrollTop to max scrollable offset', () => {
16+
const el = {
17+
scrollHeight: 500,
18+
clientHeight: 300,
19+
scrollTop: 250,
20+
} as HTMLElement;
21+
clampScrollTop(el);
22+
expect(el.scrollTop).toBe(200);
23+
});
24+
25+
it('handles non-scrollable content', () => {
26+
const el = {
27+
scrollHeight: 200,
28+
clientHeight: 300,
29+
scrollTop: 50,
30+
} as HTMLElement;
31+
clampScrollTop(el);
32+
expect(el.scrollTop).toBe(0);
33+
});
34+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
function isTextInput(
4+
el: EventTarget | null,
5+
): el is HTMLInputElement | HTMLTextAreaElement {
6+
if (!el || !(el instanceof HTMLElement)) return false;
7+
if (el instanceof HTMLTextAreaElement) return true;
8+
if (el instanceof HTMLInputElement) {
9+
const type = el.type?.toLowerCase() ?? 'text';
10+
return ![
11+
'hidden',
12+
'checkbox',
13+
'radio',
14+
'file',
15+
'button',
16+
'submit',
17+
'reset',
18+
].includes(type);
19+
}
20+
return false;
21+
}
22+
23+
/** Prevent scrolling past the last real content row inside the form scroll area. */
24+
export function clampScrollTop(el: HTMLElement): void {
25+
const max = Math.max(0, el.scrollHeight - el.clientHeight);
26+
if (el.scrollTop > max) {
27+
el.scrollTop = max;
28+
}
29+
}
30+
31+
/**
32+
* Clamps FormLayout scroll when the IME opens and after text field focus.
33+
* Avoids scrolling into flex filler / padding void above the nav bar.
34+
*/
35+
export function useKeyboardScrollClamp<T extends HTMLElement>() {
36+
const scrollRef = useRef<T | null>(null);
37+
38+
const clamp = useCallback(() => {
39+
const el = scrollRef.current;
40+
if (el) clampScrollTop(el);
41+
}, []);
42+
43+
useEffect(() => {
44+
const el = scrollRef.current;
45+
if (!el) return;
46+
47+
const vv = window.visualViewport;
48+
49+
const onViewportChange = () => {
50+
requestAnimationFrame(clamp);
51+
};
52+
53+
const onFocusIn = (event: FocusEvent) => {
54+
const target = event.target;
55+
if (!isTextInput(target)) return;
56+
57+
requestAnimationFrame(() => {
58+
requestAnimationFrame(() => {
59+
if (target instanceof HTMLElement) {
60+
try {
61+
target.scrollIntoView({ block: 'nearest', behavior: 'instant' });
62+
} catch {
63+
target.scrollIntoView({ block: 'nearest' });
64+
}
65+
}
66+
clamp();
67+
});
68+
});
69+
};
70+
71+
vv?.addEventListener('resize', onViewportChange);
72+
vv?.addEventListener('scroll', onViewportChange);
73+
el.addEventListener('focusin', onFocusIn);
74+
75+
return () => {
76+
vv?.removeEventListener('resize', onViewportChange);
77+
vv?.removeEventListener('scroll', onViewportChange);
78+
el.removeEventListener('focusin', onFocusIn);
79+
};
80+
}, [clamp]);
81+
82+
return scrollRef;
83+
}

formulus-formplayer/src/index.css

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ body {
2525
width: 100%;
2626
min-width: 0;
2727
/*
28-
* Fill the FormLayout scroll area when content is short (more reliable than min-height: 100%,
29-
* which depends on percentage height resolution in scroll/flex/WebView).
30-
* flex-shrink: 0 keeps long content from being squashed; the scroll parent scrolls instead.
28+
* Do not flex-grow to fill the scroll viewport — empty filler below short pages
29+
* becomes scrollable when the IME shrinks the host WebView (white gap bug).
3130
*/
32-
flex: 1 0 auto;
31+
flex: 0 1 auto;
32+
min-height: 0;
3333
box-sizing: border-box;
3434
overflow: visible; /* Ensure no scrolling happens here - only in FormLayout content area */
3535
}

formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ const SwipeLayoutRenderer = ({
645645
}
646646
: undefined
647647
}
648-
contentBottomPadding={80}
648+
contentBottomPadding={24}
649649
showNavigation={true}>
650650
<div
651651
ref={mergedSwipeScreenRef}

formulus/src/components/CustomAppWebView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ interface CustomAppWebViewProps {
4040
onNavigateToSettings?: () => void;
4141
/** When true, WebView and container use transparent background (e.g. for placeholder over the app screen shell). */
4242
transparentBackground?: boolean;
43+
/** WebView surface color behind HTML content (defaults to platform white on Android). */
44+
backgroundColor?: string;
4345
}
4446

4547
const INJECTION_SCRIPT_PATH =
@@ -174,6 +176,7 @@ const CustomAppWebView = forwardRef<
174176
onNavigateToSync,
175177
onNavigateToSettings,
176178
transparentBackground = false,
179+
backgroundColor,
177180
},
178181
ref,
179182
) => {
@@ -434,6 +437,7 @@ const CustomAppWebView = forwardRef<
434437
style={[
435438
styles.webView,
436439
transparentBackground && styles.webViewTransparent,
440+
backgroundColor != null && { backgroundColor },
437441
]}
438442
containerStyle={
439443
transparentBackground ? styles.webViewContainerTransparent : undefined

formulus/src/components/FormplayerModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(
713713
ref={webViewRef}
714714
appUrl={formplayerUri}
715715
appName="Formplayer"
716+
backgroundColor={themeColors.background as string}
716717
onLoadEndProp={handleWebViewLoad}
717718
/>
718719

0 commit comments

Comments
 (0)