Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion core/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Wa
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap';
import { GESTURE_CONTROLLER } from '@utils/gesture';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import { shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { printIonError } from '@utils/logging';
Expand Down Expand Up @@ -726,6 +726,7 @@ export class Menu implements ComponentInterface, MenuI {
// emit opened/closed events
this._isOpen = isOpen;
this.isAnimating = false;
updateCloseWatcher();
if (!this._isOpen) {
this.blocker.unblock();
}
Expand Down
71 changes: 71 additions & 0 deletions core/src/components/menu/test/close-watcher/menu.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('menu: close watcher'), () => {
test('should close the menu on a close request and release the back button', async ({ page, skip }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29648',
});
skip.browser((browserName: string) => browserName !== 'chromium', 'Only Chromium supports the CloseWatcher API');

await page.setContent(
`
<script>
window.Ionic.config.experimentalCloseWatcher = true;

// Count live watchers so the test can check the back button gets released
window.liveCloseWatchers = 0;
const NativeCloseWatcher = window.CloseWatcher;
window.CloseWatcher = class extends NativeCloseWatcher {
constructor(...args) {
super(...args);
window.liveCloseWatchers++;
this.addEventListener('close', () => this.release());
}
destroy() {
this.release();
super.destroy();
}
// A closed watcher can still be destroyed, so only count it once
release() {
if (!this.released) {
this.released = true;
window.liveCloseWatchers--;
}
}
};
</script>
<ion-app>
<ion-menu content-id="main">
<ion-content>Menu Content</ion-content>
</ion-menu>
<div class="ion-page" id="main">
<ion-content>Main Content</ion-content>
</div>
</ion-app>
`,
config
);

const menu = page.locator('ion-menu');
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionDidClose = await page.spyOnEvent('ionDidClose');

await menu.evaluate((el: HTMLIonMenuElement) => el.open());
await ionDidOpen.next();
expect(await page.evaluate(() => (window as any).liveCloseWatchers)).toBe(1);

// Escape sends a close request to the active CloseWatcher
await page.keyboard.press('Escape');

await ionDidClose.next();
await expect(menu).not.toHaveClass(/show-menu/);
expect(await page.evaluate(() => (window as any).liveCloseWatchers)).toBe(0);
});
});
});
5 changes: 4 additions & 1 deletion core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ export interface IonicConfig {
/**
* @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.
* Escape key and Android back button presses that dismiss menus and overlays. It's only active
* while a menu or an overlay other than a toast is open, so back navigation works as usual
* otherwise. Hybrid apps still handle the hardware back button through the native `backbutton`
* event.
* Note that the `hardwareBackButton` config option must also be `true`.
*/
experimentalCloseWatcher?: boolean;
Expand Down
59 changes: 48 additions & 11 deletions core/src/utils/hardware-back-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ export const blockHardwareBackButton = () => {
document.addEventListener('backbutton', () => {});
};

const closeWatcherConditions: (() => boolean)[] = [];
let syncCloseWatcher: (() => void) | undefined;

/**
* Registers a check for whether the back button
* has something to close. The CloseWatcher is only
* active while one of these checks passes.
*/
export const addCloseWatcherCondition = (condition: () => boolean) => {
closeWatcherConditions.push(condition);
};

/**
* Call this whenever something the back button
* can close opens or closes.
*/
export const updateCloseWatcher = () => {
syncCloseWatcher?.();
};

export const startHardwareBackButton = () => {
const doc = document;
let busy = false;
Expand Down Expand Up @@ -105,34 +125,51 @@ export const startHardwareBackButton = () => {
};

/**
* 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.
* Android WebView never sends the hardware back
* button to a CloseWatcher, so hybrid apps still
* need this. Browsers never fire backbutton.
*/
doc.addEventListener('backbutton', backButtonCallback);

if (shouldUseCloseWatcher()) {
let watcher: CloseWatcher | undefined;

const configureWatcher = () => {
watcher?.destroy();
/**
* An active CloseWatcher stops the back button from
* navigating, so only keep one while there's something to close.
*/
syncCloseWatcher = () => {
const canClose = closeWatcherConditions.some((condition) => condition());

if (!canClose) {
watcher?.destroy();
watcher = undefined;
return;
}

if (watcher !== undefined) {
return;
}

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.
* close requests, including after one that
* closed nothing, like a modal whose canDismiss
* returned false.
*/
watcher!.onclose = () => {
watcher = undefined;
backButtonCallback();
configureWatcher();
syncCloseWatcher?.();
};
};

configureWatcher();
} else {
doc.addEventListener('backbutton', backButtonCallback);
syncCloseWatcher();
}
};

Expand Down
3 changes: 2 additions & 1 deletion core/src/utils/menu-controller/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { doc } from '@utils/browser';
import type { BackButtonEvent } from '@utils/hardware-back-button';
import { MENU_BACK_BUTTON_PRIORITY } from '@utils/hardware-back-button';
import { MENU_BACK_BUTTON_PRIORITY, addCloseWatcherCondition } from '@utils/hardware-back-button';
import { printIonWarning } from '@utils/logging';

import type { MenuI, MenuControllerI } from '../../components/menu/menu-interface';
Expand Down Expand Up @@ -229,6 +229,7 @@ const createMenuController = (): MenuControllerI => {
registerAnimation('push', menuPushAnimation);
registerAnimation('overlay', menuOverlayAnimation);

addCloseWatcherCondition(() => _getOpenSync() !== undefined);
doc?.addEventListener('ionBackButton', (ev: BackButtonEvent) => {
const openMenu = _getOpenSync();
if (openMenu) {
Expand Down
40 changes: 36 additions & 4 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { doc } from '@utils/browser';
import { focusFirstDescendant, focusLastDescendant, focusableQueryString } from '@utils/focus-trap';
import type { BackButtonEvent } from '@utils/hardware-back-button';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import { addCloseWatcherCondition, shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button';
import { printIonError, printIonWarning } from '@utils/logging';

import { config } from '../global/config';
Expand Down Expand Up @@ -35,6 +35,12 @@ import {
let lastOverlayIndex = 0;
let lastId = 0;

/**
* Every overlay except toast, which doesn't trap
* focus or close on back.
*/
const NON_TOAST_OVERLAYS = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover';

export const activeAnimations = new WeakMap<OverlayInterface, Animation[]>();

type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
Expand Down Expand Up @@ -186,7 +192,7 @@ const focusElementInOverlay = (hostToFocus: HTMLElement | null | undefined, over
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover');
const lastOverlay = getPresentedOverlay(doc, NON_TOAST_OVERLAYS);
const target = ev.target as HTMLElement | null;

/**
Expand Down Expand Up @@ -383,6 +389,12 @@ const connectListeners = (doc: Document) => {
true
);

/**
* Whether the overlay actually dismisses is checked at press time,
* since `backdropDismiss` can change while it's open.
*/
addCloseWatcherCondition(() => getPresentedOverlay(doc, NON_TOAST_OVERLAYS) !== undefined);

// handle back-button click
doc.addEventListener('ionBackButton', (ev) => {
const lastOverlay = getPresentedOverlay(doc);
Expand Down Expand Up @@ -525,7 +537,8 @@ export const setRootAriaHidden = (hidden = false) => {
* Cleans up root `aria-hidden` and `backdrop-no-scroll` when
* an overlay is removed from the DOM without going through
* the `dismiss()` flow (e.g., when a framework unmounts the
* overlay during a route change).
* overlay during a route change). Also releases the
* CloseWatcher if nothing is left to close.
*
* Should be called from an overlay's `disconnectedCallback`
* when the overlay was still presented at the time of removal.
Expand All @@ -535,6 +548,8 @@ export const cleanupRootFocusTrapAccessibility = () => {
return;
}

updateCloseWatcher();

const remainingOverlays = getPresentedOverlays(document);
const hasRemainingLocking = remainingOverlays.some((o) => locksAppRoot(o as OverlayWithFocusTrapProps));

Expand All @@ -559,7 +574,9 @@ const applyRootLock = (el: OverlayWithFocusTrapProps) => {

/**
* Re-applies the root lock that `cleanupRootFocusTrapAccessibility()` released.
* Call from `connectedCallback` when the overlay is still presented.
* Call from `connectedCallback` when the overlay is still presented. Also
* re-arms the CloseWatcher, which every overlay needs, so that happens
* before the `locksAppRoot` check.
*
* A synchronous move keeps the overlay connected, so the lock survives. A
* detach with a re-insert in a later task releases it, which is the shape a
Expand All @@ -570,6 +587,8 @@ export const restoreRootFocusTrapAccessibility = (overlayEl: HTMLIonOverlayEleme
return;
}

updateCloseWatcher();

const el = overlayEl as OverlayWithFocusTrapProps;
if (!locksAppRoot(el)) {
return;
Expand Down Expand Up @@ -828,6 +847,12 @@ export const dismiss = async <OverlayDismissOptions>(

overlay.el.remove();

/**
* Release the back button if nothing
* else is left to close.
*/
updateCloseWatcher();

return true;
};

Expand All @@ -844,6 +869,13 @@ const overlayAnimation = async (
// Make overlay visible in case it's hidden
baseEl.classList.remove('overlay-hidden');

/**
* Removing `overlay-hidden` makes the overlay count
* as presented, so back can dismiss it while it
* animates in.
*/
updateCloseWatcher();

const aniRoot = overlay.el;
const animation = animationBuilder(aniRoot, opts);

Expand Down
Loading
Loading