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

Conversation

andy9775
Copy link
Contributor

Currently CdkMenuItem is designed to handle the logic for the radio and
checkox role along with performing basic user actions and opening an
attached Menu. Separating the logic into separate directives for each
role makes the code easier to reason about and more testable and
reliable. It also aids in implementing future functionality.

Currently CdkMenuItem is designed to handle the logic for the radio and
checkox role along with performing basic user actions and opening an
attached Menu. Separating the logic into separate directives for each
role makes the code easier to reason about and more testable and
reliable. It also aids in implementing future functionality.
@andy9775 andy9775 requested a review from jelbourn as a code owner June 18, 2020 14:11
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jun 18, 2020
@andy9775 andy9775 requested a review from teflonwaffles June 18, 2020 14:11
andy9775 added 2 commits June 18, 2020 11:06
MenuItemTriggers selector specifies that it must be placed alongside a
cdkMenuItem directive hence placing alongside a cdkMenuItenCheckbox (or
any other selectable) should be a compile time failure.
Element attributes in the host bindings are inherited by subclasses with
Ivy but not View Engine. Specifying explicitly on each subclass fixes
this issue.
.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.

private readonly _selectableItems: QueryList<CdkMenuItemSelectable>;

/** Emitter used to unsubscribe from selectable item click events */
private readonly _nextChanges: EventEmitter<void> = new EventEmitter();
Copy link

@teflonwaffles teflonwaffles Jun 18, 2020

Choose a reason for hiding this comment

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

I'd give this a clearer name. Maybe '_unsubscribeCurrentMenuItemListeners'?

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

it('should toggle state when called with own id and differing id', () => {

Choose a reason for hiding this comment

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

'and not with differing id'?

}
}

static ngAcceptInputType_checked: BooleanInput;

Choose a reason for hiding this comment

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

I've never seen a static class member used in TypeScript before. What is this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

when you do something like <div disabled> this is the equivalent of disabled = "" and therefore we need to coerce the truthy value. However we want checked to specify that it is a boolean type. This ensures that we can provide a truthy type but allows us to specify it as a boolean type.

Copy link
Member

Choose a reason for hiding this comment

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

Yep- it let's Angular know that, when binding a value from a template, the property accepts a broader range of values than its setter specifies.

}

/** Return true if the trigger has an attached menu */
hasSubmenu() {

Choose a reason for hiding this comment

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

Should this be private? (It's odd for 'hasSubmenu' to be a field in some places and a method in others.)


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

expect(spy).toHaveBeenCalledTimes(0);

Choose a reason for hiding this comment

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

Optional: .not.toHaveBeenCalled() may be mildly more readable.

menuItems = getMenuItems();
}));

it('should not emit after the group mounts', () => {

Choose a reason for hiding this comment

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

This terminology is unclear. Instead of 'mounts', could you use terms more specific to the directives? (Also, I'm not sure which statement corresponds to the 'mounting' in the test code.)

<ng-template [ngIf]="!show">
<li><button cdkMenuItemCheckbox>first</button></li>
</ng-template>
<ng-template [ngIf]="show">

Choose a reason for hiding this comment

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

Ah, now I see -- 'show = true' is what mounts the inner group. However, does the test depend on 'first' being hidden when show is true? If not, I'd remove the condition from 'first'.

I'd use a clearer name for 'show' so the test code is clearer -- maybe 'showInnerGroup' or 'renderInnerGroup'?

Also, is there a reason you're using the more verbose [ngIf] syntax? (If there is, I'd add a comment explaining the reason, unless it's part of CDK convention.)

// If there are nested MenuGroup elements within this group
// we complete the change emitter in order to ensure that
// the this menu does not emit change events.
if (this._hasNestedGroups()) {

Choose a reason for hiding this comment

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

Is the if-then necessary here? I would expect the behavior to be the same even without it, assuming _nestedGroups.changes emits immediately if there are nested groups from the beginning. (If that assumption isn't correct, then it does become necessary. I might add a comment explaining this, if that's the case.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here I'm saying, if we have groups from the start lets close the emitter. Otherwise we don't have any on initial mount, but if some do show up, lets disable the emitter once they do.

Choose a reason for hiding this comment

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

Right, so could this work without the if statement, by simply relying on the subscription?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's unfortunately no way to subscribe to the QueryList in such a way where your callback will get executed when the initial children are mounted. At the time you call subscribe the QueryList will have elements if they are already visible in the Component. Hence any logic needs to perform some logic on the existing children (if any) and subscribe to any future changes.

For example, if we have a single CdkMenuGroup with no conditional mounting the QueryList will contain that one component when ngAfterContentInit executes but the changes emitter won't emit until another is added, or the current one is removed. That means that if there is only ever one CdkMenuGroup, the changes emitter won't execute at all.

andy9775 added 5 commits June 18, 2020 14:56
In ViewEngine when selecting for ContentChildren, it will include the
current component if it matches the query (including subclasses).
This ensures that a check is performed to prevent this bug from causing
the change emitter from being completed when only a CdkMenu exists with
no real nested groups.
Change event emitter emits when the list of Selectables gets updated
@andy9775
Copy link
Contributor Author

@teflonwaffles the feedback should be addressed. As for the forEach, I've seen it throughout the repo but I'll let Jeremy respond as well.

@andy9775 andy9775 requested a review from teflonwaffles June 18, 2020 21:36
compileComponents() is an async function and therefore createComponents
cannot follow it. Breaking up into seperate beforeEach blocks prevents
any potential issues
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

.query(By.directive(CdkMenuItemCheckbox))
.injector.get(CdkMenuItemCheckbox);

nativeButton = fixture.debugElement.query(By.directive(CdkMenuItemCheckbox)).nativeElement;
Copy link
Member

Choose a reason for hiding this comment

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

I typically would name these something like checkbox and checkboxElement

'(click)': 'trigger()',
'type': 'button',
'role': 'menuitemcheckbox',
'[attr.aria-checked]': 'checked',
Copy link
Member

Choose a reason for hiding this comment

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

Based on screen-reader testing, we may want aria-checked to also be checked || null

Copy link
Contributor Author

@andy9775 andy9775 Jun 19, 2020

Choose a reason for hiding this comment

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

Since it's a checkbox wouldn't it be explicitly checked or not?
Edit: Wouldn't we want to specify false instead of null for unchecked state?

Copy link
Member

Choose a reason for hiding this comment

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

We've heard some feedback that making elements null when they would be false makes the screen-reader less verbose, so rather then being e.g.

Cheese selected
Tomato not selected
Onion not selected
Pepper selected

it would instead read

Cheese selected
Tomato
Onion
Pepper selected

So that "not selected" is implicit

}
}

static ngAcceptInputType_checked: BooleanInput;
Copy link
Member

Choose a reason for hiding this comment

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

Yep- it let's Angular know that, when binding a value from a template, the property accepts a broader range of values than its setter specifies.

/** Counter used to set a unique name for a selectable item */
let _uniqueNameCounter = 0;
/** Counter used to set a unique id for a selectable item */
let _uniqueIdCounter = 0;
Copy link
Member

Choose a reason for hiding this comment

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

You could use the same counter for each of these. Also they don't need to start with _ since they're not exported. I typically just call it nextId

const spy = jasmine.createSpy('changeSpy for root menu');
menu.change.subscribe(spy);

menuItems.forEach(menuItem => menuItem.trigger());
Copy link
Member

Choose a reason for hiding this comment

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

Can use for of here instead of .forEach

Comment on lines 120 to 126
fixture = TestBed.createComponent(MenuWithConditionalGroup);

fixture.detectChanges();

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

menuItems = getMenuItems();
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
fixture = TestBed.createComponent(MenuWithConditionalGroup);
fixture.detectChanges();
menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu);
menuItems = getMenuItems();
fixture = TestBed.createComponent(MenuWithConditionalGroup);
fixture.detectChanges();
menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu);
menuItems = getMenuItems();

Maybe a bit overboard with the empty lines here

// view engine has a bug where @ContentChildren will return the current element
// along with children if the selectors match - not just the children.
// Here we check to see if the first element is a CdkMenu in order to ensure
// that we return true iff there are child CdkMenuGroup elements.
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 avoid putting comments in the conditional expression, instead putting the comment above the return

@andy9775 andy9775 requested a review from jelbourn June 19, 2020 00:42
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

'(click)': 'trigger()',
'type': 'button',
'role': 'menuitemcheckbox',
'[attr.aria-checked]': 'checked',
Copy link
Member

Choose a reason for hiding this comment

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

We've heard some feedback that making elements null when they would be false makes the screen-reader less verbose, so rather then being e.g.

Cheese selected
Tomato not selected
Onion not selected
Pepper selected

it would instead read

Cheese selected
Tomato
Onion
Pepper selected

So that "not selected" is implicit

@jelbourn jelbourn added lgtm action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release labels Jun 19, 2020
@mmalerba
Copy link
Contributor

this builds on #19628 which was target: minor, switching this one to target: minor as well

@mmalerba mmalerba 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 19, 2020
@mmalerba mmalerba merged commit 298cdc0 into angular:master Jun 19, 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 21, 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