Skip to content

Commit 2980b07

Browse files
authored
feat(cdk-experimental/menu): add ability to close menus when clicking outside the menu tree (#20064)
Add functionality to close any open menus (and submenus) when a user clicks on an element outside the open menu tree and the menu bar components.
1 parent 89b5fa8 commit 2980b07

File tree

5 files changed

+161
-0
lines changed

5 files changed

+161
-0
lines changed

src/cdk-experimental/menu/menu-bar.spec.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {CdkMenuItemRadio} from './menu-item-radio';
3434
import {CdkMenu} from './menu';
3535
import {CdkMenuItem} from './menu-item';
3636
import {CdkMenuItemCheckbox} from './menu-item-checkbox';
37+
import {CdkMenuItemTrigger} from './menu-item-trigger';
38+
import {CdkMenuGroup} from './menu-group';
3739

3840
describe('MenuBar', () => {
3941
describe('as radio group', () => {
@@ -735,6 +737,106 @@ describe('MenuBar', () => {
735737
);
736738
});
737739
});
740+
741+
describe('background click closeout', () => {
742+
let fixture: ComponentFixture<MenuBarWithMenus>;
743+
744+
let menus: CdkMenu[];
745+
let triggers: CdkMenuItemTrigger[];
746+
747+
/** open the attached menu. */
748+
function openMenu() {
749+
triggers[0].toggle();
750+
detectChanges();
751+
}
752+
753+
/** set the menus and triggers arrays. */
754+
function grabElementsForTesting() {
755+
menus = fixture.componentInstance.menus.toArray();
756+
triggers = fixture.componentInstance.triggers.toArray();
757+
}
758+
759+
/** run change detection and, extract and set the rendered elements. */
760+
function detectChanges() {
761+
fixture.detectChanges();
762+
grabElementsForTesting();
763+
}
764+
765+
beforeEach(async(() => {
766+
TestBed.configureTestingModule({
767+
imports: [CdkMenuModule],
768+
declarations: [MenuBarWithMenus],
769+
}).compileComponents();
770+
}));
771+
772+
beforeEach(() => {
773+
fixture = TestBed.createComponent(MenuBarWithMenus);
774+
detectChanges();
775+
});
776+
777+
it('should close out all open menus when clicked outside the menu tree', () => {
778+
openMenu();
779+
expect(menus.length).toBe(1);
780+
781+
fixture.debugElement.query(By.css('#container')).nativeElement.click();
782+
detectChanges();
783+
784+
expect(menus.length).toBe(0);
785+
});
786+
787+
it('should not close open menus when clicking on a menu group', () => {
788+
openMenu();
789+
expect(menus.length).toBe(1);
790+
791+
const menuGroups = fixture.debugElement.queryAll(By.directive(CdkMenuGroup));
792+
menuGroups[2].nativeElement.click();
793+
detectChanges();
794+
795+
expect(menus.length).toBe(1);
796+
});
797+
798+
it('should not close open menus when clicking on a menu', () => {
799+
openMenu();
800+
expect(menus.length).toBe(1);
801+
802+
fixture.debugElement.query(By.directive(CdkMenu)).nativeElement.click();
803+
detectChanges();
804+
805+
expect(menus.length).toBe(1);
806+
});
807+
808+
it('should not close open menus when clicking on a menu bar', () => {
809+
openMenu();
810+
expect(menus.length).toBe(1);
811+
812+
fixture.debugElement.query(By.directive(CdkMenuBar)).nativeElement.click();
813+
detectChanges();
814+
815+
expect(menus.length).toBe(1);
816+
});
817+
818+
it('should not close when clicking on a CdkMenuItemCheckbox element', () => {
819+
openMenu();
820+
expect(menus.length).toBe(1);
821+
822+
fixture.debugElement.query(By.directive(CdkMenuItemCheckbox)).nativeElement.click();
823+
fixture.detectChanges();
824+
825+
expect(menus.length).toBe(1);
826+
});
827+
828+
it('should not close when clicking on a non-menu element inside menu', () => {
829+
openMenu();
830+
expect(menus.length).toBe(1);
831+
832+
fixture.debugElement.query(By.css('#inner-element')).nativeElement.click();
833+
detectChanges();
834+
835+
expect(menus.length)
836+
.withContext('menu should stay open if clicking on an inner span element')
837+
.toBe(1);
838+
});
839+
});
738840
});
739841

740842
@Component({
@@ -847,3 +949,27 @@ class MenuWithRadioButtons {
847949

848950
@ViewChildren(CdkMenuItemRadio) radioItems: QueryList<CdkMenuItemRadio>;
849951
}
952+
953+
@Component({
954+
template: `
955+
<div id="container">
956+
<div cdkMenuBar>
957+
<button cdkMenuItem [cdkMenuTriggerFor]="sub1">Trigger</button>
958+
</div>
959+
960+
<ng-template cdkMenuPanel #sub1="cdkMenuPanel">
961+
<div cdkMenu [cdkMenuPanel]="sub1">
962+
<div cdkMenuGroup>
963+
<button cdkMenuItemCheckbox>Trigger</button>
964+
<span id="inner-element">A nested non-menuitem element</span>
965+
</div>
966+
</div>
967+
</ng-template>
968+
</div>
969+
`,
970+
})
971+
class MenuBarWithMenus {
972+
@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
973+
974+
@ViewChildren(CdkMenuItemTrigger) triggers: QueryList<CdkMenuItemTrigger>;
975+
}

src/cdk-experimental/menu/menu-bar.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ import {CDK_MENU, Menu} from './menu-interface';
2525
import {CdkMenuItem} from './menu-item';
2626
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
2727

28+
/**
29+
* Check if the given element is part of the cdk menu module.
30+
* @param target the element to check.
31+
* @return true if the given element is part of the menu module.
32+
*/
33+
function isMenuElement(target: Element) {
34+
return target.className.indexOf('cdk-menu') !== -1;
35+
}
36+
2837
/**
2938
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
3039
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
@@ -36,8 +45,10 @@ import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
3645
exportAs: 'cdkMenuBar',
3746
host: {
3847
'(keydown)': '_handleKeyEvent($event)',
48+
'(document:click)': '_closeOnBackgroundClick($event)',
3949
'(focus)': 'focusFirstItem()',
4050
'role': 'menubar',
51+
'class': 'cdk-menu-bar',
4152
'tabindex': '0',
4253
'[attr.aria-orientation]': 'orientation',
4354
},
@@ -212,6 +223,22 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
212223
return this.orientation === 'horizontal';
213224
}
214225

226+
/** Close any open submenu if there was a click event which occurred outside the menu stack. */
227+
_closeOnBackgroundClick(event: MouseEvent) {
228+
if (this._hasOpenSubmenu()) {
229+
// get target from composed path to account for shadow dom
230+
let target = event.composedPath ? event.composedPath()[0] : event.target;
231+
while (target instanceof Element) {
232+
if (isMenuElement(target)) {
233+
return;
234+
}
235+
target = target.parentElement;
236+
}
237+
238+
this._openItem?.getMenuTrigger()?.toggle();
239+
}
240+
}
241+
215242
/**
216243
* Subscribe to the menu trigger's open events in order to track the trigger which opened the menu
217244
* and stop tracking it when the menu is closed.
@@ -236,6 +263,11 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
236263
.subscribe(() => (this._openItem = undefined));
237264
}
238265

266+
/** Return true if the MenuBar has an open submenu. */
267+
private _hasOpenSubmenu() {
268+
return !!this._openItem;
269+
}
270+
239271
ngOnDestroy() {
240272
super.ngOnDestroy();
241273
this._destroyed.next();

src/cdk-experimental/menu/menu-group.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {CdkMenuItem} from './menu-item';
2929
exportAs: 'cdkMenuGroup',
3030
host: {
3131
'role': 'group',
32+
'class': 'cdk-menu-group',
3233
},
3334
providers: [{provide: UniqueSelectionDispatcher, useClass: UniqueSelectionDispatcher}],
3435
})

src/cdk-experimental/menu/menu-item.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function removeIcons(element: Element) {
4545
'tabindex': '-1',
4646
'type': 'button',
4747
'role': 'menuitem',
48+
'class': 'cdk-menu-item',
4849
'[attr.aria-disabled]': 'disabled || null',
4950
},
5051
})

src/cdk-experimental/menu/menu.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
5151
host: {
5252
'(keydown)': '_handleKeyEvent($event)',
5353
'role': 'menu',
54+
'class': 'cdk-menu',
5455
'[attr.aria-orientation]': 'orientation',
5556
},
5657
providers: [

0 commit comments

Comments
 (0)