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

Conversation

andy9775
Copy link
Contributor

The MenuBar and Menu pattern allows for MenuItems which have the
menuitemcheckbox or menuitemradio roles. This implements the grouping
logic for, menuitemradio which ensures that sibling items within a
CdkMenu or CdkMenGroup are logically connected and only one can be
checked. It also implements the logic for menuitemcheckbox. Further it
implements the logic for emitting checked events for elements marked
menuitemcheckbox and menuitemradio from their enclosing groups.


Note that I opted not to allow the disabled property on CdkMenu and CdkMenuGroup since this follows more in line with mat-menu, and how I interpreted the pattern. However if we decide to implement this functionality it can be easily added with a followup pr.

The MenuBar and Menu pattern allows for MenuItems which have the
menuitemcheckbox or menuitemradio roles. This implements the grouping
logic for, menuitemradio which ensures that sibling items within a
CdkMenu or CdkMenGroup are logically connected and only one can be
checked. It also implements the logic for menuitemcheckbox. Further it
implements the logic for emitting checked events for elements marked
menuitemcheckbox and menuitemradio from their enclosing groups.
@andy9775 andy9775 requested a review from jelbourn as a code owner June 12, 2020 18:52
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jun 12, 2020
@andy9775 andy9775 requested a review from teflonwaffles June 12, 2020 18:53
expect(menuItems[0].checked).toBeTrue();
expect(menuItems[1].checked).toBeFalse();

menuItems[1].trigger();

Choose a reason for hiding this comment

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

I'm not familiar with the public Angular testing conventions. Would this be the appropriate point to bring in test harnesses? (Would they apply for CDK directives, as opposed to actual components?)

Usually, it would be better to simulate real clicks using harness actions or mouse events, instead of directly calling methods on the directive class. But I don't know if that applies here, or if it's possible at this point in development.

Copy link
Member

Choose a reason for hiding this comment

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

We actually don't use harnesses for the component's unit test; we create separate tests for the harnesses once we make them

@Output() change: EventEmitter<CdkMenuItem>;
/** All the child MenuItem components which this directive wraps including descendants */
@ContentChildren(CdkMenuItem, {descendants: true})
readonly _allItems: QueryList<CdkMenuItem>;

Choose a reason for hiding this comment

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

Typically, we'd mark the field as '_allItems!: QueryList<...>', and assert the field is not undefined in ngAfterContentInit(). But I don't know if that's the convention in the CDK.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, we don't use that convention yet because we haven't enabled --strictPropertyInitialization w/ TypeScript. We will likely do this someday, but don't have the assertion system set up presently.

menuItems = menuBar._allItems.toArray();
}));

it('should toggle menuitemcheckbox', () => {

Choose a reason for hiding this comment

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

If you remove words like 'should' from your test descriptions, you can make them more concise. (We can assume that all tests describe the intended behavior for the components under tests.)

Also, this description could be more specific. You're testing that menuitemcheckboxes are toggled in isolation, so if the description mentions this, you can avoid the comment further down.

Copy link
Member

Choose a reason for hiding this comment

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

This is a convention for Angular projects; we want test description to read like a sentence ("it should...")

/**
* 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.

@@ -0,0 +1,175 @@
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...)

</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

/** Configure event subscriptions */
ngAfterContentInit() {
if (this.role !== 'menuitem') {
this._menuGroup.change.subscribe((button: CdkMenuItem) => this._toggleCheckedState(button));

Choose a reason for hiding this comment

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

Typically, we'd make sure to unsubscribe any subscriptions in ngOnDestroy, using a ReplaySubject and takeUntil (https://ultimatecourses.com/blog/exploring-angular-lifecycle-hooks-ondestroy). I don't know if there's a different convention for this in the CDK, though.

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.

My approach here is that the emitter comes from the parent and the child wouldn't get destroyed independent of the parent hence it should auto-unsubscribe when the parent gets destroyed. I've seen it in a few places in the cdk with that assumption IIRC.

Choose a reason for hiding this comment

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

That should be fine, although it's worth considering the lazy-loaded scenario, where menu items are generated on demand. If previous menu items are destroyed (such as with a long, scrollable menu list), their subscriptions will have to be unsubscribed manually.

That said, that probably won't be in place for a while, so it should be okay to follow the convention for now.

Copy link
Member

Choose a reason for hiding this comment

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

It's actually pretty common to use ngIf or ngFor with menu items, which will destroy the items independently from the parent. You can just do the right thing here by using takeUntil(this._destroyed) with the subscription (you can see a bunch of examples of this throughout the library)

private _toggleCheckedState(selected: CdkMenuItem) {
if (this.role === 'menuitemradio') {
this.checked = selected === this;
} else if (this.role === 'menuitemcheckbox' && selected === this) {

Choose a reason for hiding this comment

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

I wonder if it would be a good idea to save 'menuitemradio' and 'menuitemcheckbox' as constants somewhere, so any typos become evident at compile time (instead of runtime).

Copy link
Member

Choose a reason for hiding this comment

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

These would actually be enforced by the type system already because they're string literal types

menuItems = menu._allItems.toArray();
}));

it('should toggle menuitemradio items', () => {

Choose a reason for hiding this comment

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

Again, I don't know if you need to replicate the menu-group tests here.

andy9775 added 5 commits June 12, 2020 17:04
Not all MenuItems will open a submenu so this updates the reference to
being optional.
Use MenuItem to help fix the circular dependency between CdkMenuItem and
CdkMenuGroup. The interface is also the return type the change event
handler and therefore only specifies relevant methods/properties
if (this.hasSubmenu()) {
// TODO(andy): open the menu
} else if (this.role !== 'menuitem') {
this._menuGroup.change.next(this);

Choose a reason for hiding this comment

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

This is quite a clever mechanism -- I like it. (I wouldn't have thought of emitting menu item instances through an event emitter that way.)

That said, it feels a little awkward to reach into the CdkMenuGroup and call a method directly on one of its members (even if it's a public member). I might instead have a 'registerTriggeredMenuItem()' method on the menu group class -- calling it with a menu item causes the item to be emitted by (change).

Then again, since this is all package-private interaction, it may be okay this way. I'll leave it to your judgment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good Idea. I took it a step further and moved the logic into CdkMenuGroup - I think it makes more sense for it to determine when to emit a change event than having the MenuItem doing so.

/**
* 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 */
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.

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)


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.

</ul>
`,
})
class MenuGroups {}
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

/** Configure event subscriptions */
ngAfterContentInit() {
if (this.role !== 'menuitem') {
this._menuGroup.change.subscribe((button: CdkMenuItem) => this._toggleCheckedState(button));
Copy link
Member

Choose a reason for hiding this comment

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

It's actually pretty common to use ngIf or ngFor with menu items, which will destroy the items independently from the parent. You can just do the right thing here by using takeUntil(this._destroyed) with the subscription (you can see a bunch of examples of this throughout the library)

private _toggleCheckedState(selected: CdkMenuItem) {
if (this.role === 'menuitemradio') {
this.checked = selected === this;
} else if (this.role === 'menuitemcheckbox' && selected === this) {
Copy link
Member

Choose a reason for hiding this comment

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

These would actually be enforced by the type system already because they're string literal types


/** Configure event subscriptions */
ngAfterContentInit() {
if (this.role !== 'menuitem') {
Copy link
Member

Choose a reason for hiding this comment

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

Rather than using conditionals to check for the type of the menu item throughout this PR, what do you think of having separate subclasses for menuitemradio and menuitemcheckbox? I have a hunch it will make the code simpler. They might even share a base class (or interface?) like MenuItemWithSelection.

Copy link
Contributor Author

@andy9775 andy9775 Jun 15, 2020

Choose a reason for hiding this comment

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

Something like, CdkMenuItem, CdkMenuItemTrigger, CdkMenuItemRadio, CdkMenuItemCheckbox with CdkMenuItem being the base class and having a MenuItem interface?

Edit agreed offline to address this in a follow up PR

Copy link
Member

Choose a reason for hiding this comment

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

I was imagining one of these options:
menu-item

The regular menu item isn't necessarily a trigger for another menu, it can just be a regular action, so the trigger would be a separate directive that's applied to a menu item. For the checkbox and radio items, they probably share some behavior, but I'm not sure how much. If it's a lot, I would add a base class for that shared code. If it's not a lot of code, then they would just extend CdkMenuItem directly.

@Output() change: EventEmitter<CdkMenuItem>;
/** All the child MenuItem components which this directive wraps including descendants */
@ContentChildren(CdkMenuItem, {descendants: true})
readonly _allItems: QueryList<CdkMenuItem>;
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, we don't use that convention yet because we haven't enabled --strictPropertyInitialization w/ TypeScript. We will likely do this someday, but don't have the assertion system set up presently.

/** Emits when the attached submenu is opened */
@Output() opened: EventEmitter<void> = new EventEmitter();

constructor(
/** reference a parent CdkMenuGroup component */
private readonly _menuGroup: CdkMenuGroup
Copy link
Member

Choose a reason for hiding this comment

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

I know we discussed this at some point, but we can do it more here- where did we land on making it so that the menu/items only know about each other in one direction? E.g. the menu know about its items OR the items know about their menu, but not both.

Copy link
Contributor Author

@andy9775 andy9775 Jun 15, 2020

Choose a reason for hiding this comment

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

We decided to use an interface which I've started implementing for the next feature in order to prevent circular dependencies
Edit: Decided to go one way - most likely the parent knows about children

@andy9775 andy9775 requested a review from a team as a code owner June 16, 2020 15:10
Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

LGTM, can explore different classes for the different types of items in a follow-up PR

@jelbourn jelbourn added lgtm action: merge The PR is ready for merge by the caretaker labels Jun 16, 2020
@jelbourn jelbourn added merge safe target: patch This PR is targeted for the next patch release labels Jun 16, 2020
@andy9775 andy9775 added target: minor This PR is targeted for the next minor release and removed target: patch This PR is targeted for the next patch release labels Jun 17, 2020
@mmalerba mmalerba merged commit d8c2d63 into angular:master Jun 17, 2020
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Jul 18, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
action: merge The PR is ready for merge by the caretaker cla: yes PR author has agreed to Google's Contributor License Agreement target: minor This PR is targeted for the next minor release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants