-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(cdk-experimental/menu): implement menu handling logic #19701
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
CdkMenu elements should be wrapped with ng-template marked with a CdkMenuPanel directive
Implement logic to open/close attached menus and submenus when triggering the specified MenuItem to which the menu is attached to
let triggers: CdkMenuItemTrigger[]; | ||
let nativeTriggers: HTMLButtonElement[]; | ||
|
||
const setElements = () => { |
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 method name seems ambiguous. Could you use something more specific, like 'grabElementsForTesting'?
detectChanges(); | ||
|
||
expect(menus.length).toEqual(1); | ||
expect(triggers[0]._menuPanel!._menu!).toEqual(menus[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.
I'm unclear how this tests the condition stated in the test title. Could you confirm that this assertion is necessary?
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 check that there is only one opened menu and that the opened menu is the one which is attached to the 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.
But since the 'menus' array is populated dynamically, is that enough to verify that the correct menu is opened? (If _menuPanel on the trigger is known to refer to the correct menu, before the menu is opened, then that would do it.)
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.
ya, so the _menuPanel menu reference doesn't change since it's set in the template.
Looking at it now they should be flipped though - that's probably where the confusion is.
triggers[1].toggle(); | ||
detectChanges(); | ||
|
||
expect(menus.length).withContext('first level menu should stay open').toEqual(1); |
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.
If you really want to get picky, it might be nice to test that the nested submenu is the one that gets closed (as opposed to the outer menu getting closed, and the nested submenu dangling from the outer trigger). I'm not sure how you'd do that, though. (Also, since unit tests can assume some knowledge of implementation details, it's probably okay to skip this if it's infeasible based on the implementation.)
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 can verify that the remaining menu is the one which is attached to the first trigger. This will ensure that the first submenu is left open.
|
||
it('should fallback to positioning the overlay above the trigger for horizontal Menu', () => { | ||
nativeTriggers[0].style.position = 'fixed'; | ||
nativeTriggers[0].style.bottom = '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.
Does this put the trigger at the bottom of the page? (I've been under the impression 0 is the top of the page, but I'll admit my CSS isn't very good.)
I'd make the test description clearer by making the condition explicit. ('should put overlay above trigger for horizontal Menu if no room below', or something like that.)
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 the position is set to fixed, setting the bottom to 0 sets it 0px away from the bottom edge. Similar logic applies for right
below.
Reference https://developer.mozilla.org/en-US/docs/Web/CSS/bottom and https://developer.mozilla.org/en-US/docs/Web/CSS/right
I'll clear up the comment though
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.
Wow, I had no idea that was a thing. CSS is a mystery...
|
||
it('should fallback to positioning nested submenu overlay to the left in ltr layout', () => { | ||
nativeTriggers[0].style.position = 'fixed'; | ||
nativeTriggers[0].style.right = '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.
Again, I thought 0 was the left.
|
||
/** Return the portal to be attached to the overlay which contains the menu */ | ||
private _getPortal() { | ||
if (!this._portal || this._portal.templateRef !== this._menuPanel?._templateReference) { |
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 assume the right hand side of this OR statement would happen if the trigger's submenu programmatically changes (via an *ngIf, for instance)? If so, it may be a good idea to make this explicit in the method comment.
|
||
constructor(readonly _templateReference: TemplateRef<unknown>) {} | ||
|
||
/** Set the menu component on the menu panel */ |
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 make this method description a little more elaborate? Right now, it doesn't actually convey why this method is important.
export const CDK_MENU = new InjectionToken<Menu>('cdk-menu'); | ||
|
||
/** Interface which specifies Menu operations and used to break circular dependency issues */ | ||
export interface Menu { |
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 should make sure that this symbol isn't exported through the public API when we get that far
@Output() readonly opened: EventEmitter<void> = new EventEmitter(); | ||
|
||
/** Emits when the attached submenu is requested to close */ | ||
@Output() readonly closed: 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 think these outputs should have a prefixes, like cdkMenuOpened
/ cdkMenuClosed
.
If we were really technical, the names would be e.g. cdkMenuTriggerOpened
, but I don't think this actually makes sense.
@Output() readonly closed: EventEmitter<void> = new EventEmitter(); | ||
|
||
/** A reference to the overlay which manages the triggered submenu */ | ||
private _overlayReference: OverlayRef | null = 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.
Just _overlayRef
is okay since the concept of a "ref" is widespread throughout Angular
@@ -24,29 +42,121 @@ import {CdkMenuItem} from './menu-item'; | |||
exportAs: 'cdkMenuTriggerFor', | |||
host: { | |||
'aria-haspopup': 'menu', | |||
'[attr.aria-expanded]': 'isSubmenuOpen() || 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.
So, opposite of other attributes, I think we do want aria-expanded
to always be present because it cues the user that there's something here to be expanded (otherwise it seems like a regular button)
} | ||
} | ||
|
||
/** Return the configuration object used to create the overlay */ |
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.
nit: Prefer "Gets ..." to "Returns ..."
(here and elsehwere)
{originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top'}, | ||
{originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom'}, | ||
{originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top'}, | ||
{originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom'}, |
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.
It would be good to add a TODO
here to one common "source of truth" for the conventional menu/dropdown positioning strategy, potentially in cdk/overlay
In ViewEngine, if a directive is placed on an `ng-template` a child cannot reference it via DI. Therefore when running under VE a reference to the parent CdkMenuPanel must be explicitly passed to the child CdkMenu. Under Ivy it defaults to the injected value.
*Ref is a common naming scheme throughout angular therefore there is no need to specify *Reference
Should be able to use a common configuration strategy as defined in cdk/overlay (ideally) since most components will prefer a common set of layouts
@jelbourn @teflonwaffles comments should be addressed. PTAL |
It should assert that the dynamically opened menu is expected to be the one attached to the trigger
@@ -48,10 +56,42 @@ export class CdkMenu extends CdkMenuGroup implements AfterContentInit { | |||
@ContentChildren(CdkMenuGroup, {descendants: true}) | |||
private readonly _nestedGroups: QueryList<CdkMenuGroup>; | |||
|
|||
/** | |||
* A reference to the enclosing parent menu panel. |
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.
Just to clarify, what's the difference between cdkMenu and cdkMenuPanel?
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.
cdkMenu is a directive attached directly to the menu content. cdkMenuPanel is attached to the ng-template.
cdkMenuPanel provides a reference to the template used to display the content (cdkMenu) in an overlay. It also provides a reference to the child menu.
.toEqual(Math.floor(nativeMenus[1].getBoundingClientRect().left)); | ||
}); | ||
|
||
it('should position nested submenu at trigger level by default', () => { |
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 there a fallback test for this?
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.
No. My thinking is if the left/right fallback works testing left/right height would start testing the implementation of the Overlay class.
if (parent) { | ||
parent._registerMenu(this); | ||
} else { | ||
throwMissingMenuPanelError(); |
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 wouldn't strictly be an error- using a menu outside of a panel should be valid (i.e. having an inline menu)
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 I remove this now or address it once I implement inline menu support?
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.
SGTM to do in a follow-up PR
/** The Portal in which the Menu is displayed inside of */ | ||
private _portal: TemplatePortal; |
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.
/** The Portal in which the Menu is displayed inside of */ | |
private _portal: TemplatePortal; | |
/** The content of the menu panel opened by this trigger. */ | |
private _panelContent: TemplatePortal; |
/** Return true if the trigger has an attached menu */ | ||
private _hasSubmenu() { | ||
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.
Thinking about this more, should this just be hasMenu
and isMenuOpen
below? Each trigger is inherently connected to a single menu item, regardless of whether it's a top-level menu or a sub-menu on a menu item (I think the class name also then becomes just CdkMenuTrigger
)
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 cdkMenuTrigger
is added alongside cdkMenuItem
would CdkMenuItem also drop the Sub
?
I think it makes sense to stick with one approach for consistency since it becomes confusing why a MenuItem has a submenu and a trigger has a Menu.
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.
SGTM to drop "sub" everywhere in a follow-up PR
|
||
/** Determine and return where to position the submenu relative to the menu item */ | ||
private _getOverlayPositions(): ConnectedPosition[] { | ||
// TODO: use a common positioning strategy from (possibly) cdk/overlay |
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.
// TODO: use a common positioning strategy from (possibly) cdk/overlay | |
// TODO: use a common positioning config from (possibly) cdk/overlay |
@jelbourn @teflonwaffles Followup feedback should be addressed PTAL |
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.
Looks good, just one last comment
@Output() readonly cdkMenuOpened: EventEmitter<void> = new EventEmitter(); | ||
|
||
/** Emits when the attached submenu is requested to close */ | ||
@Output() readonly cdkMenuClosed: 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.
Ah, I should have been more specific here; these can be
@Output('cdkMenuOpened') readonly opened: EventEmitter<void> = new EventEmitter();
@Output('cdkMenuClosed') readonly closed: EventEmitter<void> = new EventEmitter();
We always want the actual property in TypeScript to be unprefixed, with the prefix only aplpying to template bindings via the alias
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.
🤣 makes sense now.
Addressed
/** Return true if the trigger has an attached menu */ | ||
private _hasSubmenu() { | ||
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.
SGTM to drop "sub" everywhere in a follow-up PR
if (parent) { | ||
parent._registerMenu(this); | ||
} else { | ||
throwMissingMenuPanelError(); |
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.
SGTM to do in a follow-up PR
with prefix - not internal name
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
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. |
Implement logic to open/close attached menus and submenus when triggering the specified MenuItem to which the menu is attached to