diff --git a/src/cdk/menu/menu-item-checkbox.spec.ts b/src/cdk/menu/menu-item-checkbox.spec.ts index e0a1f249e055..d9a69536bee4 100644 --- a/src/cdk/menu/menu-item-checkbox.spec.ts +++ b/src/cdk/menu/menu-item-checkbox.spec.ts @@ -3,6 +3,21 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; +import {MENU_STACK, MenuStack} from './menu-stack'; +import {ENTER, SPACE} from '../keycodes'; + +/** + * Simulates what the browser does when a native button is activated from the keyboard: a trusted + * keydown that the item lets through, followed by a click with a `detail` of 0. + */ +function pressKeyOnNativeButton(item: CdkMenuItemCheckbox, element: HTMLElement, keyCode: number) { + item._onKeydown({ + keyCode, + isTrusted: true, + preventDefault: () => {}, + } as unknown as KeyboardEvent); + element.dispatchEvent(new MouseEvent('click', {bubbles: true, detail: 0})); +} describe('MenuItemCheckbox', () => { let fixture: ComponentFixture; @@ -72,6 +87,42 @@ describe('MenuItemCheckbox', () => { expect(spy).toHaveBeenCalledTimes(1); }); + describe('keyboard activation of a native button', () => { + let menuStack: MenuStack; + + beforeEach(() => { + menuStack = fixture.debugElement + .query(By.directive(CdkMenuItemCheckbox)) + .injector.get(MENU_STACK); + spyOn(menuStack, 'closeAll'); + }); + + it('should keep the menu open when toggled with space', () => { + pressKeyOnNativeButton(checkbox, checkboxElement, SPACE); + + expect(checkbox.checked).toBeTrue(); + expect(menuStack.closeAll).not.toHaveBeenCalled(); + }); + + it('should close the menu when toggled with enter', () => { + pressKeyOnNativeButton(checkbox, checkboxElement, ENTER); + + expect(checkbox.checked).toBeTrue(); + expect(menuStack.closeAll).toHaveBeenCalled(); + }); + + it('should close the menu on a mouse click after the space key was pressed', () => { + checkbox._onKeydown({ + keyCode: SPACE, + isTrusted: true, + preventDefault: () => {}, + } as unknown as KeyboardEvent); + checkboxElement.dispatchEvent(new MouseEvent('click', {bubbles: true, detail: 1})); + + expect(menuStack.closeAll).toHaveBeenCalled(); + }); + }); + it('should not emit on clicked emitter when disabled', () => { const spy = jasmine.createSpy('cdkMenuItemCheckbox clicked spy'); checkbox.triggered.subscribe(spy); diff --git a/src/cdk/menu/menu-item-radio.spec.ts b/src/cdk/menu/menu-item-radio.spec.ts index fed614abe45a..d4fc2a774051 100644 --- a/src/cdk/menu/menu-item-radio.spec.ts +++ b/src/cdk/menu/menu-item-radio.spec.ts @@ -3,6 +3,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemRadio} from './menu-item-radio'; +import {MENU_STACK} from './menu-stack'; +import {SPACE} from '../keycodes'; describe('MenuItemRadio', () => { let fixture: ComponentFixture; @@ -62,6 +64,24 @@ describe('MenuItemRadio', () => { expect(spy).toHaveBeenCalledTimes(1); }); + it('should keep the menu open when a native button is checked with space', () => { + const menuStack = fixture.debugElement + .query(By.directive(CdkMenuItemRadio)) + .injector.get(MENU_STACK); + spyOn(menuStack, 'closeAll'); + + // The browser turns a trusted space keydown on a button into a click with a `detail` of 0. + radioButton._onKeydown({ + keyCode: SPACE, + isTrusted: true, + preventDefault: () => {}, + } as unknown as KeyboardEvent); + radioElement.dispatchEvent(new MouseEvent('click', {bubbles: true, detail: 0})); + + expect(radioButton.checked).toBeTrue(); + expect(menuStack.closeAll).not.toHaveBeenCalled(); + }); + it('should not emit on clicked emitter when disabled', () => { const spy = jasmine.createSpy('cdkMenuItemRadio clicked spy'); radioButton.triggered.subscribe(spy); diff --git a/src/cdk/menu/menu-item.ts b/src/cdk/menu/menu-item.ts index b4be5f390d51..6e9ff8505360 100644 --- a/src/cdk/menu/menu-item.ts +++ b/src/cdk/menu/menu-item.ts @@ -98,6 +98,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, /** Whether the item should close the menu if triggered by the spacebar. */ protected closeOnSpacebarTrigger = true; + /** + * Whether the click that the browser dispatches for the last keydown should keep the menu open. + * Native buttons turn space and enter into a click, so the keydown handler can't trigger them. + */ + private _keepOpenOnKeyboardClick = false; + /** Emits when the menu item is destroyed. */ protected readonly destroyed = new Subject(); @@ -188,8 +194,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, event.preventDefault(); event.stopPropagation(); } else { - this.trigger(); + // Clicks dispatched by the browser for a keydown have a `detail` of 0. + const keepOpen = this._keepOpenOnKeyboardClick && event.detail === 0; + this.trigger({keepOpen}); } + + this._keepOpenOnKeyboardClick = false; } /** @@ -202,8 +212,16 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, switch (event.keyCode) { case SPACE: case ENTER: - // Skip events that will trigger clicks so the handler doesn't get triggered twice. - if (!hasModifierKey(event) && !eventDispatchesNativeClick(this._elementRef, event)) { + if (!hasModifierKey(event)) { + const keepOpen = event.keyCode === SPACE && !this.closeOnSpacebarTrigger; + + // Skip events that will trigger clicks so the handler doesn't get triggered twice. + // The click handler needs to know whether to keep the menu open though. + if (eventDispatchesNativeClick(this._elementRef, event)) { + this._keepOpenOnKeyboardClick = keepOpen; + break; + } + const nodeName = this._elementRef.nativeElement.nodeName; // Avoid repeat events on non-native elements (see #30250). Note that we don't do this @@ -212,7 +230,7 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, event.preventDefault(); } - this.trigger({keepOpen: event.keyCode === SPACE && !this.closeOnSpacebarTrigger}); + this.trigger({keepOpen}); } break;