From 15d6104c6f40b0e1322423d6a713254f54c990bf Mon Sep 17 00:00:00 2001 From: Tanner Reits <47483144+tanner-reits@users.noreply.github.com> Date: Wed, 16 Oct 2024 15:15:54 -0400 Subject: [PATCH] feat(app): move `ion-app` init logic to `initialize` function (#29930) Issue number: resolves internal --------- ## What is the current behavior? Some app functionality (like focus management, keyboard utils, and shimming) are tied to the `ion-app` which requires all Ionic applications to have a root `ion-app` element. ## What is the new behavior? `ion-app` specific init functionality is moved to the global `initialize` function ## Does this introduce a breaking change? - [ ] Yes - [x] No Although it is not expected that this introduces a breaking change, these changes were introduced on the `next` branch as a precaution. ## Other information NOTE: This is **NOT** a recommended pattern for Ionic applications and was created for a specific internal use case --------- Co-authored-by: Brandy Carney --- core/api.txt | 4 +- core/src/components/app/app.tsx | 87 +------------------ core/src/components/content/content.tsx | 4 +- core/src/components/footer/footer.tsx | 4 +- core/src/components/header/header.tsx | 7 +- core/src/global/ionic-global.ts | 77 +++++++++++++++- core/src/utils/config.ts | 8 ++ core/src/utils/focus-visible.ts | 10 +++ core/src/utils/framework-delegate.ts | 4 +- core/src/utils/helpers.ts | 4 +- .../src/utils/keyboard/keyboard-controller.ts | 4 +- core/src/utils/overlays.ts | 3 +- 12 files changed, 122 insertions(+), 94 deletions(-) diff --git a/core/api.txt b/core/api.txt index 4ca483d69dd..3805cae0aca 100644 --- a/core/api.txt +++ b/core/api.txt @@ -884,7 +884,7 @@ ion-infinite-scroll-content,prop,theme,"ios" | "md" | "ionic",undefined,false,fa ion-input,scoped ion-input,prop,autocapitalize,string,'off',false,false -ion-input,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false +ion-input,prop,autocomplete,"name" | "url" | "off" | "on" | "additional-name" | "address-level1" | "address-level2" | "address-level3" | "address-level4" | "address-line1" | "address-line2" | "address-line3" | "bday-day" | "bday-month" | "bday-year" | "cc-csc" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-family-name" | "cc-given-name" | "cc-name" | "cc-number" | "cc-type" | "country" | "country-name" | "current-password" | "family-name" | "given-name" | "honorific-prefix" | "honorific-suffix" | "new-password" | "one-time-code" | "organization" | "postal-code" | "street-address" | "transaction-amount" | "transaction-currency" | "username" | "email" | "tel" | "tel-area-code" | "tel-country-code" | "tel-extension" | "tel-local" | "tel-national" | "nickname" | "organization-title" | "cc-additional-name" | "language" | "bday" | "sex" | "impp" | "photo",'off',false,false ion-input,prop,autocorrect,"off" | "on",'off',false,false ion-input,prop,autofocus,boolean,false,false,false ion-input,prop,clearInput,boolean,false,false,false @@ -1867,7 +1867,7 @@ ion-row,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-searchbar,scoped ion-searchbar,prop,animated,boolean,false,false,false ion-searchbar,prop,autocapitalize,string,'off',false,false -ion-searchbar,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false +ion-searchbar,prop,autocomplete,"name" | "url" | "off" | "on" | "additional-name" | "address-level1" | "address-level2" | "address-level3" | "address-level4" | "address-line1" | "address-line2" | "address-line3" | "bday-day" | "bday-month" | "bday-year" | "cc-csc" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-family-name" | "cc-given-name" | "cc-name" | "cc-number" | "cc-type" | "country" | "country-name" | "current-password" | "family-name" | "given-name" | "honorific-prefix" | "honorific-suffix" | "new-password" | "one-time-code" | "organization" | "postal-code" | "street-address" | "transaction-amount" | "transaction-currency" | "username" | "email" | "tel" | "tel-area-code" | "tel-country-code" | "tel-extension" | "tel-local" | "tel-national" | "nickname" | "organization-title" | "cc-additional-name" | "language" | "bday" | "sex" | "impp" | "photo",'off',false,false ion-searchbar,prop,autocorrect,"off" | "on",'off',false,false ion-searchbar,prop,cancelButtonIcon,string | undefined,undefined,false,false ion-searchbar,prop,cancelButtonText,string,'Cancel',false,false diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index 2ed1b3679d5..dfbbfc45a93 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,9 +1,6 @@ import type { ComponentInterface } from '@stencil/core'; -import { Build, Component, Element, Host, Method, h } from '@stencil/core'; -import type { FocusVisibleUtility } from '@utils/focus-visible'; -import { shouldUseCloseWatcher } from '@utils/hardware-back-button'; -import { printIonWarning } from '@utils/logging'; -import { isPlatform } from '@utils/platform'; +import { Component, Element, Host, Method, h } from '@stencil/core'; +import { getOrInitFocusVisibleUtility } from '@utils/focus-visible'; import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; @@ -17,53 +14,8 @@ import { getIonTheme } from '../../global/ionic-global'; styleUrl: 'app.scss', }) export class App implements ComponentInterface { - private focusVisible?: FocusVisibleUtility; - @Element() el!: HTMLElement; - componentDidLoad() { - if (Build.isBrowser) { - rIC(async () => { - const isHybrid = isPlatform(window, 'hybrid'); - if (!config.getBoolean('_testing')) { - import('../../utils/tap-click').then((module) => module.startTapClick(config)); - } - if (config.getBoolean('statusTap', isHybrid)) { - import('../../utils/status-tap').then((module) => module.startStatusTap()); - } - if (config.getBoolean('inputShims', needInputShims())) { - /** - * needInputShims() ensures that only iOS and Android - * platforms proceed into this block. - */ - const platform = isPlatform(window, 'ios') ? 'ios' : 'android'; - import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); - } - const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); - const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher(); - if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) { - hardwareBackButtonModule.startHardwareBackButton(); - } else { - /** - * If an app sets hardwareBackButton: false and experimentalCloseWatcher: true - * then the close watcher will not be used. - */ - if (shouldUseCloseWatcher()) { - printIonWarning( - 'experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.' - ); - } - - hardwareBackButtonModule.blockHardwareBackButton(); - } - if (typeof (window as any) !== 'undefined') { - import('../../utils/keyboard/keyboard').then((module) => module.startKeyboardAssist(window)); - } - import('../../utils/focus-visible').then((module) => (this.focusVisible = module.startFocusVisible())); - }); - } - } - /** * @internal * Used to set focus on an element that uses `ion-focusable`. @@ -76,9 +28,8 @@ export class App implements ComponentInterface { */ @Method() async setFocus(elements: HTMLElement[]) { - if (this.focusVisible) { - this.focusVisible.setFocus(elements); - } + const focusVisible = getOrInitFocusVisibleUtility(); + focusVisible.setFocus(elements); } render() { @@ -94,33 +45,3 @@ export class App implements ComponentInterface { ); } } - -const needInputShims = () => { - /** - * iOS always needs input shims - */ - const needsShimsIOS = isPlatform(window, 'ios') && isPlatform(window, 'mobile'); - if (needsShimsIOS) { - return true; - } - - /** - * Android only needs input shims when running - * in the browser and only if the browser is using the - * new Chrome 108+ resize behavior: https://developer.chrome.com/blog/viewport-resize-behavior/ - */ - const isAndroidMobileWeb = isPlatform(window, 'android') && isPlatform(window, 'mobileweb'); - if (isAndroidMobileWeb) { - return true; - } - - return false; -}; - -const rIC = (callback: () => void) => { - if ('requestIdleCallback' in window) { - (window as any).requestIdleCallback(callback); - } else { - setTimeout(callback, 32); - } -}; diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index a3f19dbcbc6..aef15c9c00c 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -6,6 +6,7 @@ import { isPlatform } from '@utils/platform'; import { isRTL } from '@utils/rtl'; import { createColorClasses, hostContext } from '@utils/theme'; +import { config } from '../../global/config'; import { getIonMode, getIonTheme } from '../../global/ionic-global'; import type { Color, Mode } from '../../interface'; @@ -518,7 +519,8 @@ const getPageElement = (el: HTMLElement) => { * between the popover and the edges of the screen. But if the popover contains * its own page element, we should use that instead. */ - const page = el.closest('ion-app, ion-page, .ion-page, page-inner, .popover-content'); + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const page = el.closest(`${appRootSelector}, ion-page, .ion-page, page-inner, .popover-content`); if (page) { return page; } diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index 8fd77a24bc6..0fb75cac82f 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -4,6 +4,7 @@ import { findIonContent, getScrollElement, printIonContentErrorMsg } from '@util import type { KeyboardController } from '@utils/keyboard/keyboard-controller'; import { createKeyboardController } from '@utils/keyboard/keyboard-controller'; +import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; import { handleFooterFade } from './footer.utils'; @@ -86,7 +87,8 @@ export class Footer implements ComponentInterface { this.destroyCollapsibleFooter(); if (hasFade) { - const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const pageEl = this.el.closest(`${appRootSelector},ion-page,.ion-page,page-inner`); const contentEl = pageEl ? findIonContent(pageEl) : null; if (!contentEl) { diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 8decd60ef98..ab87f2c82e9 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -5,6 +5,7 @@ import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes } from '@utils/helpers'; import { hostContext } from '@utils/theme'; +import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; import { @@ -91,8 +92,10 @@ export class Header implements ComponentInterface { this.destroyCollapsibleHeader(); + const appRootSelector = config.get('appRootSelector', 'ion-app'); + if (hasCondense) { - const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); + const pageEl = this.el.closest(`${appRootSelector},ion-page,.ion-page,page-inner`); const contentEl = pageEl ? findIonContent(pageEl) : null; // Cloned elements are always needed in iOS transition @@ -104,7 +107,7 @@ export class Header implements ComponentInterface { await this.setupCondenseHeader(contentEl, pageEl); } else if (hasFade) { - const pageEl = this.el.closest('ion-app,ion-page,.ion-page,page-inner'); + const pageEl = this.el.closest(`${appRootSelector},ion-page,.ion-page,page-inner`); const contentEl = pageEl ? findIonContent(pageEl) : null; if (!contentEl) { diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index ee2ff31ddc1..427887a97b8 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -1,7 +1,8 @@ -import { getMode, setMode, setPlatformHelpers, getElement } from '@stencil/core'; +import { Build, getMode, setMode, setPlatformHelpers, getElement } from '@stencil/core'; import { printIonWarning } from '@utils/logging'; import type { IonicConfig, Mode, Theme } from '../interface'; +import { shouldUseCloseWatcher } from '../utils/hardware-back-button'; import { isPlatform, setupPlatforms } from '../utils/platform'; import { config, configFromSession, configFromURL, saveConfig } from './config'; @@ -131,6 +132,36 @@ export const getIonTheme = (ref?: any): Theme => { return defaultTheme; }; +export const rIC = (callback: () => void) => { + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(callback); + } else { + setTimeout(callback, 32); + } +}; + +export const needInputShims = () => { + /** + * iOS always needs input shims + */ + const needsShimsIOS = isPlatform(window, 'ios') && isPlatform(window, 'mobile'); + if (needsShimsIOS) { + return true; + } + + /** + * Android only needs input shims when running + * in the browser and only if the browser is using the + * new Chrome 108+ resize behavior: https://developer.chrome.com/blog/viewport-resize-behavior/ + */ + const isAndroidMobileWeb = isPlatform(window, 'android') && isPlatform(window, 'mobileweb'); + if (isAndroidMobileWeb) { + return true; + } + + return false; +}; + export const initialize = (userConfig: IonicConfig = {}) => { if (typeof (window as any) === 'undefined') { return; @@ -255,6 +286,50 @@ export const initialize = (userConfig: IonicConfig = {}) => { } return defaultTheme; }); + + // `IonApp` code + // ---------------------------------------------- + + if (Build.isBrowser) { + rIC(async () => { + const isHybrid = isPlatform(window, 'hybrid'); + if (!config.getBoolean('_testing')) { + import('../utils/tap-click').then((module) => module.startTapClick(config)); + } + if (config.getBoolean('statusTap', isHybrid)) { + import('../utils/status-tap').then((module) => module.startStatusTap()); + } + if (config.getBoolean('inputShims', needInputShims())) { + /** + * needInputShims() ensures that only iOS and Android + * platforms proceed into this block. + */ + const platform = isPlatform(window, 'ios') ? 'ios' : 'android'; + import('../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); + } + const hardwareBackButtonModule = await import('../utils/hardware-back-button'); + const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher(); + if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) { + hardwareBackButtonModule.startHardwareBackButton(); + } else { + /** + * If an app sets hardwareBackButton: false and experimentalCloseWatcher: true + * then the close watcher will not be used. + */ + if (shouldUseCloseWatcher()) { + printIonWarning( + 'experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.' + ); + } + + hardwareBackButtonModule.blockHardwareBackButton(); + } + if (typeof (window as any) !== 'undefined') { + import('../utils/keyboard/keyboard').then((module) => module.startKeyboardAssist(window)); + } + import('../utils/focus-visible').then((module) => module.getOrInitFocusVisibleUtility()); + }); + } }; export default initialize; diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index ceb76550777..283b0ff274e 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -124,6 +124,14 @@ export interface IonicConfig { */ toastDuration?: number; + /** + * The selector that will be used to query the root of the Ionic application. + * This element is used for things like injecting overlay elements into the DOM and managing focus. + * + * @default 'ion-app' + */ + appRootSelector?: string; + /** * Overrides the toggle icon for all `ion-accordion` components. */ diff --git a/core/src/utils/focus-visible.ts b/core/src/utils/focus-visible.ts index b5473c9e680..259518789cb 100644 --- a/core/src/utils/focus-visible.ts +++ b/core/src/utils/focus-visible.ts @@ -20,6 +20,16 @@ export interface FocusVisibleUtility { setFocus: (elements: Element[]) => void; } +let focusVisibleUtility: FocusVisibleUtility | null = null; + +export const getOrInitFocusVisibleUtility = () => { + if (!focusVisibleUtility) { + focusVisibleUtility = startFocusVisible(); + } + + return focusVisibleUtility; +}; + export const startFocusVisible = (rootEl?: HTMLElement): FocusVisibleUtility => { let currentFocus: Element[] = []; let keyboardMode = true; diff --git a/core/src/utils/framework-delegate.ts b/core/src/utils/framework-delegate.ts index 6a2f7041cb0..37616c037ac 100644 --- a/core/src/utils/framework-delegate.ts +++ b/core/src/utils/framework-delegate.ts @@ -1,3 +1,4 @@ +import { config } from '../global/config'; import type { ComponentRef, FrameworkDelegate } from '../interface'; import { componentOnReady } from './helpers'; @@ -128,7 +129,8 @@ export const CoreDelegate = () => { * Get the root of the app and * add the overlay there. */ - const app = document.querySelector('ion-app') || document.body; + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const app = document.querySelector(appRootSelector) || document.body; /** * Create a placeholder comment so that diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index fdf351e1a0c..c57c95a70c5 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -1,6 +1,7 @@ import type { EventEmitter } from '@stencil/core'; import type { Side } from '../components/menu/menu-interface'; +import { config } from '../global/config'; // TODO(FW-2832): types @@ -266,7 +267,8 @@ export const focusVisibleElement = (el: HTMLElement) => { * which will let us explicitly set the elements to focus. */ if (el.classList.contains('ion-focusable')) { - const app = el.closest('ion-app'); + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const app = el.closest(appRootSelector) as HTMLIonAppElement | null; if (app) { app.setFocus([el]); } diff --git a/core/src/utils/keyboard/keyboard-controller.ts b/core/src/utils/keyboard/keyboard-controller.ts index 2cf0c19d344..5c08ce58710 100644 --- a/core/src/utils/keyboard/keyboard-controller.ts +++ b/core/src/utils/keyboard/keyboard-controller.ts @@ -1,5 +1,6 @@ import { doc, win } from '@utils/browser'; +import { config } from '../../global/config'; import { Keyboard, KeyboardResize } from '../native/keyboard'; /** @@ -25,7 +26,8 @@ const getResizeContainer = (resizeMode?: KeyboardResize): HTMLElement | null => * on that. In the event `ion-app` is not available then * we can fall back to `body`. */ - const ionApp = doc.querySelector('ion-app'); + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const ionApp = doc.querySelector(appRootSelector) as HTMLIonAppElement | null; return ionApp ?? doc.body; }; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index e69bd49dcda..a23f305c7b7 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -697,7 +697,8 @@ export const dismiss = async ( }; const getAppRoot = (doc: Document) => { - return doc.querySelector('ion-app') || doc.body; + const appRootSelector = config.get('appRootSelector', 'ion-app'); + return doc.querySelector(appRootSelector) || doc.body; }; const overlayAnimation = async (