Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion core/src/components/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still deciding if we should target Ionic 8 vs. Ionic 7.7. Anyone have strong feelings one way or the other?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No strong feelings since it's an opt-in feature. Maybe 7.7 b/c the sooner it comes out then the sooner the community can provide feedback if needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ Agreed.

import { isPlatform } from '@utils/platform';

import { config } from '../../global/config';
Expand Down Expand Up @@ -34,7 +35,8 @@ 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 {
hardwareBackButtonModule.blockHardwareBackButton();
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this return CloseWatcher gives me the following lint error:

/Users/liamdebeasi/Ionic/ionic/core/src/utils/browser/index.ts
  76:3  error  Interfaces cannot be constructed, only classes  @typescript-eslint/no-misused-new

✖ 1 problem (1 error, 0 warnings)

However, using any still gives us type checking:

[ ERROR ]  TypeScript: src/utils/hardware-back-button.ts:120:16
           Property 'liam' does not exist on type
           'CloseWatcher'.

    L119:   */
    L120:  watcher!.liam = true;
    L121:  watcher!.requestClose();

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`.
thetaPC marked this conversation as resolved.
Show resolved Hide resolved
liamdebeasi marked this conversation as resolved.
Show resolved Hide resolved
*/
experimentalCloseWatcher?: boolean;

// PRIVATE configs
keyboardHeight?: number;
inputShims?: boolean;
Expand Down
51 changes: 48 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,15 @@ 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.
*/
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 +43,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 +95,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
liamdebeasi marked this conversation as resolved.
Show resolved Hide resolved
* 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
Loading