-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(cdk-experimental/menu): add functionality to navigate a Menu and MenuBar with a keyboard #19962
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
dc1d361
to
c359611
Compare
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.
First pass of comments, got to go walk the dog again- will take another look tomorrow
close(): Observable<MenuStackItem> { | ||
return this._close.asObservable(); | ||
} |
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.
close(): Observable<MenuStackItem> { | |
return this._close.asObservable(); | |
} | |
readonly close: Observable<MenuStackItem> = this._close.asObservable(); |
Could close
and empty
be properties? It looks like they never change
@@ -18,6 +19,9 @@ export class CdkMenuPanel { | |||
/** Reference to the child menu component */ | |||
_menu?: Menu; | |||
|
|||
/** Keep track of open Menus. */ | |||
_menuStack: MenuStack = new MenuStack(); |
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 got confused at first because this looked like this was always creating a new stack; it wasn't clear that the trigger would overwrite this stack upon the panel being bound to the trigger.
Is it possible to not instantiate a new stack here and instead have a comment that explains that the stack would be set by the trigger upon opening?
|
||
/** Get the MenuStack from the parent menu. */ | ||
private _getMenuStack() { | ||
return this._parentMenu._menuStack; |
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 add a comment here explaining that this needs to be a function (rather than a property set during construction) because the menu doesn't have a reference to its stack at creation time.
@@ -26,5 +30,8 @@ export class CdkMenuPanel { | |||
*/ | |||
_registerMenu(child: Menu) { | |||
this._menu = child; | |||
|
|||
this._menu._menuStack = this._menuStack; |
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 add a comment here that explains that, while we'd ideally like to inject the stack into the menu, this isn't currently possible because the menu's injector can't be affected by the code stamping out the panel template.
import {Subject, Observable} from 'rxjs'; | ||
|
||
/** Events to emit as specified by the caller once the MenuStack is empty. */ | ||
export type FocusNext = 'nextItem' | 'previousItem' | 'currentItem' | undefined; |
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 this be a const enum
? We typically only use string literal types like this when the value would be coming from a template binding or other user-based input.
case HOME: | ||
case END: | ||
if (!hasModifierKey(event)) { | ||
event.preventDefault(); | ||
event.keyCode === HOME ? this.focusFirstItem('keyboard') : this.focusLastItem('keyboard'); | ||
} | ||
break; | ||
|
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.
FYI we just support for home and end in ListKeyManager
this past week
@crisbeto I'd love to get your input on this approach; I've had my head in the problem for too long |
@@ -20,18 +35,169 @@ import {CDK_MENU, Menu} from './menu-interface'; | |||
selector: '[cdkMenuBar]', | |||
exportAs: 'cdkMenuBar', | |||
host: { | |||
'(keydown)': '_handleKeyEvent($event)', | |||
'(focus)': 'focusFirstItem()', |
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 we're redirecting focus anyway, couldn't we remove the tabindex
from this this and let the user tab into the first item directly?
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.
yeah, I didn't want to pollute this PR with too much tab focus logic. I'll have a follow on which sets the tabindex of the first menu item in the menu bar to 0 and handles changes
as well.
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.
Considering this further, the current implementation will set focus on the first non-disabled item and keeps focus on the MenuBar if all elements are disabled which is the expected behaviour. Any changes would essentially do the same. The only difference being that the tab index of the first menu item would be 0 rather than -1 as is here. Thoughts?
) { | ||
event.preventDefault(); | ||
|
||
const prevIsOpen = this._keyManager.activeItem?.isMenuOpen(); |
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 looks like you're repeating this._keyManager.activeItem
in almost all of the switch cases. Maybe move it out into a variable at the top?
/** Subscribe to the MenuStack close and empty observables. */ | ||
private _subscribeToMenuStack() { | ||
this._menuStack.close().subscribe((item: MenuStackItem) => this._closeOpenMenu(item)); | ||
this._menuStack.empty().subscribe((event: FocusNext) => this._toggleOpenMenu(event)); |
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 doesn't look like these are unsubscribing.
if (item === this._keyManager.activeItem?.getMenu()) { | ||
this._keyManager.activeItem.getMenuTrigger()?.closeMenu(); | ||
this._keyManager.setFocusOrigin('keyboard'); | ||
this._keyManager.setActiveItem(this._keyManager.activeItem); |
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.
Repeating this._keyManager
won't minify with anything aside from Closure compiler. If you have it multiple times within the same function, it's better to save it in a variable.
if (!this.isMenuOpen()) { | ||
this.opened.next(); | ||
|
||
this._overlayRef = this._overlay.create(this._getOverlayConfig()); |
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 will recreate the overlay every time you open it. You can reuse it like this:
if (!this._overlayRef) {
this._overlayRef = this._overlay.create(this._getOverlayConfig());
}
this._overlayRef.attach(this._getPortal());
case RIGHT_ARROW: | ||
if (this._isParentVertical()) { | ||
event.preventDefault(); | ||
if (this._directionality.value === 'ltr') { |
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 _directionality
should be decorated with @Optional
, because we aren't guaranteed for it to exist. Also generally we format direction checks like this, because LTR is the most common case and it handles undefined values better:
if (direction === 'rtl') {
// do RTL stuff
} else {
// do LTR stuff
}
/** Get the label for this element which is required by the FocusableOption interface. */ | ||
getLabel(): string { | ||
// TODO(andy): implement a more robust algorithm for determining nested text | ||
return this._elementRef.nativeElement.innerText; |
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.
innerText
will trigger a reflow, because it has some logic where it excludes hidden text. We should use textContent
here.
*/ | ||
export class MenuStack { | ||
/** All MenuStackItems tracked by this MenuStack. */ | ||
private readonly _elements: MenuStackItem[] = []; |
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 that using a stack/array that pretends to be a stack here will run into the same issue as when we tried to keep track of menu items ourselves in mat-menu
. If a menu pushes itself onto the stack, gets destroyed by an ngIf
and then gets re-added, the stack will be out of order. E.g. if we have [1, 2, 3]
, we remove 2 and then we re-add it, the stack will have [1, 3, 2]
, whereas the DOM will still be [1, 2, 3]
.
That being said, I'm having a hard time visualizing when the registration happens. If it's only while a stack of menus is open, we'll probably be fine.
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.
Hmm. Not sure if I follow. If we remove 2 that means we closed 2 and closing 2 should close all submenus. Hence [1,2,3]
would result in [1]
after removing 2.
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.
Yeah, I think that if you ensure any menu closing also closes all of its sub-menus, this should be okay. (Unless there's something a user might do that I'm not imagining)
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.
Was already merged in before I had a chance to address this. I'll address with a follow on pr.
closeAll(focusNext?: FocusNext) { | ||
if (this._elements.length) { | ||
while (this._elements.length) { | ||
const menuStackItem = this._elements.pop(); |
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 looks like you're repeating a bit of the logic from closeLatest
. Could it be reduced to this?
while (this._elements.length) {
this.closeLatest();
}
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.
Long story short, no. It has to do with the Subject which results in loop
* not emit if the stack was initially empty. | ||
*/ | ||
closeAll(focusNext?: FocusNext) { | ||
if (this._elements.length) { |
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 check isn't necessary since the loop won't start if the array is empty.
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.
yeah, but I don't want to emit if the stack was initially empty. Of course I could do something like
const shouldEmit = this._elements.length > 0;
while(...) {...}
if (shouldEmit) {this._empty.next(focusNext)}
But the existing approach seemed cleaner IMO, but let me know what you think
.map(e => e.nativeElement) | ||
.slice(0, 2); // menu bar has the first 2 menu items | ||
|
||
if (nativeMenus.length > 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.
It would be best to avoid any conditional logic when setting up a unit test suite, if possible.
function openShareMenu() { | ||
openFileMenu(); | ||
|
||
if (nativeMenus.length === 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 openShareMenu() will be called with both nativeMenus.length === 1 and nativeMenus.length !== 1, could you create separate versions of the function and call the relevant function in each scenario (instead of relying on an if statement)>
button = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem); | ||
nativeButton = fixture.debugElement.query(By.directive(CdkMenuItem)).nativeElement; | ||
}); | ||
describe('without enclosing 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 thought this meant, without elements around the menu item. Could you reword this to be clearer (eg. 'with no complex inner elements', to match the other suite).
private readonly _empty: Subject<FocusNext> = new Subject(); | ||
|
||
/** Observable which emits the MenuStackItem which has been requested to close. */ | ||
close(): Observable<MenuStackItem> { |
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 have an internal guideline to use typing to restrict the type when exposing a subject as an observable, instead of explicitly calling asObservable(). @jelbourn , is this also true in the CDK codebase, or do you prefer to call asObservable()?
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.
Yeah, we could do that here; that is probably better
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.
Was already merged in before I had a chance to address, will include fix in a follow on pr.
… MenuBar with a keyboard The aria spec for Menu and MenuBar describes a robust set of instructions for navigating the Menu using a keyboard. This implements the full aria spec for vertical and horizontal Menus along with support for left-to-right and right-to-left layouts. Some liberty is taken with handling keyboard events with modifier keys (home/end and escape) in order to match the existing mat-menu implementation for those keys.
c359611
to
134f627
Compare
@teflonwaffles @jelbourn @crisbeto Feedback should be addressed. |
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
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
… MenuBar with a keyboard (angular#19962) The aria spec for Menu and MenuBar describes a robust set of instructions for navigating the Menu using a keyboard. This implements the full aria spec for vertical and horizontal Menus along with support for left-to-right and right-to-left layouts. Some liberty is taken with handling keyboard events with modifier keys (home/end and escape) in order to match the existing mat-menu implementation for those keys.
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. |
The aria spec for Menu and MenuBar describes a robust set of instructions for navigating the Menu
using a keyboard. This implements the full aria spec for vertical and horizontal Menus along with
support for left-to-right and right-to-left layouts. Some liberty is taken with handling keyboard
events with modifier keys (home/end and escape) in order to match the existing mat-menu
implementation for those keys.