Skip to content

refactor(cdk-experimental/menu): Break MenuItems into directives #19688

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eef0518
refactor(cdk-experimental/menu): Break MenuItems into directives
andy9775 Jun 18, 2020
20a9915
test(cdk-experimental/menu): Remove unnecessary tests
andy9775 Jun 18, 2020
e929ad8
fix(cdk-experimental/menu): Fix attribute inheritance
andy9775 Jun 18, 2020
2918e80
fix(cdk-experimental/menu): Fix ViewEngine tests
andy9775 Jun 18, 2020
96a5e9e
test(cdk-experimental): Clear up tests
andy9775 Jun 18, 2020
0d7f807
refactor(cdk-experimental/menu): Make internal method private
andy9775 Jun 18, 2020
988412d
test(cdk-experimental/menu): clear up test description
andy9775 Jun 18, 2020
a5303e8
refactor(cdk-experimental/menu): Rename change event emitter
andy9775 Jun 18, 2020
cc2cfd8
test(cdk-experimental/menu): Refactor test configuration
andy9775 Jun 18, 2020
400e55c
refactor(cdk-experimental/menu): Rename registration method
andy9775 Jun 19, 2020
80b9bcd
refactor(cdk-experimental/menu): rename native elements for clarity
andy9775 Jun 19, 2020
f9825f5
refactor(cdk-experimental/menu): use the same counter for name and id
andy9775 Jun 19, 2020
33513a3
style(cdk-experimental/menu): clean up whitespace
andy9775 Jun 19, 2020
96bf59d
test(cdk-experimental/menu): refactor to use for..of where prefered
andy9775 Jun 19, 2020
042e321
style(cdk-experimental): move comment outside of conditional block
andy9775 Jun 19, 2020
299db25
refactor(cdk-experimental/menu): move logic out of lifecycle methods
andy9775 Jun 19, 2020
97db1ae
refactor(cdk-experimental/menu): simplify checkbox toggle logic
andy9775 Jun 19, 2020
26f90b6
docs(cdk-experimental/menu): add clearer documentation for checkbox and
andy9775 Jun 19, 2020
919dc03
refactor(cdk-experimental/menu): refactor checked attribute to be null
andy9775 Jun 19, 2020
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
2 changes: 2 additions & 0 deletions src/cdk-experimental/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ng_module(
module_name = "@angular/cdk-experimental/menu",
deps = [
"//src/cdk/coercion",
"//src/cdk/collections",
"@npm//@angular/core",
"@npm//rxjs",
],
Expand All @@ -24,6 +25,7 @@ ng_test_library(
),
deps = [
":menu",
"//src/cdk/collections",
"@npm//@angular/platform-browser",
],
)
Expand Down
20 changes: 10 additions & 10 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
import {Component} from '@angular/core';
import {By} from '@angular/platform-browser';
import {CdkMenuBar} from './menu-bar';
import {CdkMenuModule} from './menu-module';
import {CdkMenuItem} from './menu-item';
import {By} from '@angular/platform-browser';
import {CdkMenuItemRadio} from './menu-item-radio';

describe('MenuBar', () => {
describe('as radio group', () => {
let fixture: ComponentFixture<MenuBarRadioGroup>;
let menuItems: CdkMenuItem[];
let menuItems: CdkMenuItemRadio[];

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -20,8 +20,8 @@ describe('MenuBar', () => {
fixture.detectChanges();

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map(element => element.injector.get(CdkMenuItem));
.queryAll(By.directive(CdkMenuItemRadio))
.map(element => element.injector.get(CdkMenuItemRadio));
}));

it('should toggle menuitemradio items', () => {
Expand All @@ -37,7 +37,7 @@ describe('MenuBar', () => {

describe('radiogroup change events', () => {
let fixture: ComponentFixture<MenuBarRadioGroup>;
let menuItems: CdkMenuItem[];
let menuItems: CdkMenuItemRadio[];

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -50,8 +50,8 @@ describe('MenuBar', () => {
fixture.detectChanges();

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map(element => element.injector.get(CdkMenuItem));
.queryAll(By.directive(CdkMenuItemRadio))
.map(element => element.injector.get(CdkMenuItemRadio));
}));

it('should emit on click', () => {
Expand All @@ -73,12 +73,12 @@ describe('MenuBar', () => {
template: `
<ul cdkMenuBar>
<li role="none">
<button checked="true" role="menuitemradio" cdkMenuItem>
<button checked="true" cdkMenuItemRadio>
first
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
second
</button>
</li>
Expand Down
2 changes: 1 addition & 1 deletion src/cdk-experimental/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {CdkMenuGroup} from './menu-group';

/**
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
* role, aria attributes, and accessable keyboard and mouse handling logic. The component that
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
* this directive is applied to should contain components marked with CdkMenuItem.
*
*/
Expand Down
72 changes: 23 additions & 49 deletions src/cdk-experimental/menu/menu-group.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import {Component} from '@angular/core';
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {CdkMenuModule} from './menu-module';
import {CdkMenuGroup} from './menu-group';
import {CdkMenuItem} from './menu-item';
import {CdkMenu} from './menu';
import {By} from '@angular/platform-browser';
import {CdkMenuItemCheckbox} from './menu-item-checkbox';
import {CdkMenuItemRadio} from './menu-item-radio';

describe('MenuGroup', () => {
describe('with MenuItems as checkbox', () => {
let fixture: ComponentFixture<CheckboxMenu>;
let menuItems: CdkMenuItem[];
let menuItems: CdkMenuItemCheckbox[];

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -21,8 +21,8 @@ describe('MenuGroup', () => {
fixture.detectChanges();

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map(e => e.injector.get(CdkMenuItem));
.queryAll(By.directive(CdkMenuItemCheckbox))
.map(e => e.injector.get(CdkMenuItemCheckbox));
}));

it('should not change state of sibling checked menuitemcheckbox', () => {
Expand All @@ -34,7 +34,7 @@ describe('MenuGroup', () => {

describe('with MenuItems as radio button', () => {
let fixture: ComponentFixture<MenuWithMultipleRadioGroups>;
let menuItems: CdkMenuItem[];
let menuItems: CdkMenuItemRadio[];

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -46,8 +46,8 @@ describe('MenuGroup', () => {
fixture.detectChanges();

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map(e => e.injector.get(CdkMenuItem));
.queryAll(By.directive(CdkMenuItemRadio))
.map(e => e.injector.get(CdkMenuItemRadio));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and elsewhere, the test code may be a little clearer if you replace 'e' with a more descriptive name (such as debugElement or menuItemRadioDebugElement). If you're following the CDK convention, however, you should probably continue doing so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This convention is totally acceptable (even encouraged) in this repo. I find that, for simple map/find/filter functions, one-character names make the operation easier to read.

}));

it('should change state of sibling menuitemradio in same group', () => {
Expand Down Expand Up @@ -76,8 +76,7 @@ describe('MenuGroup', () => {

describe('change events', () => {
let fixture: ComponentFixture<MenuWithMenuItemsAndRadioGroups>;
let menu: CdkMenu;
let menuItems: CdkMenuItem[];
let menuItems: CdkMenuItemRadio[];

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -88,22 +87,11 @@ describe('MenuGroup', () => {
fixture = TestBed.createComponent(MenuWithMenuItemsAndRadioGroups);
fixture.detectChanges();

menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu);

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map(element => element.injector.get(CdkMenuItem));
.queryAll(By.directive(CdkMenuItemRadio))
.map(element => element.injector.get(CdkMenuItemRadio));
}));

it('should not emit from root menu with nested groups', () => {
const spy = jasmine.createSpy('changeSpy for root menu');
menu.change.subscribe(spy);

menuItems.forEach(menuItem => menuItem.trigger());

expect(spy).toHaveBeenCalledTimes(0);
});

it('should emit from enclosing radio group only', () => {
const spies: jasmine.Spy[] = [];

Expand All @@ -117,8 +105,8 @@ describe('MenuGroup', () => {

expect(spies[1]).toHaveBeenCalledTimes(1);
expect(spies[1]).toHaveBeenCalledWith(menuItems[0]);
expect(spies[2]).toHaveBeenCalledTimes(0);
expect(spies[3]).toHaveBeenCalledTimes(0);
expect(spies[2]).not.toHaveBeenCalled();
expect(spies[3]).not.toHaveBeenCalled();
});

it('should not emit with click on disabled button', () => {
Expand All @@ -132,21 +120,7 @@ describe('MenuGroup', () => {

menuItems[0].trigger();

expect(spy).toHaveBeenCalledTimes(0);
});

it('should not emit on menuitem click', () => {
const spies: jasmine.Spy[] = [];

fixture.debugElement.queryAll(By.directive(CdkMenuGroup)).forEach((group, index) => {
const spy = jasmine.createSpy(`cdkMenuGroup ${index} change spy`);
spies.push(spy);
group.injector.get(CdkMenuGroup).change.subscribe(spy);
});

menuItems[2].trigger();

spies.forEach(spy => expect(spy).toHaveBeenCalledTimes(0));
expect(spy).not.toHaveBeenCalled();
});
});
});
Expand All @@ -157,12 +131,12 @@ describe('MenuGroup', () => {
<li role="none">
<ul cdkMenuGroup>
<li #first role="none">
<button checked="true" role="menuitemcheckbox" cdkMenuItem>
<button checked="true" cdkMenuItemCheckbox>
one
</button>
</li>
<li role="none">
<button role="menuitemcheckbox" cdkMenuItem>
<button cdkMenuItemCheckbox>
two
</button>
</li>
Expand All @@ -179,12 +153,12 @@ class CheckboxMenu {}
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button checked="true" role="menuitemradio" cdkMenuItem>
<button checked="true" cdkMenuItemRadio>
one
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
two
</button>
</li>
Expand All @@ -193,12 +167,12 @@ class CheckboxMenu {}
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
three
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
four
</button>
</li>
Expand All @@ -215,7 +189,7 @@ class MenuWithMultipleRadioGroups {}
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
one
</button>
</li>
Expand All @@ -224,7 +198,7 @@ class MenuWithMultipleRadioGroups {}
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
<button cdkMenuItemRadio>
two
</button>
</li>
Expand Down
58 changes: 48 additions & 10 deletions src/cdk-experimental/menu/menu-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, Output, EventEmitter} from '@angular/core';
import {MenuItem} from './menu-item-interface';
import {
Directive,
Output,
EventEmitter,
ContentChildren,
AfterContentInit,
QueryList,
OnDestroy,
} from '@angular/core';
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
import {takeUntil} from 'rxjs/operators';
import {CdkMenuItemSelectable} from './menu-item-selectable';
import {CdkMenuItem} from './menu-item';

/**
* Directive which acts as a grouping container for `CdkMenuItem` instances with
Expand All @@ -19,18 +30,45 @@ import {MenuItem} from './menu-item-interface';
host: {
'role': 'group',
},
providers: [{provide: UniqueSelectionDispatcher, useClass: UniqueSelectionDispatcher}],
})
export class CdkMenuGroup {
export class CdkMenuGroup implements AfterContentInit, OnDestroy {
/** Emits the element when checkbox or radiobutton state changed */
@Output() change: EventEmitter<MenuItem> = new EventEmitter();
@Output() readonly change: EventEmitter<CdkMenuItem> = new EventEmitter();

/** List of menuitemcheckbox or menuitemradio elements which reside in this group */
@ContentChildren(CdkMenuItemSelectable, {descendants: true})
private readonly _selectableItems: QueryList<CdkMenuItemSelectable>;

/** Emits when the _selectableItems QueryList triggers a change */
private readonly _selectableChanges: EventEmitter<void> = new EventEmitter();

ngAfterContentInit() {
this._registerMenuSelectionListeners();
}

/**
* Emits events for the clicked MenuItem
* @param menuItem The clicked MenuItem to handle
* Register the child selectable elements with the change emitter and ensure any new child
* elements do so as well.
*/
_registerTriggeredItem(menuItem: MenuItem) {
if (menuItem.role !== 'menuitem') {
this.change.emit(menuItem);
}
private _registerMenuSelectionListeners() {
this._selectableItems.forEach(selectable => this._registerClickListener(selectable));

this._selectableItems.changes.subscribe((selectableItems: QueryList<CdkMenuItemSelectable>) => {
this._selectableChanges.next();
selectableItems.forEach(selectable => this._registerClickListener(selectable));
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you move the logic inside ngAfterContentInit into a separate method? Something like

ngAfterContentInit() {
  this._registerMenuSelectionListeners();
}

This related to my philosophy on naming methods for what they do rather than how/when they're called. This also helps things that you would call from lifecycle hooks more single-responsibility

}

/** Register each selectable to emit on the change Emitter when clicked */
private _registerClickListener(selectable: CdkMenuItemSelectable) {
selectable.clicked
.pipe(takeUntil(this._selectableChanges))
.subscribe(() => this.change.next(selectable));
}

ngOnDestroy() {
this._selectableChanges.next();
this._selectableChanges.complete();
}
}
Loading