-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Conversation
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.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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"> |
There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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
@teflonwaffles the feedback should be addressed. As for the |
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)); | ||
}); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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
fixture = TestBed.createComponent(MenuWithConditionalGroup); | ||
|
||
fixture.detectChanges(); | ||
|
||
menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu); | ||
|
||
menuItems = getMenuItems(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
src/cdk-experimental/menu/menu.ts
Outdated
// 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. |
There was a problem hiding this comment.
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
Extract logic from the lifecycle method and place into a more readable method
There was a problem hiding this 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', |
There was a problem hiding this comment.
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
this builds on #19628 which was |
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
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.