From baedec536426693c600cd942970bd6862c391659 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Fri, 5 Jan 2024 09:10:53 -0500 Subject: [PATCH] feat: add experimental hardware back button support in browsers (#28705) resolves #28703 --- core/src/components/app/app.tsx | 15 ++++- core/src/components/menu/menu.tsx | 8 ++- core/src/utils/browser/index.ts | 27 ++++++++- core/src/utils/config.ts | 8 +++ core/src/utils/hardware-back-button.ts | 57 ++++++++++++++++++- core/src/utils/overlays.ts | 38 ++++++++++--- .../utils/test/hardware-back-button.spec.ts | 52 +++++++++++++++++ 7 files changed, 190 insertions(+), 15 deletions(-) diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index b0fc4731b3c..44a67d2a156 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,6 +1,8 @@ import type { ComponentInterface } from '@stencil/core'; import { Build, Component, Element, Host, Method, h } from '@stencil/core'; import type { FocusVisibleUtility } from '@utils/focus-visible'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; +import { printIonWarning } from '@utils/logging'; import { isPlatform } from '@utils/platform'; import { config } from '../../global/config'; @@ -34,9 +36,20 @@ export class App implements ComponentInterface { import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); } const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); - if (config.getBoolean('hardwareBackButton', isHybrid)) { + const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher(); + 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 (shoudUseCloseWatcher()) { + 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') { diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 9fbb4a3bfff..437c5363453 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -2,6 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core'; import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { GESTURE_CONTROLLER } from '@utils/gesture'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers'; import { menuController } from '@utils/menu-controller'; @@ -321,7 +322,6 @@ export class Menu implements ComponentInterface, MenuI { } } - @Listen('keydown') onKeydown(ev: KeyboardEvent) { if (ev.key === 'Escape') { this.close(); @@ -781,8 +781,14 @@ export class Menu implements ComponentInterface, MenuI { const { type, disabled, isPaneVisible, inheritedAttributes, side } = this; const mode = getIonMode(this); + /** + * If the Close Watcher is enabled then + * the ionBackButton listener in the menu controller + * will handle closing the menu when Escape is pressed. + */ return ( void | null; + onclose: (event: Event) => void | null; +} + +interface CloseWatcherOptions { + signal: AbortSignal; +} + +/** + * Experimental browser features that + * are selectively used inside of Ionic + * Since they are experimental they typically + * do not have types yet, so we can add custom ones + * here until types are available. + */ +type ExperimentalWindowFeatures = { + CloseWatcher?: CloseWatcher; +}; + +type IonicWindow = Window & IonicEvents & ExperimentalWindowFeatures; type IonicDocument = Document & IonicEvents; export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined; diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index 4cf3cc08d0e..bc1dc785c2b 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -204,6 +204,14 @@ export interface IonicConfig { */ platform?: PlatformConfig; + /** + * @experimental + * If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle + * all Escape key and hardware back button presses to dismiss menus and overlays and to navigate. + * Note that the `hardwareBackButton` config option must also be `true`. + */ + experimentalCloseWatcher?: boolean; + // PRIVATE configs keyboardHeight?: number; inputShims?: boolean; diff --git a/core/src/utils/hardware-back-button.ts b/core/src/utils/hardware-back-button.ts index 91a0bc540f2..1005497faa1 100644 --- a/core/src/utils/hardware-back-button.ts +++ b/core/src/utils/hardware-back-button.ts @@ -1,3 +1,8 @@ +import { win } from '@utils/browser'; +import type { CloseWatcher } from '@utils/browser'; + +import { config } from '../global/config'; + // TODO(FW-2832): type type Handler = (processNextHandler: () => void) => Promise | void | null; @@ -13,6 +18,21 @@ interface HandlerRegister { id: number; } +/** + * CloseWatcher is a newer API that lets + * use detect the hardware back button event + * in a web browser: https://caniuse.com/?search=closewatcher + * However, not every browser supports it yet. + * + * This needs to be a function so that we can + * check the config once it has been set. + * Otherwise, this code would be evaluated the + * moment this file is evaluated which could be + * before the config is set. + */ +export const shoudUseCloseWatcher = () => + config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win; + /** * When hardwareBackButton: false in config, * we need to make sure we also block the default @@ -29,9 +49,9 @@ export const blockHardwareBackButton = () => { export const startHardwareBackButton = () => { const doc = document; - let busy = false; - doc.addEventListener('backbutton', () => { + + const backButtonCallback = () => { if (busy) { return; } @@ -81,7 +101,38 @@ export const startHardwareBackButton = () => { }; processHandlers(); - }); + }; + + /** + * If the CloseWatcher is defined then + * we don't want to also listen for the native + * backbutton event otherwise we may get duplicate + * events firing. + */ + if (shoudUseCloseWatcher()) { + let watcher: CloseWatcher | undefined; + + const configureWatcher = () => { + watcher?.destroy(); + watcher = new win!.CloseWatcher!(); + + /** + * Once a close request happens + * the watcher gets destroyed. + * As a result, we need to re-configure + * the watcher so we can respond to other + * close requests. + */ + watcher!.onclose = () => { + backButtonCallback(); + configureWatcher(); + }; + }; + + configureWatcher(); + } else { + doc.addEventListener('backbutton', backButtonCallback); + } }; export const OVERLAY_BACK_BUTTON_PRIORITY = 100; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index bd56ac36844..0ea4cb62727 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -1,5 +1,6 @@ import { doc } from '@utils/browser'; import type { BackButtonEvent } from '@utils/hardware-back-button'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; import { config } from '../global/config'; import { getIonMode } from '../global/ionic-global'; @@ -353,20 +354,39 @@ const connectListeners = (doc: Document) => { const lastOverlay = getPresentedOverlay(doc); if (lastOverlay?.backdropDismiss) { (ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => { - return lastOverlay.dismiss(undefined, BACKDROP); + /** + * Do not return this promise otherwise + * the hardware back button utility will + * be blocked until the overlay dismisses. + * This is important for a modal with canDismiss. + * If the application presents a confirmation alert + * in the "canDismiss" callback, then it will be impossible + * to use the hardware back button to dismiss the alert + * dialog because the hardware back button utility + * is blocked on waiting for the modal to dismiss. + */ + lastOverlay.dismiss(undefined, BACKDROP); }); } }); - // handle ESC to close overlay - doc.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') { - const lastOverlay = getPresentedOverlay(doc); - if (lastOverlay?.backdropDismiss) { - lastOverlay.dismiss(undefined, BACKDROP); + /** + * Handle ESC to close overlay. + * CloseWatcher also handles pressing the Esc + * key, so if a browser supports CloseWatcher then + * this behavior will be handled via the ionBackButton + * event. + */ + if (!shoudUseCloseWatcher()) { + doc.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { + const lastOverlay = getPresentedOverlay(doc); + if (lastOverlay?.backdropDismiss) { + lastOverlay.dismiss(undefined, BACKDROP); + } } - } - }); + }); + } } }; diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts index a4e5bbb7c45..87f27d407cc 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -1,5 +1,7 @@ import type { BackButtonEvent } from '../../../src/interface'; import { startHardwareBackButton } from '../hardware-back-button'; +import { config } from '../../global/config'; +import { win } from '@utils/browser'; describe('Hardware Back Button', () => { beforeEach(() => startHardwareBackButton()); @@ -54,6 +56,56 @@ describe('Hardware Back Button', () => { }); }); +describe('Experimental Close Watcher', () => { + test('should not use the Close Watcher API when available', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: false }); + + startHardwareBackButton(); + + expect(mockAPI.mock.calls).toHaveLength(0); + }); + test('should use the Close Watcher API when available', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: true }); + + startHardwareBackButton(); + + expect(mockAPI.mock.calls).toHaveLength(1); + }); + test('Close Watcher should dispatch ionBackButton events', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: true }); + + startHardwareBackButton(); + + const cbSpy = jest.fn(); + document.addEventListener('ionBackButton', cbSpy); + + // Call onclose on Ionic's instance of CloseWatcher + mockAPI.getMockImplementation()!().onclose(); + + expect(cbSpy).toHaveBeenCalled(); + }); +}); + +const mockCloseWatcher = () => { + const mockCloseWatcher = jest.fn(); + mockCloseWatcher.mockReturnValue({ + requestClose: () => null, + close: () => null, + destroy: () => null, + oncancel: () => null, + onclose: () => null, + }); + (win as any).CloseWatcher = mockCloseWatcher; + + return mockCloseWatcher; +}; + const dispatchBackButtonEvent = () => { const ev = new Event('backbutton'); document.dispatchEvent(ev);