From ba6b5396754151d884fa276a7227facd28a431df Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Fri, 9 Sep 2022 14:22:44 -0500 Subject: [PATCH] fix(input, textarea): padding is now added to content so inputs scroll above keyboard (#25849) resolves #18532 --- .../utils/input-shims/hacks/scroll-assist.ts | 64 +++++- .../utils/input-shims/hacks/scroll-padding.ts | 92 +++++---- .../utils/input-shims/hacks/test/index.html | 18 ++ .../hacks/test/scroll-assist.e2e.ts | 194 ++++++++++++++---- core/src/utils/input-shims/input-shims.ts | 29 ++- core/src/utils/native/keyboard.ts | 27 +++ .../utils/test/playwright/page/utils/goto.ts | 17 +- 7 files changed, 349 insertions(+), 92 deletions(-) create mode 100644 core/src/utils/native/keyboard.ts diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index 72845c953c..2de0df3b02 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -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 = () => { diff --git a/core/src/utils/input-shims/hacks/scroll-padding.ts b/core/src/utils/input-shims/hacks/scroll-padding.ts index d729c492d7..da540eef5b 100644 --- a/core/src/utils/input-shims/hacks/scroll-padding.ts +++ b/core/src/utils/input-shims/hacks/scroll-padding.ts @@ -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 }); +}; diff --git a/core/src/utils/input-shims/hacks/test/index.html b/core/src/utils/input-shims/hacks/test/index.html index ac588d0ce8..1864094236 100644 --- a/core/src/utils/input-shims/hacks/test/index.html +++ b/core/src/utils/input-shims/hacks/test/index.html @@ -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, + }); + }, + }, + }, + }; + } diff --git a/core/src/utils/input-shims/hacks/test/scroll-assist.e2e.ts b/core/src/utils/input-shims/hacks/test/scroll-assist.e2e.ts index 5bbd28d1f9..9227dca3c8 100644 --- a/core/src/utils/input-shims/hacks/test/scroll-assist.e2e.ts +++ b/core/src/utils/input-shims/hacks/test/scroll-assist.e2e.ts @@ -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'); + } +} diff --git a/core/src/utils/input-shims/input-shims.ts b/core/src/utils/input-shims/input-shims.ts index a83f4776fd..1bee552d37 100644 --- a/core/src/utils/input-shims/input-shims.ts +++ b/core/src/utils/input-shims/input-shims.ts @@ -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 void>(); const scrollAssistMap = new WeakMap 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. diff --git a/core/src/utils/native/keyboard.ts b/core/src/utils/native/keyboard.ts new file mode 100644 index 0000000000..2c1bc7e4c4 --- /dev/null +++ b/core/src/utils/native/keyboard.ts @@ -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 { + const engine = this.getEngine(); + if (!engine || !engine.getResizeMode) { + return Promise.resolve(undefined); + } + + return engine.getResizeMode(); + }, +}; diff --git a/core/src/utils/test/playwright/page/utils/goto.ts b/core/src/utils/test/playwright/page/utils/goto.ts index f1667b259e..9f5e84dbcc 100644 --- a/core/src/utils/test/playwright/page/utils/goto.ts +++ b/core/src/utils/test/playwright/page/utils/goto.ts @@ -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',