Skip to content

Commit

Permalink
feat: add experimental hardware back button support in browsers (#28705)
Browse files Browse the repository at this point in the history
resolves #28703
  • Loading branch information
liamdebeasi committed Jan 31, 2024
1 parent bf34e0e commit baedec5
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 15 deletions.
15 changes: 14 additions & 1 deletion core/src/components/app/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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') {
Expand Down
8 changes: 7 additions & 1 deletion core/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -321,7 +322,6 @@ export class Menu implements ComponentInterface, MenuI {
}
}

@Listen('keydown')
onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
this.close();
Expand Down Expand Up @@ -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 (
<Host
onKeyDown={shoudUseCloseWatcher() ? null : this.onKeydown}
role="navigation"
aria-label={inheritedAttributes['aria-label'] || 'menu'}
class={{
Expand Down
27 changes: 26 additions & 1 deletion core/src/utils/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,32 @@ type IonicEvents = {
): 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;

export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined;
Expand Down
8 changes: 8 additions & 0 deletions core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 54 additions & 3 deletions core/src/utils/hardware-back-button.ts
Original file line number Diff line number Diff line change
@@ -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<any> | void | null;

Expand All @@ -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
Expand All @@ -29,9 +49,9 @@ export const blockHardwareBackButton = () => {

export const startHardwareBackButton = () => {
const doc = document;

let busy = false;
doc.addEventListener('backbutton', () => {

const backButtonCallback = () => {
if (busy) {
return;
}
Expand Down Expand Up @@ -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;
Expand Down
38 changes: 29 additions & 9 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
}
}
});
});
}
}
};

Expand Down
52 changes: 52 additions & 0 deletions core/src/utils/test/hardware-back-button.spec.ts
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit baedec5

Please sign in to comment.