chore: sync with main

This commit is contained in:
Liam DeBeasi
2024-02-07 11:48:46 -05:00
167 changed files with 1120 additions and 841 deletions

View File

@@ -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;

View File

@@ -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;

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
type Handler = (processNextHandler: () => void) => Promise<any> | void | null;
@@ -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
@@ -29,9 +49,9 @@ export const blockHardwareBackButton = () => {
export const startHardwareBackButton = () => {
const doc = document;
let busy = false;
doc.addEventListener('backbutton', () => {
const backButtonCallback = () => {
if (busy) {
return;
}
@@ -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;

View File

@@ -22,7 +22,7 @@ export const transitionEndAsync = (el: HTMLElement | null, expectedDuration = 0)
*/
const transitionEnd = (el: HTMLElement | null, expectedDuration = 0, callback: (ev?: TransitionEvent) => void) => {
let unRegTrans: (() => void) | undefined;
let animationTimeout: any;
let animationTimeout: number | undefined;
const opts: AddEventListenerOptions = { passive: true };
const ANIMATION_FALLBACK_TIMEOUT = 500;
@@ -45,7 +45,7 @@ const transitionEnd = (el: HTMLElement | null, expectedDuration = 0, callback: (
animationTimeout = setTimeout(onTransitionEnd, expectedDuration + ANIMATION_FALLBACK_TIMEOUT);
unRegTrans = () => {
if (animationTimeout) {
if (animationTimeout !== undefined) {
clearTimeout(animationTimeout);
animationTimeout = undefined;
}

View File

@@ -48,37 +48,31 @@
<ion-content class="ion-padding">
<ion-item>
<ion-label>Input Above Keyboard</ion-label>
<ion-input id="input-above-keyboard"></ion-input>
<ion-input label="Input Above Keyboard" id="input-above-keyboard"></ion-input>
</ion-item>
<ion-item>
<ion-label>Textarea Above Keyboard</ion-label>
<ion-textarea id="textarea-above-keyboard"></ion-textarea>
<ion-textarea label="Textarea Above Keyboard" id="textarea-above-keyboard"></ion-textarea>
</ion-item>
<div class="spacer" style="height: 400px"></div>
<ion-item id="item-below-keyboard">
<ion-label>Input Below Keyboard</ion-label>
<ion-input id="input-below-keyboard"></ion-input>
<ion-input label="Input Below Keyboard" id="input-below-keyboard"></ion-input>
</ion-item>
<ion-item>
<ion-label>Textarea Below Keyboard</ion-label>
<ion-textarea id="textarea-below-keyboard"></ion-textarea>
<ion-textarea label="Textarea Below Keyboard" id="textarea-below-keyboard"></ion-textarea>
</ion-item>
<div class="spacer" style="height: 400px"></div>
<ion-item>
<ion-label>Input Outside Viewport</ion-label>
<ion-input id="input-outside-viewport"></ion-input>
<ion-input label="Input Outside Viewport" id="input-outside-viewport"></ion-input>
</ion-item>
<ion-item>
<ion-label>Textarea Outside Viewport</ion-label>
<ion-textarea id="textarea-outside-viewport"></ion-textarea>
<ion-textarea label="Textarea Outside Viewport" id="textarea-outside-viewport"></ion-textarea>
</ion-item>
</ion-content>

View File

@@ -43,8 +43,8 @@
<ion-footer>
<ion-toolbar>
<ion-input type="text" placeholder="Enter some text..."></ion-input>
<ion-input type="text" placeholder="Enter some text..."></ion-input>
<ion-input aria-label="input" type="text" placeholder="Enter some text..."></ion-input>
<ion-input aria-label="input" type="text" placeholder="Enter some text..."></ion-input>
</ion-toolbar>
</ion-footer>
</ion-app>

View File

@@ -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';
@@ -359,20 +360,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);
}
}
}
});
});
}
}
};
@@ -589,6 +609,12 @@ export const dismiss = async <OverlayDismissOptions>(
overlay.didDismiss.emit({ data, role });
overlay.didDismissShorthand?.emit({ data, role });
// Get a reference to all animations currently assigned to this overlay
// Then tear them down to return the overlay to its initial visual state
const animations = activeAnimations.get(overlay) || [];
animations.forEach((ani) => ani.destroy());
activeAnimations.delete(overlay);
/**

View File

@@ -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());
@@ -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);

View File

@@ -34,8 +34,7 @@
<ion-content class="ion-padding">
<ion-item>
<ion-label>Text Input</ion-label>
<ion-input id="root-input"></ion-input>
<ion-input label="Text Input" id="root-input"></ion-input>
</ion-item>
<ion-button id="create" onclick="createModal()">Create a Modal</ion-button>
@@ -66,8 +65,7 @@
Modal Content
<ion-item>
<ion-label>Text Input</ion-label>
<ion-input class="modal-input modal-input-${id}"></ion-input>
<ion-input label="Text Input" class="modal-input modal-input-${id}"></ion-input>
</ion-item>
<ion-button id="modal-create">Create a Modal</ion-button>