fix(input, textarea): padding is now added to content so inputs scroll above keyboard (#25849)

resolves #18532
This commit is contained in:
Liam DeBeasi
2022-09-09 14:22:44 -05:00
committed by GitHub
parent bb5ecf51ec
commit ba6b539675
7 changed files with 349 additions and 92 deletions

View File

@ -1,23 +1,42 @@
import { getScrollElement, scrollByPoint } from '../../content';
import { raf } from '../../helpers';
import type { KeyboardResizeOptions } from '../../native/keyboard';
import { KeyboardResize } from '../../native/keyboard';
import { relocateInput, SCROLL_AMOUNT_PADDING } from './common';
import { getScrollData } from './scroll-data';
import { setScrollPadding, setClearScrollPaddingListener } from './scroll-padding';
let currentPadding = 0;
export const enableScrollAssist = (
componentEl: HTMLElement,
inputEl: HTMLInputElement | HTMLTextAreaElement,
contentEl: HTMLElement | null,
footerEl: HTMLIonFooterElement | null,
keyboardHeight: number
keyboardHeight: number,
enableScrollPadding: boolean,
keyboardResize: KeyboardResizeOptions | undefined
) => {
/**
* Scroll padding should only be added if:
* 1. The global scrollPadding config option
* is set to true.
* 2. The native keyboard resize mode is either "none"
* (keyboard overlays webview) or undefined (resize
* information unavailable)
* Resize info is available on Capacitor 4+
*/
const addScrollPadding =
enableScrollPadding && (keyboardResize === undefined || keyboardResize.mode === KeyboardResize.None);
/**
* When the input is about to receive
* focus, we need to move it to prevent
* mobile Safari from adjusting the viewport.
*/
const focusIn = () => {
jsSetFocus(componentEl, inputEl, contentEl, footerEl, keyboardHeight);
const focusIn = async () => {
jsSetFocus(componentEl, inputEl, contentEl, footerEl, keyboardHeight, addScrollPadding);
};
componentEl.addEventListener('focusin', focusIn, true);
@ -31,7 +50,8 @@ const jsSetFocus = async (
inputEl: HTMLInputElement | HTMLTextAreaElement,
contentEl: HTMLElement | null,
footerEl: HTMLIonFooterElement | null,
keyboardHeight: number
keyboardHeight: number,
enableScrollPadding: boolean
) => {
if (!contentEl && !footerEl) {
return;
@ -42,6 +62,22 @@ const jsSetFocus = async (
// the text input is in a safe position that doesn't
// require it to be scrolled into view, just set focus now
inputEl.focus();
/**
* Even though the input does not need
* scroll assist, we should preserve the
* the scroll padding as users could be moving
* focus from an input that needs scroll padding
* to an input that does not need scroll padding.
* If we remove the scroll padding now, users will
* see the page jump.
*/
if (enableScrollPadding && contentEl) {
currentPadding += scrollData.scrollAmount;
setScrollPadding(contentEl, currentPadding);
setClearScrollPaddingListener(inputEl, contentEl, () => (currentPadding = 0));
}
return;
}
@ -58,6 +94,17 @@ const jsSetFocus = async (
*/
raf(() => componentEl.click());
/**
* If enabled, we can add scroll padding to
* the bottom of the content so that scroll assist
* has enough room to scroll the input above
* the keyboard.
*/
if (enableScrollPadding && contentEl) {
currentPadding += scrollData.scrollAmount;
setScrollPadding(contentEl, currentPadding);
}
if (typeof window !== 'undefined') {
let scrollContentTimeout: any;
const scrollContent = async () => {
@ -80,6 +127,15 @@ const jsSetFocus = async (
// ensure this is the focused input
inputEl.focus();
/**
* When the input is about to be blurred
* we should set a timeout to remove
* any scroll padding.
*/
if (enableScrollPadding) {
setClearScrollPaddingListener(inputEl, contentEl, () => (currentPadding = 0));
}
};
const doubleKeyboardEventListener = () => {

View File

@ -1,51 +1,61 @@
import { findClosestIonContent } from '../../content';
const PADDING_TIMER_KEY = '$ionPaddingTimer';
export const enableScrollPadding = (keyboardHeight: number) => {
const doc = document;
const onFocusin = (ev: any) => {
setScrollPadding(ev.target, keyboardHeight);
};
const onFocusout = (ev: any) => {
setScrollPadding(ev.target, 0);
};
doc.addEventListener('focusin', onFocusin);
doc.addEventListener('focusout', onFocusout);
return () => {
doc.removeEventListener('focusin', onFocusin);
doc.removeEventListener('focusout', onFocusout);
};
};
const setScrollPadding = (input: HTMLElement, keyboardHeight: number) => {
if (input.tagName !== 'INPUT') {
return;
}
if (input.parentElement && input.parentElement.tagName === 'ION-INPUT') {
return;
}
if (input.parentElement?.parentElement?.tagName === 'ION-SEARCHBAR') {
return;
}
const el = findClosestIonContent(input);
if (el === null) {
return;
}
const timer = (el as any)[PADDING_TIMER_KEY];
/**
* Scroll padding adds additional padding to the bottom
* of ion-content so that there is enough scroll space
* for an input to be scrolled above the keyboard. This
* is needed in environments where the webview does not
* resize when the keyboard opens.
*
* Example: If an input at the bottom of ion-content is
* focused, there is no additional scrolling space below
* it, so the input cannot be scrolled above the keyboard.
* Scroll padding fixes this by adding padding equal to the
* height of the keyboard to the bottom of the content.
*
* Common environments where this is needed:
* - Mobile Safari: The keyboard overlays the content
* - Capacitor/Cordova on iOS: The keyboard overlays the content
* when the KeyboardResize mode is set to 'none'.
*/
export const setScrollPadding = (contentEl: HTMLElement, paddingAmount: number, clearCallback?: () => void) => {
const timer = (contentEl as any)[PADDING_TIMER_KEY];
if (timer) {
clearTimeout(timer);
}
if (keyboardHeight > 0) {
el.style.setProperty('--keyboard-offset', `${keyboardHeight}px`);
if (paddingAmount > 0) {
contentEl.style.setProperty('--keyboard-offset', `${paddingAmount}px`);
} else {
(el as any)[PADDING_TIMER_KEY] = setTimeout(() => {
el.style.setProperty('--keyboard-offset', '0px');
(contentEl as any)[PADDING_TIMER_KEY] = setTimeout(() => {
contentEl.style.setProperty('--keyboard-offset', '0px');
if (clearCallback) {
clearCallback();
}
}, 120);
}
};
/**
* When an input is about to be focused,
* set a timeout to clear any scroll padding
* on the content. Note: The clearing
* is done on a timeout so that if users
* are moving focus from one input to the next
* then re-adding scroll padding to the new
* input with cancel the timeout to clear the
* scroll padding.
*/
export const setClearScrollPaddingListener = (
inputEl: HTMLInputElement | HTMLTextAreaElement,
contentEl: HTMLElement | null,
doneCallback: () => void
) => {
const clearScrollPadding = () => {
if (contentEl) {
setScrollPadding(contentEl, 0, doneCallback);
}
};
inputEl.addEventListener('focusout', clearScrollPadding, { once: true });
};

View File

@ -88,6 +88,24 @@
keyboardHeight: 250,
},
};
const params = new URLSearchParams(window.location.href.split('?')[1]);
const resizeMode = params.get('resizeMode');
if (resizeMode) {
window.Capacitor = {
isPluginAvailable: (plugin) => plugin === 'Keyboard',
Plugins: {
Keyboard: {
getResizeMode: () => {
return Promise.resolve({
mode: resizeMode,
});
},
},
},
};
}
</script>
</ion-app>
</body>

View File

@ -1,60 +1,178 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';
import { KeyboardResize } from '@utils/native/keyboard';
import type { E2EPage } from '@utils/test/playwright';
import { test } from '@utils/test/playwright';
test.describe('scroll-assist', () => {
const getScrollPosition = async (contentEl: Locator) => {
return await contentEl.evaluate(async (el: HTMLIonContentElement) => {
const scrollEl = await el.getScrollElement();
const getScrollPosition = async (contentEl: Locator) => {
return await contentEl.evaluate(async (el: HTMLIonContentElement) => {
const scrollEl = await el.getScrollElement();
return scrollEl.scrollTop;
});
};
return scrollEl.scrollTop;
});
};
test.describe('scroll-assist', () => {
let scrollAssistFixture: ScrollAssistFixture;
test.beforeEach(async ({ page, skip }) => {
skip.rtl();
skip.mode('md', 'Scroll utils are only needed on iOS mode');
skip.browser('firefox');
skip.browser('chromium');
await page.goto('/src/utils/input-shims/hacks/test');
});
test('should not activate when input is above the keyboard', async ({ page }) => {
const input = page.locator('#input-above-keyboard');
const content = page.locator('ion-content');
await expect(await getScrollPosition(content)).toBe(0);
await input.click();
await expect(input.locator('input')).toBeFocused();
await page.waitForChanges();
await expect(await getScrollPosition(content)).toBe(0);
scrollAssistFixture = new ScrollAssistFixture(page);
});
test('should activate when input is below the keyboard', async ({ page }) => {
const input = page.locator('#input-below-keyboard');
const content = page.locator('ion-content');
test.describe('scroll-assist: basic functionality', () => {
test.beforeEach(async () => {
await scrollAssistFixture.goto();
});
test('should not activate when input is above the keyboard', async () => {
await scrollAssistFixture.expectNotToHaveScrollAssist(
'#input-above-keyboard',
'#input-above-keyboard input:not(.cloned-input)'
);
});
await expect(await getScrollPosition(content)).toBe(0);
test('should activate when input is below the keyboard', async () => {
await scrollAssistFixture.expectToHaveScrollAssist(
'#input-below-keyboard',
'#input-below-keyboard input:not(.cloned-input)'
);
});
await input.click({ force: true });
await page.waitForChanges();
await expect(input.locator('input:not(.cloned-input)')).toBeFocused();
await expect(await getScrollPosition(content)).not.toBe(0);
test('should activate even when not explicitly tapping input', async () => {
await scrollAssistFixture.expectToHaveScrollAssist(
'#item-below-keyboard ion-label',
'#input-below-keyboard input:not(.cloned-input)'
);
});
});
test.describe('scroll-assist: scroll-padding', () => {
test.describe('scroll-padding: browser/cordova', () => {
test.beforeEach(async () => {
await scrollAssistFixture.goto();
});
test('should add scroll padding for an input at the bottom of the scroll container', async () => {
await scrollAssistFixture.expectToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
});
test('should activate even when not explicitly tapping input', async ({ page }) => {
const label = page.locator('#item-below-keyboard ion-label');
const input = page.locator('#input-below-keyboard');
const content = page.locator('ion-content');
test('should keep scroll padding even when switching between inputs', async () => {
await scrollAssistFixture.expectToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
await expect(await getScrollPosition(content)).toBe(0);
await scrollAssistFixture.expectToHaveScrollPadding(
'#textarea-outside-viewport',
'#textarea-outside-viewport textarea:not(.cloned-input)'
);
});
});
test.describe('scroll-padding: webview resizing', () => {
test('should add scroll padding when webview resizing is "none"', async () => {
await scrollAssistFixture.goto(KeyboardResize.None);
await label.click({ force: true });
await page.waitForChanges();
await expect(input.locator('input:not(.cloned-input)')).toBeFocused();
await scrollAssistFixture.expectToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
});
test('should not add scroll padding when webview resizing is "body"', async () => {
await scrollAssistFixture.goto(KeyboardResize.Body);
await expect(await getScrollPosition(content)).not.toBe(0);
await scrollAssistFixture.expectNotToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
});
test('should not add scroll padding when webview resizing is "ionic"', async () => {
await scrollAssistFixture.goto(KeyboardResize.Ionic);
await scrollAssistFixture.expectNotToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
});
test('should not add scroll padding when webview resizing is "native"', async () => {
await scrollAssistFixture.goto(KeyboardResize.Native);
await scrollAssistFixture.expectNotToHaveScrollPadding(
'#input-outside-viewport',
'#input-outside-viewport input:not(.cloned-input)'
);
});
});
});
});
class ScrollAssistFixture {
readonly page: E2EPage;
private content!: Locator;
constructor(page: E2EPage) {
this.page = page;
}
async goto(resizeMode?: KeyboardResize) {
let url = `/src/utils/input-shims/hacks/test`;
if (resizeMode !== undefined) {
url += `?resizeMode=${resizeMode}`;
}
await this.page.goto(url);
this.content = this.page.locator('ion-content');
}
private async focusInput(interactiveSelector: string, inputSelector: string) {
const { page } = this;
const interactive = page.locator(interactiveSelector);
const input = page.locator(inputSelector);
await interactive.click({ force: true });
await expect(input).toBeFocused();
await page.waitForChanges();
}
private getScrollPosition() {
const { content } = this;
return getScrollPosition(content);
}
async expectNotToHaveScrollAssist(interactiveSelector: string, inputSelector: string) {
await expect(await this.getScrollPosition()).toBe(0);
await this.focusInput(interactiveSelector, inputSelector);
await expect(await this.getScrollPosition()).toBe(0);
}
async expectToHaveScrollAssist(interactiveSelector: string, inputSelector: string) {
await expect(await this.getScrollPosition()).toBe(0);
await this.focusInput(interactiveSelector, inputSelector);
await expect(await this.getScrollPosition()).not.toBe(0);
}
async expectToHaveScrollPadding(interactiveSelector: string, inputSelector: string) {
const { content } = this;
await this.focusInput(interactiveSelector, inputSelector);
await expect(content).not.toHaveCSS('--keyboard-offset', '0px');
}
async expectNotToHaveScrollPadding(interactiveSelector: string, inputSelector: string) {
const { content } = this;
await this.focusInput(interactiveSelector, inputSelector);
await expect(content).toHaveCSS('--keyboard-offset', '0px');
}
}

View File

@ -1,18 +1,17 @@
import type { Config } from '../../interface';
import { findClosestIonContent } from '../content';
import { componentOnReady } from '../helpers';
import { Keyboard } from '../native/keyboard';
import { enableHideCaretOnScroll } from './hacks/hide-caret';
import { enableInputBlurring } from './hacks/input-blurring';
import { enableScrollAssist } from './hacks/scroll-assist';
import { enableScrollPadding } from './hacks/scroll-padding';
const INPUT_BLURRING = true;
const SCROLL_ASSIST = true;
const SCROLL_PADDING = true;
const HIDE_CARET = true;
export const startInputShims = (config: Config) => {
export const startInputShims = async (config: Config) => {
const doc = document;
const keyboardHeight = config.getNumber('keyboardHeight', 290);
const scrollAssist = config.getBoolean('scrollAssist', true);
@ -24,6 +23,16 @@ export const startInputShims = (config: Config) => {
const hideCaretMap = new WeakMap<HTMLElement, () => void>();
const scrollAssistMap = new WeakMap<HTMLElement, () => void>();
/**
* Grab the native keyboard resize configuration
* and pass it to scroll assist. Scroll assist requires
* that we adjust the input right before the input
* is about to be focused. If we called `Keyboard.getResizeMode`
* on focusin in scroll assist, we could potentially adjust the
* input too late since this call is async.
*/
const keyboardResizeMode = await Keyboard.getResizeMode();
const registerInput = async (componentEl: HTMLElement) => {
await new Promise((resolve) => componentOnReady(componentEl, resolve));
@ -55,7 +64,15 @@ export const startInputShims = (config: Config) => {
scrollAssist &&
!scrollAssistMap.has(componentEl)
) {
const rmFn = enableScrollAssist(componentEl, inputEl, scrollEl, footerEl, keyboardHeight);
const rmFn = enableScrollAssist(
componentEl,
inputEl,
scrollEl,
footerEl,
keyboardHeight,
scrollPadding,
keyboardResizeMode
);
scrollAssistMap.set(componentEl, rmFn);
}
};
@ -82,10 +99,6 @@ export const startInputShims = (config: Config) => {
enableInputBlurring();
}
if (scrollPadding && SCROLL_PADDING) {
enableScrollPadding(keyboardHeight);
}
// Input might be already loaded in the DOM before ion-device-hacks did.
// At this point we need to look for all of the inputs not registered yet
// and register them.

View File

@ -0,0 +1,27 @@
import { win } from '../window';
// Interfaces source: https://capacitorjs.com/docs/apis/keyboard#interfaces
export interface KeyboardResizeOptions {
mode: KeyboardResize;
}
export enum KeyboardResize {
Body = 'body',
Ionic = 'ionic',
Native = 'native',
None = 'none',
}
export const Keyboard = {
getEngine() {
return (win as any)?.Capacitor?.isPluginAvailable('Keyboard') && (win as any)?.Capacitor.Plugins.Keyboard;
},
getResizeMode(): Promise<KeyboardResizeOptions | undefined> {
const engine = this.getEngine();
if (!engine || !engine.getResizeMode) {
return Promise.resolve(undefined);
}
return engine.getResizeMode();
},
};

View File

@ -22,7 +22,22 @@ export const goto = async (page: Page, url: string, options: any, testInfo: Test
const formattedRtl = urlToParams.get('rtl') ?? rtl;
const ionicTesting = urlToParams.get('ionic:_testing') ?? _testing;
const formattedUrl = `${splitUrl[0]}?ionic:_testing=${ionicTesting}&ionic:mode=${formattedMode}&rtl=${formattedRtl}`;
/**
* Pass through other custom query params
*/
urlToParams.delete('ionic:mode');
urlToParams.delete('rtl');
urlToParams.delete('ionic:_testing');
/**
* Convert remaining query params to a string.
* Be sure to call decodeURIComponent to decode
* characters such as &.
*/
const remainingQueryParams = decodeURIComponent(urlToParams.toString());
const remainingQueryParamsString = remainingQueryParams == '' ? '' : `&${remainingQueryParams}`;
const formattedUrl = `${splitUrl[0]}?ionic:_testing=${ionicTesting}&ionic:mode=${formattedMode}&rtl=${formattedRtl}${remainingQueryParamsString}`;
testInfo.annotations.push({
type: 'mode',