Skip to content

Commit 658d1ca

Browse files
authored
feat: add experimental hardware back button support in browsers (#28705)
resolves #28703
1 parent e886e3f commit 658d1ca

File tree

7 files changed

+190
-15
lines changed

7 files changed

+190
-15
lines changed

core/src/components/app/app.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { ComponentInterface } from '@stencil/core';
22
import { Build, Component, Element, Host, Method, h } from '@stencil/core';
33
import type { FocusVisibleUtility } from '@utils/focus-visible';
4+
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
5+
import { printIonWarning } from '@utils/logging';
46
import { isPlatform } from '@utils/platform';
57

68
import { config } from '../../global/config';
@@ -34,9 +36,20 @@ export class App implements ComponentInterface {
3436
import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform));
3537
}
3638
const hardwareBackButtonModule = await import('../../utils/hardware-back-button');
37-
if (config.getBoolean('hardwareBackButton', isHybrid)) {
39+
const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher();
40+
if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) {
3841
hardwareBackButtonModule.startHardwareBackButton();
3942
} else {
43+
/**
44+
* If an app sets hardwareBackButton: false and experimentalCloseWatcher: true
45+
* then the close watcher will not be used.
46+
*/
47+
if (shoudUseCloseWatcher()) {
48+
printIonWarning(
49+
'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.'
50+
);
51+
}
52+
4053
hardwareBackButtonModule.blockHardwareBackButton();
4154
}
4255
if (typeof (window as any) !== 'undefined') {

core/src/components/menu/menu.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
33
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
44
import { GESTURE_CONTROLLER } from '@utils/gesture';
5+
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
56
import type { Attributes } from '@utils/helpers';
67
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
78
import { menuController } from '@utils/menu-controller';
@@ -321,7 +322,6 @@ export class Menu implements ComponentInterface, MenuI {
321322
}
322323
}
323324

324-
@Listen('keydown')
325325
onKeydown(ev: KeyboardEvent) {
326326
if (ev.key === 'Escape') {
327327
this.close();
@@ -781,8 +781,14 @@ export class Menu implements ComponentInterface, MenuI {
781781
const { type, disabled, isPaneVisible, inheritedAttributes, side } = this;
782782
const mode = getIonMode(this);
783783

784+
/**
785+
* If the Close Watcher is enabled then
786+
* the ionBackButton listener in the menu controller
787+
* will handle closing the menu when Escape is pressed.
788+
*/
784789
return (
785790
<Host
791+
onKeyDown={shoudUseCloseWatcher() ? null : this.onKeydown}
786792
role="navigation"
787793
aria-label={inheritedAttributes['aria-label'] || 'menu'}
788794
class={{

core/src/utils/browser/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,32 @@ type IonicEvents = {
7272
): void;
7373
};
7474

75-
type IonicWindow = Window & IonicEvents;
75+
export interface CloseWatcher extends EventTarget {
76+
new (options?: CloseWatcherOptions): any;
77+
requestClose(): void;
78+
close(): void;
79+
destroy(): void;
80+
81+
oncancel: (event: Event) => void | null;
82+
onclose: (event: Event) => void | null;
83+
}
84+
85+
interface CloseWatcherOptions {
86+
signal: AbortSignal;
87+
}
88+
89+
/**
90+
* Experimental browser features that
91+
* are selectively used inside of Ionic
92+
* Since they are experimental they typically
93+
* do not have types yet, so we can add custom ones
94+
* here until types are available.
95+
*/
96+
type ExperimentalWindowFeatures = {
97+
CloseWatcher?: CloseWatcher;
98+
};
99+
100+
type IonicWindow = Window & IonicEvents & ExperimentalWindowFeatures;
76101
type IonicDocument = Document & IonicEvents;
77102

78103
export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined;

core/src/utils/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ export interface IonicConfig {
204204
*/
205205
platform?: PlatformConfig;
206206

207+
/**
208+
* @experimental
209+
* If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle
210+
* all Escape key and hardware back button presses to dismiss menus and overlays and to navigate.
211+
* Note that the `hardwareBackButton` config option must also be `true`.
212+
*/
213+
experimentalCloseWatcher?: boolean;
214+
207215
// PRIVATE configs
208216
keyboardHeight?: number;
209217
inputShims?: boolean;

core/src/utils/hardware-back-button.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { win } from '@utils/browser';
2+
import type { CloseWatcher } from '@utils/browser';
3+
4+
import { config } from '../global/config';
5+
16
// TODO(FW-2832): type
27
type Handler = (processNextHandler: () => void) => Promise<any> | void | null;
38

@@ -13,6 +18,21 @@ interface HandlerRegister {
1318
id: number;
1419
}
1520

21+
/**
22+
* CloseWatcher is a newer API that lets
23+
* use detect the hardware back button event
24+
* in a web browser: https://caniuse.com/?search=closewatcher
25+
* However, not every browser supports it yet.
26+
*
27+
* This needs to be a function so that we can
28+
* check the config once it has been set.
29+
* Otherwise, this code would be evaluated the
30+
* moment this file is evaluated which could be
31+
* before the config is set.
32+
*/
33+
export const shoudUseCloseWatcher = () =>
34+
config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win;
35+
1636
/**
1737
* When hardwareBackButton: false in config,
1838
* we need to make sure we also block the default
@@ -29,9 +49,9 @@ export const blockHardwareBackButton = () => {
2949

3050
export const startHardwareBackButton = () => {
3151
const doc = document;
32-
3352
let busy = false;
34-
doc.addEventListener('backbutton', () => {
53+
54+
const backButtonCallback = () => {
3555
if (busy) {
3656
return;
3757
}
@@ -81,7 +101,38 @@ export const startHardwareBackButton = () => {
81101
};
82102

83103
processHandlers();
84-
});
104+
};
105+
106+
/**
107+
* If the CloseWatcher is defined then
108+
* we don't want to also listen for the native
109+
* backbutton event otherwise we may get duplicate
110+
* events firing.
111+
*/
112+
if (shoudUseCloseWatcher()) {
113+
let watcher: CloseWatcher | undefined;
114+
115+
const configureWatcher = () => {
116+
watcher?.destroy();
117+
watcher = new win!.CloseWatcher!();
118+
119+
/**
120+
* Once a close request happens
121+
* the watcher gets destroyed.
122+
* As a result, we need to re-configure
123+
* the watcher so we can respond to other
124+
* close requests.
125+
*/
126+
watcher!.onclose = () => {
127+
backButtonCallback();
128+
configureWatcher();
129+
};
130+
};
131+
132+
configureWatcher();
133+
} else {
134+
doc.addEventListener('backbutton', backButtonCallback);
135+
}
85136
};
86137

87138
export const OVERLAY_BACK_BUTTON_PRIORITY = 100;

core/src/utils/overlays.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { doc } from '@utils/browser';
22
import type { BackButtonEvent } from '@utils/hardware-back-button';
3+
import { shoudUseCloseWatcher } from '@utils/hardware-back-button';
34

45
import { config } from '../global/config';
56
import { getIonMode } from '../global/ionic-global';
@@ -353,20 +354,39 @@ const connectListeners = (doc: Document) => {
353354
const lastOverlay = getPresentedOverlay(doc);
354355
if (lastOverlay?.backdropDismiss) {
355356
(ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => {
356-
return lastOverlay.dismiss(undefined, BACKDROP);
357+
/**
358+
* Do not return this promise otherwise
359+
* the hardware back button utility will
360+
* be blocked until the overlay dismisses.
361+
* This is important for a modal with canDismiss.
362+
* If the application presents a confirmation alert
363+
* in the "canDismiss" callback, then it will be impossible
364+
* to use the hardware back button to dismiss the alert
365+
* dialog because the hardware back button utility
366+
* is blocked on waiting for the modal to dismiss.
367+
*/
368+
lastOverlay.dismiss(undefined, BACKDROP);
357369
});
358370
}
359371
});
360372

361-
// handle ESC to close overlay
362-
doc.addEventListener('keydown', (ev) => {
363-
if (ev.key === 'Escape') {
364-
const lastOverlay = getPresentedOverlay(doc);
365-
if (lastOverlay?.backdropDismiss) {
366-
lastOverlay.dismiss(undefined, BACKDROP);
373+
/**
374+
* Handle ESC to close overlay.
375+
* CloseWatcher also handles pressing the Esc
376+
* key, so if a browser supports CloseWatcher then
377+
* this behavior will be handled via the ionBackButton
378+
* event.
379+
*/
380+
if (!shoudUseCloseWatcher()) {
381+
doc.addEventListener('keydown', (ev) => {
382+
if (ev.key === 'Escape') {
383+
const lastOverlay = getPresentedOverlay(doc);
384+
if (lastOverlay?.backdropDismiss) {
385+
lastOverlay.dismiss(undefined, BACKDROP);
386+
}
367387
}
368-
}
369-
});
388+
});
389+
}
370390
}
371391
};
372392

core/src/utils/test/hardware-back-button.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { BackButtonEvent } from '../../../src/interface';
22
import { startHardwareBackButton } from '../hardware-back-button';
3+
import { config } from '../../global/config';
4+
import { win } from '@utils/browser';
35

46
describe('Hardware Back Button', () => {
57
beforeEach(() => startHardwareBackButton());
@@ -54,6 +56,56 @@ describe('Hardware Back Button', () => {
5456
});
5557
});
5658

59+
describe('Experimental Close Watcher', () => {
60+
test('should not use the Close Watcher API when available', () => {
61+
const mockAPI = mockCloseWatcher();
62+
63+
config.reset({ experimentalCloseWatcher: false });
64+
65+
startHardwareBackButton();
66+
67+
expect(mockAPI.mock.calls).toHaveLength(0);
68+
});
69+
test('should use the Close Watcher API when available', () => {
70+
const mockAPI = mockCloseWatcher();
71+
72+
config.reset({ experimentalCloseWatcher: true });
73+
74+
startHardwareBackButton();
75+
76+
expect(mockAPI.mock.calls).toHaveLength(1);
77+
});
78+
test('Close Watcher should dispatch ionBackButton events', () => {
79+
const mockAPI = mockCloseWatcher();
80+
81+
config.reset({ experimentalCloseWatcher: true });
82+
83+
startHardwareBackButton();
84+
85+
const cbSpy = jest.fn();
86+
document.addEventListener('ionBackButton', cbSpy);
87+
88+
// Call onclose on Ionic's instance of CloseWatcher
89+
mockAPI.getMockImplementation()!().onclose();
90+
91+
expect(cbSpy).toHaveBeenCalled();
92+
});
93+
});
94+
95+
const mockCloseWatcher = () => {
96+
const mockCloseWatcher = jest.fn();
97+
mockCloseWatcher.mockReturnValue({
98+
requestClose: () => null,
99+
close: () => null,
100+
destroy: () => null,
101+
oncancel: () => null,
102+
onclose: () => null,
103+
});
104+
(win as any).CloseWatcher = mockCloseWatcher;
105+
106+
return mockCloseWatcher;
107+
};
108+
57109
const dispatchBackButtonEvent = () => {
58110
const ev = new Event('backbutton');
59111
document.dispatchEvent(ev);

0 commit comments

Comments
 (0)