Skip to content

feat(cdk-experimental/menu): Implement grouping logic for menuitems #19628

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 15 commits into from
Jun 17, 2020
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
19 changes: 18 additions & 1 deletion src/cdk-experimental/menu/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "ng_module")
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

Expand All @@ -15,3 +15,20 @@ ng_module(
"@npm//rxjs",
],
)

ng_test_library(
name = "unit_test_sources",
srcs = glob(
["**/*.spec.ts"],
exclude = ["**/*.e2e.spec.ts"],
),
deps = [
":menu",
"@npm//@angular/platform-browser",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
88 changes: 88 additions & 0 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {ComponentFixture, TestBed, async} from '@angular/core/testing';

Choose a reason for hiding this comment

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

I just realized you test a lot of the same functionality in the tests for menu-group. Since menu-bar extends menu-group, I don't think you need to repeat the same tests here -- this suite can focus on tests specific to menu-bar's extra functionality.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The menu and menu-bar tests are more of a sanity check to ensure that it's wired up correctly The meat of the logic is in the menu-group which contains most of the tests.

Choose a reason for hiding this comment

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

In that case, just one or two sanity-check tests should be enough -- I don't think you need to go over checkboxes and radio buttons separately. (That said, I initially thought there were more tests in this file, for some reason. Now that I look again, there doesn't seem to be as much overlap. I'm still not used to reviewing on Github...)

import {Component} from '@angular/core';
import {CdkMenuBar} from './menu-bar';
import {CdkMenuModule} from './menu-module';
import {CdkMenuItem} from './menu-item';
import {By} from '@angular/platform-browser';

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

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarRadioGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarRadioGroup);
fixture.detectChanges();

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

it('should toggle menuitemradio items', () => {
expect(menuItems[0].checked).toBeTrue();
expect(menuItems[1].checked).toBeFalse();

menuItems[1].trigger();

expect(menuItems[0].checked).toBeFalse();
expect(menuItems[1].checked).toBeTrue();
});
});

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

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarRadioGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarRadioGroup);

fixture.detectChanges();

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

it('should emit on click', () => {
const spy = jasmine.createSpy('cdkMenu change spy');
fixture.debugElement
.query(By.directive(CdkMenuBar))
.injector.get(CdkMenuBar)
.change.subscribe(spy);

menuItems[0].trigger();

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(menuItems[0]);
});
});
});

@Component({
template: `
<ul cdkMenuBar>
<li role="none">
<button checked="true" role="menuitemradio" cdkMenuItem>
first
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
second
</button>
</li>
</ul>
`,
})
class MenuBarRadioGroup {}
11 changes: 9 additions & 2 deletions src/cdk-experimental/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, Input} from '@angular/core';
import {Directive, Input, ContentChildren, QueryList} from '@angular/core';
import {CdkMenuGroup} from './menu-group';
import {CdkMenuItem} from './menu-item';

/**
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
Expand All @@ -21,11 +23,16 @@ import {Directive, Input} from '@angular/core';
'role': 'menubar',
'[attr.aria-orientation]': 'orientation',
},
providers: [{provide: CdkMenuGroup, useExisting: CdkMenuBar}],
})
export class CdkMenuBar {
export class CdkMenuBar extends CdkMenuGroup {
/**
* Sets the aria-orientation attribute and determines where sub-menus will be opened.
* Does not affect styling/layout.
*/
@Input('cdkMenuBarOrientation') orientation: 'horizontal' | 'vertical' = 'horizontal';

/** All the child MenuItem components which this directive wraps including descendants */

Choose a reason for hiding this comment

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

Are you only using this to access items in the unit test? If so, it may be better to leave it out and find another way to grab the menu items for the test (using selectors, for instance). Although it's good to design things for testability, it's not a good idea to have test-only APIs. In general, they encourage testing in ways that don't match the real-world use of the components. (At the very least, you also want to reduce the size of the Angular payload by not including test-only code.)

If you do plan to use this within this component itself, then never mind (although I still wouldn't use it in tests, since it's been marked private with an underscore).

Copy link
Contributor Author

@andy9775 andy9775 Jun 12, 2020

Choose a reason for hiding this comment

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

It'll be used in future functionality. I added it at this point since I can also use it in the test. But ya, I could have also added the ContentChildren into the test fixture and gone about it that way.

As for the package private - the Angular recommendation is _underscore is used internally - kind of like package private in java. @jelbourn thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

We do use the underscore prefix as "library-internal". For testing, though, we we typically grab content child items directly (via debugElement.query) in the tests rather than going through the container component's ContentChildren property since it is more of an implementation detail.

As a rule of thumb, I would only add stuff to the code as it's needed for some behavior/feature.

@ContentChildren(CdkMenuItem, {descendants: true})
readonly _allItems: QueryList<CdkMenuItem>;
}
218 changes: 218 additions & 0 deletions src/cdk-experimental/menu/menu-group.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {Component} from '@angular/core';
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
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';

describe('MenuGroup', () => {
describe('MenuItem', () => {
let fixture: ComponentFixture<MenuGroups>;
let menuItems: Array<CdkMenuItem>;
Copy link
Member

Choose a reason for hiding this comment

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

Prefer the more concise array type syntax, CdkMenuItem[] except in cases where the type is especially complex (here and elsewhere)


beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuGroups],
}).compileComponents();

fixture = TestBed.createComponent(MenuGroups);
fixture.detectChanges();

menuItems = fixture.debugElement
.queryAll(By.directive(CdkMenuItem))
.map((element) => element.injector.get(CdkMenuItem));
Copy link
Member

Choose a reason for hiding this comment

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

You can omit the parentheses around element here.

Also, FYI, for simple map/filter/etc expressions like this, feel free to use single-character param names, e.g.

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

(though writing out element is also totally fine)

}));

it('should not change state of sibling menuitemcheckbox', () => {
menuItems[1].trigger();

expect(menuItems[0].checked).toBeTrue(); // default state true
Copy link
Member

Choose a reason for hiding this comment

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

FYI you can add descriptions for assertions with jasmine like

expect(menuItems[0].checked).toBeTrue('Expected the first menu item to remain checked');

This makes it so that, when the test fails, you get a more descriptive failure message than "Expected false to be true". This is optional, but personally I like to add it for any boolean-based assertions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

toBeTrue does not take any arguments.

});

it('should change state of sibling menuitemradio in same group', () => {
menuItems[3].trigger();

expect(menuItems[3].checked).toBeTrue();
expect(menuItems[2].checked).toBeFalse();
});

it('should not change state of menuitemradio in sibling group', () => {
menuItems[4].trigger();

expect(menuItems[4].checked).toBeTrue();
expect(menuItems[2].checked).toBeTrue();
});

it('should not change radiogroup state with disabled button', () => {
menuItems[3].disabled = true;

menuItems[3].trigger();

expect(menuItems[2].checked).toBeTrue();
expect(menuItems[3].checked).toBeFalse();
});
});

describe('change events', () => {
let fixture: ComponentFixture<MenuGroups>;
let menu: CdkMenu;
let menuItems: Array<CdkMenuItem>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuGroups],
}).compileComponents();

fixture = TestBed.createComponent(MenuGroups);
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));
}));

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 groups with clicked menu items', () => {
const spies: Array<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();
menuItems[4].trigger();

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

it('should not emit with click on disabled button', () => {
const spy = jasmine.createSpy('cdkMenuGroup change spy');

fixture.debugElement
.queryAll(By.directive(CdkMenuGroup))[3]
.injector.get(CdkMenuGroup)
.change.subscribe(spy);
menuItems[4].disabled = true;

menuItems[4].trigger();

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

it('should not emit from sibling groups', () => {
const spies: Array<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[0].trigger();

expect(spies[1]).toHaveBeenCalledTimes(1);
expect(spies[2]).toHaveBeenCalledTimes(0);
expect(spies[3]).toHaveBeenCalledTimes(0);
});

it('should not emit on menuitem click', () => {
const spies: Array<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[7].trigger();

spies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(0));
});
});
});

@Component({
template: `
<ul cdkMenu>
<!-- Checkbox group -->
<li role="none">
<ul cdkMenuGroup>
<li #first role="none">
<button checked="true" role="menuitemcheckbox" cdkMenuItem>
one
</button>
</li>
<li role="none">
<button role="menuitemcheckbox" cdkMenuItem>
two
</button>
</li>
</ul>
</li>
<!-- Radio group -->
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button checked="true" role="menuitemradio" cdkMenuItem>
three
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
four
</button>
</li>
</ul>
</li>
<!-- Radio group -->
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
five
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
six
</button>
</li>
</ul>
</li>
<li role="none">
<ul cdkMenuGroup>
<li role="none">
<button cdkMenuItem>
seven
</button>
</li>
<li role="none">
<button cdkMenuItem>
eight
</button>
</li>
</ul>
</li>
</ul>
`,
})
class MenuGroups {}

Choose a reason for hiding this comment

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

I think the smaller, focused test components from the menu-bar test suite would be better. (That way, it's easier to see the purpose of each test component, without the need for explanatory HTML comments.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ya so the menu/menu-bar tests I made small however here I also wanted to test interaction with groups next to each other hence why it's larger than normal.

Choose a reason for hiding this comment

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

Oh, you were testing radio group independence? That's a good thing to test.

I still might consider splitting the checkbox group into a separate group, though, unless you need it in a test with radio groups.

Copy link
Contributor Author

@andy9775 andy9775 Jun 12, 2020

Choose a reason for hiding this comment

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

Exactly. When I was refactoring the circular reference issue it ended up catching a potential bug so it was useful already.

Copy link
Member

Choose a reason for hiding this comment

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

I would name this component something like MenuWithMultipleSelectionGroups. Ideally you would only use this component for tests that deal specifically with interactions related to having multiple selection-toggle controls. For more basic functionality (like change events), we could create a smaller test component

14 changes: 12 additions & 2 deletions src/cdk-experimental/menu/menu-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {Directive, Output, EventEmitter} from '@angular/core';
import {CdkMenuItem} from './menu-item';
import {MenuItem} from './menu-item-interface';

/**
* Directive which acts as a grouping container for `CdkMenuItem` instances with
Expand All @@ -22,5 +22,15 @@ import {CdkMenuItem} from './menu-item';
})
export class CdkMenuGroup {
/** Emits the element when checkbox or radiobutton state changed */
@Output() change: EventEmitter<CdkMenuItem> = new EventEmitter();
@Output() change: EventEmitter<MenuItem> = new EventEmitter();
Copy link
Member

Choose a reason for hiding this comment

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

Might be a TODO for a follow-up PR, but we typically want to create an event interface for change rather than emitting the related type (ideally ending with Event, e.g. MenuItemChangeEvent or MenuItemSelectionChangeEvent)


/**
* Emits events for the clicked MenuItem
* @param menuItem The clicked MenuItem to handle
*/
_registerTriggeredItem(menuItem: MenuItem) {
Copy link
Member

Choose a reason for hiding this comment

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

Would a different verb other than "register" be more descriptive here? Maybe something like _emitSelectionChangeEvent

if (menuItem.role !== 'menuitem') {
this.change.emit(menuItem);
}
}
}
Loading