mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-09 16:16:41 +08:00
feat: add experimental hardware back button support in browsers (#28705)
resolves #28703
This commit is contained in:
@ -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') {
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user