feat: add experimental hardware back button support in browsers (#28705)

resolves #28703
This commit is contained in:
Liam DeBeasi
2024-01-05 09:10:53 -05:00
committed by GitHub
parent e886e3ff2f
commit 658d1caccd
7 changed files with 190 additions and 15 deletions

View File

@ -1,6 +1,8 @@
import type { ComponentInterface } from '@stencil/core'; import type { ComponentInterface } from '@stencil/core';
import { Build, Component, Element, Host, Method, h } from '@stencil/core'; import { Build, Component, Element, Host, Method, h } from '@stencil/core';
import type { FocusVisibleUtility } from '@utils/focus-visible'; 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 { isPlatform } from '@utils/platform';
import { config } from '../../global/config'; 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)); import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform));
} }
const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); const hardwareBackButtonModule = await import('../../utils/hardware-back-button');
if (config.getBoolean('hardwareBackButton', isHybrid)) { const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher();
if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) {
hardwareBackButtonModule.startHardwareBackButton(); hardwareBackButtonModule.startHardwareBackButton();
} else { } 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(); hardwareBackButtonModule.blockHardwareBackButton();
} }
if (typeof (window as any) !== 'undefined') { if (typeof (window as any) !== 'undefined') {

View File

@ -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 { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { GESTURE_CONTROLLER } from '@utils/gesture'; import { GESTURE_CONTROLLER } from '@utils/gesture';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers'; import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { menuController } from '@utils/menu-controller'; import { menuController } from '@utils/menu-controller';
@ -321,7 +322,6 @@ export class Menu implements ComponentInterface, MenuI {
} }
} }
@Listen('keydown')
onKeydown(ev: KeyboardEvent) { onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
this.close(); this.close();
@ -781,8 +781,14 @@ export class Menu implements ComponentInterface, MenuI {
const { type, disabled, isPaneVisible, inheritedAttributes, side } = this; const { type, disabled, isPaneVisible, inheritedAttributes, side } = this;
const mode = getIonMode(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 ( return (
<Host <Host
onKeyDown={shoudUseCloseWatcher() ? null : this.onKeydown}
role="navigation" role="navigation"
aria-label={inheritedAttributes['aria-label'] || 'menu'} aria-label={inheritedAttributes['aria-label'] || 'menu'}
class={{ class={{

View File

@ -72,7 +72,32 @@ type IonicEvents = {
): void; ): void;
}; };
type IonicWindow = Window & IonicEvents; export interface CloseWatcher extends EventTarget {
new (options?: CloseWatcherOptions): any;
requestClose(): void;
close(): void;
destroy(): void;
oncancel: (event: Event) => 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; type IonicDocument = Document & IonicEvents;
export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined; export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined;

View File

@ -204,6 +204,14 @@ export interface IonicConfig {
*/ */
platform?: PlatformConfig; 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 // PRIVATE configs
keyboardHeight?: number; keyboardHeight?: number;
inputShims?: boolean; inputShims?: boolean;

View File

@ -1,3 +1,8 @@
import { win } from '@utils/browser';
import type { CloseWatcher } from '@utils/browser';
import { config } from '../global/config';
// TODO(FW-2832): type // TODO(FW-2832): type
type Handler = (processNextHandler: () => void) => Promise<any> | void | null; type Handler = (processNextHandler: () => void) => Promise<any> | void | null;
@ -13,6 +18,21 @@ interface HandlerRegister {
id: number; 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, * When hardwareBackButton: false in config,
* we need to make sure we also block the default * we need to make sure we also block the default
@ -29,9 +49,9 @@ export const blockHardwareBackButton = () => {
export const startHardwareBackButton = () => { export const startHardwareBackButton = () => {
const doc = document; const doc = document;
let busy = false; let busy = false;
doc.addEventListener('backbutton', () => {
const backButtonCallback = () => {
if (busy) { if (busy) {
return; return;
} }
@ -81,7 +101,38 @@ export const startHardwareBackButton = () => {
}; };
processHandlers(); 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; export const OVERLAY_BACK_BUTTON_PRIORITY = 100;

View File

@ -1,5 +1,6 @@
import { doc } from '@utils/browser'; import { doc } from '@utils/browser';
import type { BackButtonEvent } from '@utils/hardware-back-button'; import type { BackButtonEvent } from '@utils/hardware-back-button';
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
import { config } from '../global/config'; import { config } from '../global/config';
import { getIonMode } from '../global/ionic-global'; import { getIonMode } from '../global/ionic-global';
@ -353,12 +354,30 @@ const connectListeners = (doc: Document) => {
const lastOverlay = getPresentedOverlay(doc); const lastOverlay = getPresentedOverlay(doc);
if (lastOverlay?.backdropDismiss) { if (lastOverlay?.backdropDismiss) {
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => { (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 /**
* 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) => { doc.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
const lastOverlay = getPresentedOverlay(doc); const lastOverlay = getPresentedOverlay(doc);
@ -368,6 +387,7 @@ const connectListeners = (doc: Document) => {
} }
}); });
} }
}
}; };
export const dismissOverlay = ( export const dismissOverlay = (

View File

@ -1,5 +1,7 @@
import type { BackButtonEvent } from '../../../src/interface'; import type { BackButtonEvent } from '../../../src/interface';
import { startHardwareBackButton } from '../hardware-back-button'; import { startHardwareBackButton } from '../hardware-back-button';
import { config } from '../../global/config';
import { win } from '@utils/browser';
describe('Hardware Back Button', () => { describe('Hardware Back Button', () => {
beforeEach(() => startHardwareBackButton()); 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 dispatchBackButtonEvent = () => {
const ev = new Event('backbutton'); const ev = new Event('backbutton');
document.dispatchEvent(ev); document.dispatchEvent(ev);