feat(menu): pass role to ionWillClose and ionDidClose events (#29954)

- Adds the `MenuCloseEventDetail` interface which includes an optional `role` property
- The `ionWillClose` and `ionDidClose` emit the `role` property for the following scenarios:
  - A role of `'gesture'` when dragging the menu closed
- A role of `'backdrop'` when clicking on the backdrop to close the menu
- A role of `'backdrop'` when the the menu is closed using the escape key
- A role of `undefined` when the menu is closed from a button inside of
the menu
This commit is contained in:
Brandy Carney
2024-10-22 10:55:45 -04:00
committed by Tanner Reits
parent 2d6eeee267
commit ee2fa19a1e
10 changed files with 163 additions and 42 deletions

View File

@ -22,7 +22,7 @@ export interface MenuI {
close(animated?: boolean): Promise<boolean>;
toggle(animated?: boolean): Promise<boolean>;
setOpen(shouldOpen: boolean, animated?: boolean): Promise<boolean>;
_setOpen(shouldOpen: boolean, animated?: boolean): Promise<boolean>;
_setOpen(shouldOpen: boolean, animated?: boolean, role?: string): Promise<boolean>;
}
export interface MenuControllerI {
@ -42,7 +42,7 @@ export interface MenuControllerI {
_createAnimation(type: string, menuCmp: MenuI): Promise<Animation>;
_register(menu: MenuI): void;
_unregister(menu: MenuI): void;
_setOpen(menu: MenuI, shouldOpen: boolean, animated: boolean): Promise<boolean>;
_setOpen(menu: MenuI, shouldOpen: boolean, animated: boolean, role?: string): Promise<boolean>;
}
export interface MenuChangeEventDetail {
@ -50,6 +50,10 @@ export interface MenuChangeEventDetail {
open: boolean;
}
export interface MenuCloseEventDetail {
role?: string;
}
export interface MenuCustomEvent<T = any> extends CustomEvent {
detail: T;
target: HTMLIonMenuElement;

View File

@ -7,14 +7,14 @@ import { shouldUseCloseWatcher } 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';
import { getPresentedOverlay } from '@utils/overlays';
import { BACKDROP, GESTURE, getPresentedOverlay } from '@utils/overlays';
import { hostContext } from '@utils/theme';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import type { Animation, Gesture, GestureDetail } from '../../interface';
import type { MenuChangeEventDetail, MenuI, MenuType, Side } from './menu-interface';
import type { MenuChangeEventDetail, MenuCloseEventDetail, MenuI, MenuType, Side } from './menu-interface';
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
@ -179,7 +179,7 @@ export class Menu implements ComponentInterface, MenuI {
/**
* Emitted when the menu is about to be closed.
*/
@Event() ionWillClose!: EventEmitter<void>;
@Event() ionWillClose!: EventEmitter<MenuCloseEventDetail>;
/**
* Emitted when the menu is open.
*/
@ -188,7 +188,7 @@ export class Menu implements ComponentInterface, MenuI {
/**
* Emitted when the menu is closed.
*/
@Event() ionDidClose!: EventEmitter<void>;
@Event() ionDidClose!: EventEmitter<MenuCloseEventDetail>;
/**
* Emitted when the menu state is changed.
@ -331,14 +331,14 @@ export class Menu implements ComponentInterface, MenuI {
if (shouldClose) {
ev.preventDefault();
ev.stopPropagation();
this.close();
this.close(undefined, BACKDROP);
}
}
}
onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
this.close();
this.close(undefined, BACKDROP);
}
}
@ -375,8 +375,8 @@ export class Menu implements ComponentInterface, MenuI {
* it returns `false`.
*/
@Method()
close(animated = true): Promise<boolean> {
return this.setOpen(false, animated);
close(animated = true, role?: string): Promise<boolean> {
return this.setOpen(false, animated, role);
}
/**
@ -393,8 +393,8 @@ export class Menu implements ComponentInterface, MenuI {
* If the operation can't be completed successfully, it returns `false`.
*/
@Method()
setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
return menuController._setOpen(this, shouldOpen, animated);
setOpen(shouldOpen: boolean, animated = true, role?: string): Promise<boolean> {
return menuController._setOpen(this, shouldOpen, animated, role);
}
private trapKeyboardFocus(ev: Event, doc: Document) {
@ -438,13 +438,13 @@ export class Menu implements ComponentInterface, MenuI {
}
}
async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
async _setOpen(shouldOpen: boolean, animated = true, role?: string): Promise<boolean> {
// If the menu is disabled or it is currently being animated, let's do nothing
if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
return false;
}
this.beforeAnimation(shouldOpen);
this.beforeAnimation(shouldOpen, role);
await this.loadAnimation();
await this.startAnimation(shouldOpen, animated);
@ -459,7 +459,7 @@ export class Menu implements ComponentInterface, MenuI {
return false;
}
this.afterAnimation(shouldOpen);
this.afterAnimation(shouldOpen, role);
return true;
}
@ -542,7 +542,7 @@ export class Menu implements ComponentInterface, MenuI {
}
private onWillStart(): Promise<void> {
this.beforeAnimation(!this._isOpen);
this.beforeAnimation(!this._isOpen, GESTURE);
return this.loadAnimation();
}
@ -624,11 +624,11 @@ export class Menu implements ComponentInterface, MenuI {
this.animation
.easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
.onFinish(() => this.afterAnimation(shouldOpen), { oneTimeCallback: true })
.onFinish(() => this.afterAnimation(shouldOpen, GESTURE), { oneTimeCallback: true })
.progressEnd(playTo ? 1 : 0, this._isOpen ? 1 - newStepValue : newStepValue, 300);
}
private beforeAnimation(shouldOpen: boolean) {
private beforeAnimation(shouldOpen: boolean, role?: string) {
assert(!this.isAnimating, '_before() should not be called while animating');
// this places the menu into the correct location before it animates in
@ -671,11 +671,11 @@ export class Menu implements ComponentInterface, MenuI {
if (shouldOpen) {
this.ionWillOpen.emit();
} else {
this.ionWillClose.emit();
this.ionWillClose.emit({ role });
}
}
private afterAnimation(isOpen: boolean) {
private afterAnimation(isOpen: boolean, role?: string) {
// keep opening/closing the menu disabled for a touch more yet
// only add listeners/css if it's enabled and isOpen
// and only remove listeners/css if it's not open
@ -731,7 +731,7 @@ export class Menu implements ComponentInterface, MenuI {
}
// emit close event
this.ionDidClose.emit();
this.ionDidClose.emit({ role });
// undo focus trapping so multiple menus don't collide
document.removeEventListener('focus', this.handleFocus, true);
@ -767,7 +767,7 @@ export class Menu implements ComponentInterface, MenuI {
* If the menu is disabled then we should
* forcibly close the menu even if it is open.
*/
this.afterAnimation(false);
this.afterAnimation(false, GESTURE);
}
}

View File

@ -51,7 +51,9 @@
</ion-header>
<ion-content>
<ion-list>
<ion-button id="start-menu-button">Button</ion-button>
<ion-menu-toggle>
<ion-button id="start-menu-button">Button</ion-button>
</ion-menu-toggle>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
<ion-item>Menu Item</ion-item>
@ -125,6 +127,19 @@
</ion-app>
<script>
window.addEventListener('ionWillOpen', function (e) {
console.log('ionWillOpen', e);
});
window.addEventListener('ionDidOpen', function (e) {
console.log('ionDidOpen', e);
});
window.addEventListener('ionWillClose', function (e) {
console.log('ionWillClose', e);
});
window.addEventListener('ionDidClose', function (e) {
console.log('ionDidClose', e);
});
async function openStart() {
// Open the menu by menu-id
await menuController.enable(true, 'start-menu');

View File

@ -1,7 +1,7 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';
import type { E2EPage, ScreenshotFn } from '@utils/test/playwright';
import { configs, test } from '@utils/test/playwright';
import { configs, dragElementBy, test } from '@utils/test/playwright';
configs().forEach(({ title, config, screenshot }) => {
test.describe(title('menu: rendering'), () => {
@ -140,6 +140,97 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
});
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('menu: events'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/menu/test/basic`, config);
});
test('should pass role when swiping to close', async ({ page }) => {
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionWillClose = await page.spyOnEvent('ionWillClose');
const ionDidClose = await page.spyOnEvent('ionDidClose');
await page.click('#open-start');
await ionDidOpen.next();
const menu = page.locator('#start-menu');
await dragElementBy(menu, page, -150, 0);
await ionWillClose.next();
await ionDidClose.next();
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'gesture' });
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'gesture' });
});
test('should pass role when clicking backdrop to close', async ({ page }) => {
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionWillClose = await page.spyOnEvent('ionWillClose');
const ionDidClose = await page.spyOnEvent('ionDidClose');
await page.click('#open-start');
await ionDidOpen.next();
const menu = page.locator('#start-menu');
const backdrop = menu.locator('ion-backdrop');
/**
* Coordinates for the click event.
* These need to be near the right edge of the backdrop
* in order to avoid clicking on the menu.
*/
const backdropBoundingBox = await backdrop.boundingBox();
const x = backdropBoundingBox!.width - 50;
const y = backdropBoundingBox!.height - 50;
// Click near the right side of the backdrop.
await backdrop.click({
position: { x, y },
});
await ionWillClose.next();
await ionDidClose.next();
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'backdrop' });
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'backdrop' });
});
test('should pass role when pressing escape key to close', async ({ page }) => {
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionWillClose = await page.spyOnEvent('ionWillClose');
const ionDidClose = await page.spyOnEvent('ionDidClose');
await page.click('#open-start');
await ionDidOpen.next();
await page.keyboard.press('Escape');
await ionWillClose.next();
await ionDidClose.next();
await expect(ionWillClose).toHaveReceivedEventDetail({ role: 'backdrop' });
await expect(ionDidClose).toHaveReceivedEventDetail({ role: 'backdrop' });
});
test('should not pass role when clicking a menu toggle button to close', async ({ page }) => {
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionWillClose = await page.spyOnEvent('ionWillClose');
const ionDidClose = await page.spyOnEvent('ionDidClose');
await page.click('#open-start');
await ionDidOpen.next();
await page.click('#start-menu-button');
await ionWillClose.next();
await ionDidClose.next();
await expect(ionWillClose).toHaveReceivedEventDetail({ role: undefined });
await expect(ionDidClose).toHaveReceivedEventDetail({ role: undefined });
});
});
});
async function testMenu(page: E2EPage, menu: Locator, menuId: string, screenshot: ScreenshotFn) {
const ionDidOpen = await page.spyOnEvent('ionDidOpen');
const ionDidClose = await page.spyOnEvent('ionDidClose');

View File

@ -154,6 +154,13 @@
</div>
</ion-app>
<script>
window.addEventListener('ionModalDidDismiss', function (e) {
console.log('DidDismiss', e);
});
window.addEventListener('ionModalWillDismiss', function (e) {
console.log('WillDismiss', e);
});
function createModal(options) {
let items = '';