Skip to content

Commit 3c37ce9

Browse files
committed
feat(cdk-experimental/menu): add ability to close menus when clicking outside the menu tree
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 ba441d4 commit 3c37ce9

File tree

5 files changed

+169
-0
lines changed

5 files changed

+169
-0
lines changed

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

Lines changed: 128 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,108 @@ 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+
const openMenu = () => {
749+
triggers[0].toggle();
750+
detectChanges();
751+
};
752+
753+
/** set the menus and triggers arrays. */
754+
const 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+
const 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[0].nativeElement.click();
793+
menuGroups[1].nativeElement.click();
794+
menuGroups[2].nativeElement.click();
795+
detectChanges();
796+
797+
expect(menus.length).toBe(1);
798+
});
799+
800+
it('should not close open menus when clicking on a menu', () => {
801+
openMenu();
802+
expect(menus.length).toBe(1);
803+
804+
fixture.debugElement.query(By.directive(CdkMenu)).nativeElement.click();
805+
detectChanges();
806+
807+
expect(menus.length).toBe(1);
808+
});
809+
810+
it('should not close open menus when clicking on a menu bar', () => {
811+
openMenu();
812+
expect(menus.length).toBe(1);
813+
814+
fixture.debugElement.query(By.directive(CdkMenuBar)).nativeElement.click();
815+
detectChanges();
816+
817+
expect(menus.length).toBe(1);
818+
});
819+
820+
it('should not close when clicking on a CdkMenuItemCheckbox element', () => {
821+
openMenu();
822+
expect(menus.length).toBe(1);
823+
824+
fixture.debugElement.query(By.directive(CdkMenuItemCheckbox)).nativeElement.click();
825+
fixture.detectChanges();
826+
827+
expect(menus.length).toBe(1);
828+
});
829+
830+
it('should not close when clicking on a non-menu element inside menu', () => {
831+
openMenu();
832+
expect(menus.length).toBe(1);
833+
834+
fixture.debugElement.query(By.css('#inner-element')).nativeElement.click();
835+
detectChanges();
836+
837+
expect(menus.length)
838+
.withContext('menu should stay open if clicking on an inner span element')
839+
.toBe(1);
840+
});
841+
});
738842
});
739843

740844
@Component({
@@ -847,3 +951,27 @@ class MenuWithRadioButtons {
847951

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

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ 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 isCdkMenuComponent(target: Element) {
34+
const classList = target.classList;
35+
return (
36+
classList.contains('cdk-menu-bar') ||
37+
classList.contains('cdk-menu') ||
38+
classList.contains('cdk-menu-item') ||
39+
classList.contains('cdk-menu-group')
40+
);
41+
}
42+
2843
/**
2944
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
3045
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
@@ -36,8 +51,10 @@ import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
3651
exportAs: 'cdkMenuBar',
3752
host: {
3853
'(keydown)': '_handleKeyEvent($event)',
54+
'(document:click)': '_closeOnBackgroundClick($event)',
3955
'(focus)': 'focusFirstItem()',
4056
'role': 'menubar',
57+
'class': 'cdk-menu-bar',
4158
'tabindex': '0',
4259
'[attr.aria-orientation]': 'orientation',
4360
},
@@ -212,6 +229,22 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
212229
return this.orientation === 'horizontal';
213230
}
214231

232+
/** Close any open submenu if there was a click event which occurred outside the menu stack. */
233+
_closeOnBackgroundClick(event: MouseEvent) {
234+
if (this._hasOpenSubmenu()) {
235+
// get target from composed path to account for shadow dom
236+
let target = event.composedPath ? event.composedPath()[0] : event.target;
237+
while (target instanceof Element) {
238+
if (isCdkMenuComponent(target)) {
239+
return;
240+
}
241+
target = target.parentElement;
242+
}
243+
244+
this._openItem?.getMenuTrigger()?.toggle();
245+
}
246+
}
247+
215248
/**
216249
* Subscribe to the menu trigger's open events in order to track the trigger which opened the menu
217250
* and stop tracking it when the menu is closed.
@@ -236,6 +269,11 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
236269
.subscribe(() => (this._openItem = undefined));
237270
}
238271

272+
/** Return true if the MenuBar has an open submenu. */
273+
private _hasOpenSubmenu() {
274+
return !!this._openItem;
275+
}
276+
239277
ngOnDestroy() {
240278
super.ngOnDestroy();
241279
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)