diff --git a/src/cdk-experimental/menu/menu-bar.ts b/src/cdk-experimental/menu/menu-bar.ts index cfef6451335c..1670c38f565e 100644 --- a/src/cdk-experimental/menu/menu-bar.ts +++ b/src/cdk-experimental/menu/menu-bar.ts @@ -7,29 +7,24 @@ */ import { - Directive, - Input, - ContentChildren, - QueryList, AfterContentInit, - OnDestroy, - Optional, - NgZone, + Directive, ElementRef, Inject, + NgZone, + OnDestroy, + Optional, Self, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; -import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; -import {LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW, ESCAPE, TAB} from '@angular/cdk/keycodes'; -import {takeUntil, mergeAll, mapTo, startWith, mergeMap, switchMap} from 'rxjs/operators'; -import {Subject, merge} from 'rxjs'; +import {DOWN_ARROW, ESCAPE, LEFT_ARROW, RIGHT_ARROW, TAB, UP_ARROW} from '@angular/cdk/keycodes'; +import {takeUntil} from 'rxjs/operators'; import {CdkMenuGroup} from './menu-group'; -import {CDK_MENU, Menu} from './menu-interface'; -import {CdkMenuItem} from './menu-item'; -import {MenuStack, MenuStackItem, FocusNext, MENU_STACK} from './menu-stack'; +import {CDK_MENU} from './menu-interface'; +import {FocusNext, MENU_STACK, MenuStack} from './menu-stack'; import {PointerFocusTracker} from './pointer-focus-tracker'; -import {MenuAim, MENU_AIM} from './menu-aim'; +import {MENU_AIM, MenuAim} from './menu-aim'; +import {CdkMenuBase} from './menu-base'; /** * Directive applied to an element which configures it as a MenuBar by setting the appropriate @@ -44,8 +39,6 @@ import {MenuAim, MENU_AIM} from './menu-aim'; 'role': 'menubar', 'class': 'cdk-menu-bar', 'tabindex': '0', - '[attr.aria-orientation]': 'orientation', - '(focus)': 'focusFirstItem()', '(keydown)': '_handleKeyEvent($event)', }, providers: [ @@ -54,60 +47,31 @@ import {MenuAim, MENU_AIM} from './menu-aim'; {provide: MENU_STACK, useClass: MenuStack}, ], }) -export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { - /** - * Sets the aria-orientation attribute and determines where menus will be opened. - * Does not affect styling/layout. - */ - @Input('cdkMenuBarOrientation') orientation: 'horizontal' | 'vertical' = 'horizontal'; - - /** Handles keyboard events for the MenuBar. */ - private _keyManager: FocusKeyManager; - - /** Manages items under mouse focus */ - private _pointerTracker?: PointerFocusTracker; +export class CdkMenuBar extends CdkMenuBase implements AfterContentInit, OnDestroy { + override readonly orientation: 'horizontal' | 'vertical' = 'horizontal'; - /** Emits when the MenuBar is destroyed. */ - private readonly _destroyed: Subject = new Subject(); - - /** All child MenuItem elements nested in this MenuBar. */ - @ContentChildren(CdkMenuItem, {descendants: true}) - private readonly _allItems: QueryList; - - /** The Menu Item which triggered the open submenu. */ - private _openItem?: CdkMenuItem; + override menuStack: MenuStack; constructor( private readonly _ngZone: NgZone, - readonly _elementRef: ElementRef, - @Inject(MENU_STACK) readonly _menuStack: MenuStack, + elementRef: ElementRef, + @Inject(MENU_STACK) menuStack: MenuStack, @Self() @Optional() @Inject(MENU_AIM) private readonly _menuAim?: MenuAim, - @Optional() private readonly _dir?: Directionality, + @Optional() dir?: Directionality, ) { - super(); + super(elementRef, menuStack, dir); } override ngAfterContentInit() { super.ngAfterContentInit(); - - this._setKeyManager(); - this._subscribeToMenuOpen(); - this._subscribeToMenuStack(); + this._subscribeToMenuStackEmptied(); this._subscribeToMouseManager(); - - this._menuAim?.initialize(this, this._pointerTracker!); - } - - /** Place focus on the first MenuItem in the menu and set the focus origin. */ - focusFirstItem(focusOrigin: FocusOrigin = 'program') { - this._keyManager.setFocusOrigin(focusOrigin); - this._keyManager.setFirstItemActive(); + this._menuAim?.initialize(this, this.pointerTracker!); } - /** Place focus on the last MenuItem in the menu and set the focus origin. */ - focusLastItem(focusOrigin: FocusOrigin = 'program') { - this._keyManager.setFocusOrigin(focusOrigin); - this._keyManager.setLastItemActive(); + override ngOnDestroy() { + super.ngOnDestroy(); + this.pointerTracker?.destroy(); } /** @@ -116,7 +80,7 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, * @param event the KeyboardEvent to handle. */ _handleKeyEvent(event: KeyboardEvent) { - const keyManager = this._keyManager; + const keyManager = this.keyManager; switch (event.keyCode) { case UP_ARROW: case DOWN_ARROW: @@ -127,8 +91,8 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, // up/down keys were clicked: if the current menu is open, close it then focus and open the // next menu. if ( - (this._isHorizontal() && horizontalArrows) || - (!this._isHorizontal() && !horizontalArrows) + (this.isHorizontal() && horizontalArrows) || + (!this.isHorizontal() && !horizontalArrows) ) { event.preventDefault(); @@ -157,69 +121,27 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, } } - /** Setup the FocusKeyManager with the correct orientation for the menu bar. */ - private _setKeyManager() { - this._keyManager = new FocusKeyManager(this._allItems) - .withWrap() - .withTypeAhead() - .withHomeAndEnd(); - - if (this._isHorizontal()) { - this._keyManager.withHorizontalOrientation(this._dir?.value || 'ltr'); - } else { - this._keyManager.withVerticalOrientation(); - } - } - /** * Set the PointerFocusTracker and ensure that when mouse focus changes the key manager is updated * with the latest menu item under mouse focus. */ private _subscribeToMouseManager() { this._ngZone.runOutsideAngular(() => { - this._pointerTracker = new PointerFocusTracker(this._allItems); - this._pointerTracker.entered.pipe(takeUntil(this._destroyed)).subscribe(item => { - if (this._hasOpenSubmenu()) { - this._keyManager.setActiveItem(item); + this.pointerTracker = new PointerFocusTracker(this.items); + this.pointerTracker.entered.pipe(takeUntil(this.destroyed)).subscribe(item => { + if (this.hasOpenSubmenu()) { + this.keyManager.setActiveItem(item); } }); }); } - /** Subscribe to the MenuStack close and empty observables. */ - private _subscribeToMenuStack() { - this._menuStack.closed - .pipe(takeUntil(this._destroyed)) - .subscribe(item => this._closeOpenMenu(item)); - - this._menuStack.emptied - .pipe(takeUntil(this._destroyed)) - .subscribe(event => this._toggleOpenMenu(event)); - } - - /** - * Close the open menu if the current active item opened the requested MenuStackItem. - * @param item the MenuStackItem requested to be closed. - */ - private _closeOpenMenu(menu: MenuStackItem | undefined) { - const trigger = this._openItem; - const keyManager = this._keyManager; - if (menu === trigger?.getMenuTrigger()?.getMenu()) { - trigger?.getMenuTrigger()?.closeMenu(); - // If the user has moused over a sibling item we want to focus the element under mouse focus - // not the trigger which previously opened the now closed menu. - if (trigger) { - keyManager.setActiveItem(this._pointerTracker?.activeElement || trigger); - } - } - } - /** * Set focus to either the current, previous or next item based on the FocusNext event, then * open the previous or next item. */ private _toggleOpenMenu(event: FocusNext | undefined) { - const keyManager = this._keyManager; + const keyManager = this.keyManager; switch (event) { case FocusNext.nextItem: keyManager.setFocusOrigin('keyboard'); @@ -242,48 +164,9 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, } } - /** - * @return true if the menu bar is configured to be horizontal. - */ - private _isHorizontal() { - return this.orientation === 'horizontal'; - } - - /** - * Subscribe to the menu trigger's open events in order to track the trigger which opened the menu - * and stop tracking it when the menu is closed. - */ - private _subscribeToMenuOpen() { - const exitCondition = merge(this._allItems.changes, this._destroyed); - this._allItems.changes - .pipe( - startWith(this._allItems), - mergeMap((list: QueryList) => - list - .filter(item => item.hasMenu()) - .map(item => item.getMenuTrigger()!.opened.pipe(mapTo(item), takeUntil(exitCondition))), - ), - mergeAll(), - switchMap((item: CdkMenuItem) => { - this._openItem = item; - return item.getMenuTrigger()!.closed; - }), - takeUntil(this._destroyed), - ) - .subscribe(() => (this._openItem = undefined)); - } - - /** Return true if the MenuBar has an open submenu. */ - private _hasOpenSubmenu() { - return !!this._openItem; - } - - override ngOnDestroy() { - super.ngOnDestroy(); - - this._destroyed.next(); - this._destroyed.complete(); - - this._pointerTracker?.destroy(); + private _subscribeToMenuStackEmptied() { + this.menuStack?.emptied + .pipe(takeUntil(this.destroyed)) + .subscribe(event => this._toggleOpenMenu(event)); } } diff --git a/src/cdk-experimental/menu/menu-base.ts b/src/cdk-experimental/menu/menu-base.ts new file mode 100644 index 000000000000..ffecee8bc3c3 --- /dev/null +++ b/src/cdk-experimental/menu/menu-base.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CdkMenuGroup} from './menu-group'; +import { + AfterContentInit, + ContentChildren, + Directive, + ElementRef, + Inject, + OnDestroy, + Optional, + QueryList, +} from '@angular/core'; +import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; +import {CdkMenuItem} from './menu-item'; +import {merge, Subject} from 'rxjs'; +import {Directionality} from '@angular/cdk/bidi'; +import {mapTo, mergeAll, mergeMap, startWith, switchMap, takeUntil} from 'rxjs/operators'; +import {MENU_STACK, MenuStack, MenuStackItem} from './menu-stack'; +import {Menu} from './menu-interface'; +import {PointerFocusTracker} from './pointer-focus-tracker'; + +@Directive({ + host: { + '[attr.aria-orientation]': 'orientation', + '(focus)': 'focusFirstItem()', + }, +}) +export abstract class CdkMenuBase + extends CdkMenuGroup + implements Menu, AfterContentInit, OnDestroy +{ + /** + * Sets the aria-orientation attribute and determines where menus will be opened. + * Does not affect styling/layout. + */ + readonly orientation: 'horizontal' | 'vertical' = 'vertical'; + + /** All child MenuItem elements nested in this Menu. */ + @ContentChildren(CdkMenuItem, {descendants: true}) + protected readonly items: QueryList; + + /** Handles keyboard events for the menu. */ + protected keyManager: FocusKeyManager; + + /** Emits when the MenuBar is destroyed. */ + protected readonly destroyed: Subject = new Subject(); + + /** The Menu Item which triggered the open submenu. */ + protected openItem?: CdkMenuItem; + + /** Manages items under mouse focus */ + protected pointerTracker?: PointerFocusTracker; + + protected constructor( + readonly _elementRef: ElementRef, + @Optional() @Inject(MENU_STACK) readonly menuStack?: MenuStack, + @Optional() protected readonly dir?: Directionality, + ) { + super(); + } + + override ngAfterContentInit() { + super.ngAfterContentInit(); + this._setKeyManager(); + this._subscribeToMenuOpen(); + this._subscribeToMenuStackClosed(); + } + + override ngOnDestroy() { + super.ngOnDestroy(); + this.destroyed.next(); + this.destroyed.complete(); + } + + /** Place focus on the first MenuItem in the menu and set the focus origin. */ + focusFirstItem(focusOrigin: FocusOrigin = 'program') { + this.keyManager.setFocusOrigin(focusOrigin); + this.keyManager.setFirstItemActive(); + } + + /** Place focus on the last MenuItem in the menu and set the focus origin. */ + focusLastItem(focusOrigin: FocusOrigin = 'program') { + this.keyManager.setFocusOrigin(focusOrigin); + this.keyManager.setLastItemActive(); + } + + /** Return true if this menu has been configured in a horizontal orientation. */ + protected isHorizontal() { + return this.orientation === 'horizontal'; + } + + /** Return true if the MenuBar has an open submenu. */ + protected hasOpenSubmenu() { + return !!this.openItem; + } + + /** + * Close the open menu if the current active item opened the requested MenuStackItem. + * @param item the MenuStackItem requested to be closed. + */ + protected closeOpenMenu(menu: MenuStackItem | undefined) { + const keyManager = this.keyManager; + const trigger = this.openItem; + if (menu === trigger?.getMenuTrigger()?.getMenu()) { + trigger?.getMenuTrigger()?.closeMenu(); + // If the user has moused over a sibling item we want to focus the element under mouse focus + // not the trigger which previously opened the now closed menu. + if (trigger) { + keyManager.setActiveItem(this.pointerTracker?.activeElement || trigger); + } + } + } + + /** Setup the FocusKeyManager with the correct orientation for the menu. */ + private _setKeyManager() { + this.keyManager = new FocusKeyManager(this.items).withWrap().withTypeAhead().withHomeAndEnd(); + + if (this.isHorizontal()) { + this.keyManager.withHorizontalOrientation(this.dir?.value || 'ltr'); + } else { + this.keyManager.withVerticalOrientation(); + } + } + + /** + * Subscribe to the menu trigger's open events in order to track the trigger which opened the menu + * and stop tracking it when the menu is closed. + */ + private _subscribeToMenuOpen() { + const exitCondition = merge(this.items.changes, this.destroyed); + this.items.changes + .pipe( + startWith(this.items), + mergeMap((list: QueryList) => + list + .filter(item => item.hasMenu()) + .map(item => item.getMenuTrigger()!.opened.pipe(mapTo(item), takeUntil(exitCondition))), + ), + mergeAll(), + switchMap((item: CdkMenuItem) => { + this.openItem = item; + return item.getMenuTrigger()!.closed; + }), + takeUntil(this.destroyed), + ) + .subscribe(() => (this.openItem = undefined)); + } + + /** Subscribe to the MenuStack close and empty observables. */ + private _subscribeToMenuStackClosed() { + this.menuStack?.closed + .pipe(takeUntil(this.destroyed)) + .subscribe(item => this.closeOpenMenu(item)); + } +} diff --git a/src/cdk-experimental/menu/menu-group.ts b/src/cdk-experimental/menu/menu-group.ts index f2f60288eb58..7f6e2670b1b7 100644 --- a/src/cdk-experimental/menu/menu-group.ts +++ b/src/cdk-experimental/menu/menu-group.ts @@ -7,13 +7,13 @@ */ import { + AfterContentInit, + ContentChildren, Directive, - Output, EventEmitter, - ContentChildren, - AfterContentInit, - QueryList, OnDestroy, + Output, + QueryList, } from '@angular/core'; import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; import {takeUntil} from 'rxjs/operators'; @@ -48,6 +48,11 @@ export class CdkMenuGroup implements AfterContentInit, OnDestroy { this._registerMenuSelectionListeners(); } + ngOnDestroy() { + this._selectableChanges.next(); + this._selectableChanges.complete(); + } + /** * Register the child selectable elements with the change emitter and ensure any new child * elements do so as well. @@ -67,9 +72,4 @@ export class CdkMenuGroup implements AfterContentInit, OnDestroy { .pipe(takeUntil(this._selectableChanges)) .subscribe(() => this.change.next(selectable)); } - - ngOnDestroy() { - this._selectableChanges.next(); - this._selectableChanges.complete(); - } } diff --git a/src/cdk-experimental/menu/menu-interface.ts b/src/cdk-experimental/menu/menu-interface.ts index 5a8af9428cd9..ca8846d4c459 100644 --- a/src/cdk-experimental/menu/menu-interface.ts +++ b/src/cdk-experimental/menu/menu-interface.ts @@ -19,7 +19,7 @@ export interface Menu extends MenuStackItem { _elementRef: ElementRef; /** The orientation of the menu */ - orientation: 'horizontal' | 'vertical'; + readonly orientation: 'horizontal' | 'vertical'; /** Place focus on the first MenuItem in the menu. */ focusFirstItem(focusOrigin: FocusOrigin): void; diff --git a/src/cdk-experimental/menu/menu-item-trigger.ts b/src/cdk-experimental/menu/menu-item-trigger.ts index 7abc285d99b7..8f7e27aedfb4 100644 --- a/src/cdk-experimental/menu/menu-item-trigger.ts +++ b/src/cdk-experimental/menu/menu-item-trigger.ts @@ -36,7 +36,7 @@ import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angu import {fromEvent, merge, Subject} from 'rxjs'; import {filter, takeUntil} from 'rxjs/operators'; import {CDK_MENU, Menu} from './menu-interface'; -import {FocusNext, MENU_STACK, MenuStack} from './menu-stack'; +import {MENU_STACK, MenuStack} from './menu-stack'; import {MENU_AIM, MenuAim} from './menu-aim'; import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; @@ -219,26 +219,18 @@ export class CdkMenuItemTrigger extends MenuTrigger implements OnDestroy { break; case RIGHT_ARROW: - if (this._parentMenu && this._isParentVertical()) { + if (this._parentMenu && this._isParentVertical() && this._directionality?.value !== 'rtl') { event.preventDefault(); - if (this._directionality?.value === 'rtl') { - this.menuStack.close(this._parentMenu, FocusNext.currentItem); - } else { - this.openMenu(); - this.childMenu?.focusFirstItem('keyboard'); - } + this.openMenu(); + this.childMenu?.focusFirstItem('keyboard'); } break; case LEFT_ARROW: - if (this._parentMenu && this._isParentVertical()) { + if (this._parentMenu && this._isParentVertical() && this._directionality?.value === 'rtl') { event.preventDefault(); - if (this._directionality?.value === 'rtl') { - this.openMenu(); - this.childMenu?.focusFirstItem('keyboard'); - } else { - this.menuStack.close(this._parentMenu, FocusNext.currentItem); - } + this.openMenu(); + this.childMenu?.focusFirstItem('keyboard'); } break; diff --git a/src/cdk-experimental/menu/menu-item.ts b/src/cdk-experimental/menu/menu-item.ts index 1bc50197b7b0..5fead3b8548e 100644 --- a/src/cdk-experimental/menu/menu-item.ts +++ b/src/cdk-experimental/menu/menu-item.ts @@ -189,20 +189,26 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, break; case RIGHT_ARROW: - if (this._parentMenu && this._isParentVertical() && !this.hasMenu()) { - event.preventDefault(); - this._dir?.value === 'rtl' - ? this._menuStack?.close(this._parentMenu, FocusNext.previousItem) - : this._menuStack?.closeAll(FocusNext.nextItem); + if (this._parentMenu && this._isParentVertical()) { + if (this._dir?.value === 'rtl') { + event.preventDefault(); + this._menuStack?.close(this._parentMenu, FocusNext.previousItem); + } else if (!this.hasMenu()) { + event.preventDefault(); + this._menuStack?.closeAll(FocusNext.nextItem); + } } break; case LEFT_ARROW: - if (this._parentMenu && this._isParentVertical() && !this.hasMenu()) { - event.preventDefault(); - this._dir?.value === 'rtl' - ? this._menuStack?.closeAll(FocusNext.nextItem) - : this._menuStack?.close(this._parentMenu, FocusNext.previousItem); + if (this._parentMenu && this._isParentVertical()) { + if (this._dir?.value !== 'rtl') { + event.preventDefault(); + this._menuStack?.close(this._parentMenu, FocusNext.previousItem); + } else if (!this.hasMenu()) { + event.preventDefault(); + this._menuStack?.closeAll(FocusNext.nextItem); + } } break; } diff --git a/src/cdk-experimental/menu/menu-stack.spec.ts b/src/cdk-experimental/menu/menu-stack.spec.ts index 72990f39bc3d..d8b475dc8783 100644 --- a/src/cdk-experimental/menu/menu-stack.spec.ts +++ b/src/cdk-experimental/menu/menu-stack.spec.ts @@ -17,7 +17,7 @@ describe('MenuStack', () => { fixture.detectChanges(); triggers = fixture.componentInstance.triggers.toArray(); menus = fixture.componentInstance.menus.toArray(); - menuStack = fixture.componentInstance.menuBar._menuStack; + menuStack = fixture.componentInstance.menuBar.menuStack; } beforeEach(waitForAsync(() => { diff --git a/src/cdk-experimental/menu/menu-stack.ts b/src/cdk-experimental/menu/menu-stack.ts index a3a93480e910..006ac4f2f8c8 100644 --- a/src/cdk-experimental/menu/menu-stack.ts +++ b/src/cdk-experimental/menu/menu-stack.ts @@ -21,7 +21,7 @@ export const enum FocusNext { */ export interface MenuStackItem { /** A reference to the previous Menus MenuStack instance. */ - _menuStack?: MenuStack; + menuStack?: MenuStack; } /** Injection token used for an implementation of MenuStack. */ diff --git a/src/cdk-experimental/menu/menu.ts b/src/cdk-experimental/menu/menu.ts index bd3efc0be0b0..dcb6ea313087 100644 --- a/src/cdk-experimental/menu/menu.ts +++ b/src/cdk-experimental/menu/menu.ts @@ -13,16 +13,13 @@ import { ElementRef, EventEmitter, Inject, - Input, NgZone, OnDestroy, - OnInit, Optional, Output, QueryList, Self, } from '@angular/core'; -import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; import { DOWN_ARROW, ESCAPE, @@ -33,15 +30,14 @@ import { UP_ARROW, } from '@angular/cdk/keycodes'; import {Directionality} from '@angular/cdk/bidi'; -import {merge} from 'rxjs'; -import {mapTo, mergeAll, mergeMap, startWith, switchMap, take, takeUntil} from 'rxjs/operators'; +import {take, takeUntil} from 'rxjs/operators'; import {CdkMenuGroup} from './menu-group'; -import {CDK_MENU, Menu} from './menu-interface'; -import {CdkMenuItem} from './menu-item'; -import {FocusNext, MENU_STACK, MenuStack, MenuStackItem} from './menu-stack'; +import {CDK_MENU} from './menu-interface'; +import {FocusNext, MENU_STACK, MenuStack} from './menu-stack'; import {PointerFocusTracker} from './pointer-focus-tracker'; import {MENU_AIM, MenuAim} from './menu-aim'; import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; +import {CdkMenuBase} from './menu-base'; /** * Directive which configures the element as a Menu which should contain child elements marked as @@ -54,12 +50,10 @@ import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; selector: '[cdkMenu]', exportAs: 'cdkMenu', host: { - '[tabindex]': '_isInline() ? 0 : null', 'role': 'menu', 'class': 'cdk-menu', + '[tabindex]': '_isInline() ? 0 : null', '[class.cdk-menu-inline]': '_isInline()', - '[attr.aria-orientation]': 'orientation', - '(focus)': 'focusFirstItem()', '(keydown)': '_handleKeyEvent($event)', }, providers: [ @@ -67,80 +61,49 @@ import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; {provide: CDK_MENU, useExisting: CdkMenu}, ], }) -export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnInit, OnDestroy { - /** - * Sets the aria-orientation attribute and determines where menus will be opened. - * Does not affect styling/layout. - */ - @Input('cdkMenuOrientation') orientation: 'horizontal' | 'vertical' = 'vertical'; - +export class CdkMenu extends CdkMenuBase implements AfterContentInit, OnDestroy { /** Event emitted when the menu is closed. */ - @Output() readonly closed: EventEmitter = new EventEmitter(); - - /** Handles keyboard events for the menu. */ - private _keyManager: FocusKeyManager; - - /** Manages items under mouse focus. */ - private _pointerTracker?: PointerFocusTracker; + @Output() readonly closed: EventEmitter = new EventEmitter(); /** List of nested CdkMenuGroup elements */ @ContentChildren(CdkMenuGroup, {descendants: true}) private readonly _nestedGroups: QueryList; - /** All child MenuItem elements nested in this Menu. */ - @ContentChildren(CdkMenuItem, {descendants: true}) - private readonly _allItems: QueryList; - - /** The Menu Item which triggered the open submenu. */ - private _openItem?: CdkMenuItem; - constructor( private readonly _ngZone: NgZone, - readonly _elementRef: ElementRef, - @Optional() @Inject(MENU_STACK) readonly _menuStack?: MenuStack, + elementRef: ElementRef, + @Optional() @Inject(MENU_STACK) menuStack?: MenuStack, @Optional() @Inject(MENU_TRIGGER) private _parentTrigger?: MenuTrigger, @Self() @Optional() @Inject(MENU_AIM) private readonly _menuAim?: MenuAim, - @Optional() private readonly _dir?: Directionality, + @Optional() dir?: Directionality, ) { - super(); - } - - ngOnInit() { - this._menuStack?.push(this); + super(elementRef, menuStack, dir); + this.destroyed.subscribe(this.closed); + this.menuStack?.push(this); this._parentTrigger?.registerChildMenu(this); } override ngAfterContentInit() { super.ngAfterContentInit(); - this._completeChangeEmitter(); - this._setKeyManager(); - this._subscribeToMenuOpen(); - this._subscribeToMenuStack(); + this._subscribeToMenuStackEmptied(); this._subscribeToMouseManager(); - - this._menuAim?.initialize(this, this._pointerTracker!); - } - - /** Place focus on the first MenuItem in the menu and set the focus origin. */ - focusFirstItem(focusOrigin: FocusOrigin = 'program') { - this._keyManager.setFocusOrigin(focusOrigin); - this._keyManager.setFirstItemActive(); + this._menuAim?.initialize(this, this.pointerTracker!); } - /** Place focus on the last MenuItem in the menu and set the focus origin. */ - focusLastItem(focusOrigin: FocusOrigin = 'program') { - this._keyManager.setFocusOrigin(focusOrigin); - this._keyManager.setLastItemActive(); + override ngOnDestroy() { + super.ngOnDestroy(); + this.closed.complete(); + this.pointerTracker?.destroy(); } /** Handle keyboard events for the Menu. */ _handleKeyEvent(event: KeyboardEvent) { - const keyManager = this._keyManager; + const keyManager = this.keyManager; switch (event.keyCode) { case LEFT_ARROW: case RIGHT_ARROW: - if (this._isHorizontal()) { + if (this.isHorizontal()) { event.preventDefault(); keyManager.setFocusOrigin('keyboard'); keyManager.onKeydown(event); @@ -149,7 +112,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI case UP_ARROW: case DOWN_ARROW: - if (!this._isHorizontal()) { + if (!this.isHorizontal()) { event.preventDefault(); keyManager.setFocusOrigin('keyboard'); keyManager.onKeydown(event); @@ -159,12 +122,12 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI case ESCAPE: if (!hasModifierKey(event)) { event.preventDefault(); - this._menuStack?.close(this, FocusNext.currentItem); + this.menuStack?.close(this, FocusNext.currentItem); } break; case TAB: - this._menuStack?.closeAll(); + this.menuStack?.closeAll(); break; default: @@ -176,6 +139,9 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI * Complete the change emitter if there are any nested MenuGroups or register to complete the * change emitter if a MenuGroup is rendered at some point */ + // TODO(mmalerba): This doesnt' quite work. It causes change events to stop + // firing for radio items directly in the menu if a second group of options + // is added in a menu-group. private _completeChangeEmitter() { if (this._hasNestedGroups()) { this.change.complete(); @@ -193,64 +159,22 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI return this._nestedGroups.length > 0 && !(this._nestedGroups.first instanceof CdkMenu); } - /** Setup the FocusKeyManager with the correct orientation for the menu. */ - private _setKeyManager() { - this._keyManager = new FocusKeyManager(this._allItems) - .withWrap() - .withTypeAhead() - .withHomeAndEnd(); - - if (this._isHorizontal()) { - this._keyManager.withHorizontalOrientation(this._dir?.value || 'ltr'); - } else { - this._keyManager.withVerticalOrientation(); - } - } - /** * Set the PointerFocusTracker and ensure that when mouse focus changes the key manager is updated * with the latest menu item under mouse focus. */ private _subscribeToMouseManager() { this._ngZone.runOutsideAngular(() => { - this._pointerTracker = new PointerFocusTracker(this._allItems); - this._pointerTracker.entered + this.pointerTracker = new PointerFocusTracker(this.items); + this.pointerTracker.entered .pipe(takeUntil(this.closed)) - .subscribe(item => this._keyManager.setActiveItem(item)); + .subscribe(item => this.keyManager.setActiveItem(item)); }); } - /** Subscribe to the MenuStack close and empty observables. */ - private _subscribeToMenuStack() { - this._menuStack?.closed - .pipe(takeUntil(this.closed)) - .subscribe(item => this._closeOpenMenu(item)); - - this._menuStack?.emptied - .pipe(takeUntil(this.closed)) - .subscribe(event => this._toggleMenuFocus(event)); - } - - /** - * Close the open menu if the current active item opened the requested MenuStackItem. - * @param item the MenuStackItem requested to be closed. - */ - private _closeOpenMenu(menu: MenuStackItem | undefined) { - const keyManager = this._keyManager; - const trigger = this._openItem; - if (menu === trigger?.getMenuTrigger()?.getMenu()) { - trigger?.getMenuTrigger()?.closeMenu(); - // If the user has moused over a sibling item we want to focus the element under mouse focus - // not the trigger which previously opened the now closed menu. - if (trigger) { - keyManager.setActiveItem(this._pointerTracker?.activeElement || trigger); - } - } - } - /** Set focus the either the current, previous or next item based on the FocusNext event. */ private _toggleMenuFocus(event: FocusNext | undefined) { - const keyManager = this._keyManager; + const keyManager = this.keyManager; switch (event) { case FocusNext.nextItem: keyManager.setFocusOrigin('keyboard'); @@ -271,53 +195,17 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI } } - // TODO(andy9775): remove duplicate logic between menu an menu bar - /** - * Subscribe to the menu trigger's open events in order to track the trigger which opened the menu - * and stop tracking it when the menu is closed. - */ - private _subscribeToMenuOpen() { - const exitCondition = merge(this._allItems.changes, this.closed); - this._allItems.changes - .pipe( - startWith(this._allItems), - mergeMap((list: QueryList) => - list - .filter(item => item.hasMenu()) - .map(item => item.getMenuTrigger()!.opened.pipe(mapTo(item), takeUntil(exitCondition))), - ), - mergeAll(), - switchMap((item: CdkMenuItem) => { - this._openItem = item; - return item.getMenuTrigger()!.closed; - }), - takeUntil(this.closed), - ) - .subscribe(() => (this._openItem = undefined)); - } - - /** Return true if this menu has been configured in a horizontal orientation. */ - private _isHorizontal() { - return this.orientation === 'horizontal'; - } - /** * Return true if this menu is an inline menu. That is, it does not exist in a pop-up and is * always visible in the dom. */ _isInline() { - return !this._menuStack; + return !this.menuStack; } - override ngOnDestroy() { - super.ngOnDestroy(); - this._emitClosedEvent(); - this._pointerTracker?.destroy(); - } - - /** Emit and complete the closed event emitter */ - private _emitClosedEvent() { - this.closed.next(); - this.closed.complete(); + private _subscribeToMenuStackEmptied() { + this.menuStack?.emptied + .pipe(takeUntil(this.destroyed)) + .subscribe(event => this._toggleMenuFocus(event)); } } diff --git a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.css b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.css index accb6e4ab791..243cbb565616 100644 --- a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.css +++ b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.css @@ -3,6 +3,11 @@ flex-direction: column; } +.example-menu .cdk-menu-group { + display: flex; + flex-direction: column; +} + .example-menu-container + .example-menu-container { /* add some buffer for the opened menu */ margin-top: 140px; @@ -22,3 +27,13 @@ demo-custom-position { display: block; margin-top: 20px; } + +.example-menu-container .cdk-menu-item:focus { + position: relative; + z-index: 1; + outline: 2px solid; +} + +.cdk-menu-item[aria-checked='true'] { + box-shadow: inset 0 0 0 100px rgba(0, 0, 255, 0.5); +} diff --git a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.html b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.html index 23883019b0b0..fff27d01aefa 100644 --- a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.html +++ b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.html @@ -151,3 +151,42 @@

Custom Context Menu Position (Centered on Cursor)

+ +
+

Radio items

+ + + + + +
+ + + +
+
+ + +
+
+ + + +
+
+
+ + +
+ + + +
+
+ + + +
+
+
+
diff --git a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.ts b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.ts index 42aefee612bd..9db26ca91d0f 100644 --- a/src/dev-app/cdk-experimental-menu/cdk-menu-demo.ts +++ b/src/dev-app/cdk-experimental-menu/cdk-menu-demo.ts @@ -8,6 +8,7 @@ import {Component} from '@angular/core'; import {ConnectedPosition} from '@angular/cdk/overlay'; +import {CdkMenuItem} from '@angular/cdk-experimental/menu'; @Component({ templateUrl: 'cdk-menu-demo.html', @@ -17,4 +18,15 @@ export class CdkMenuDemo { customPosition = [ {originX: 'center', originY: 'center', overlayX: 'center', overlayY: 'center'}, ] as ConnectedPosition[]; + + size: string | undefined = 'Normal'; + color: string | undefined = 'Red'; + + onSizeChange(item: CdkMenuItem) { + this.size = item._elementRef.nativeElement.textContent?.trim(); + } + + onColorChange(item: CdkMenuItem) { + this.color = item._elementRef.nativeElement.textContent?.trim(); + } } diff --git a/src/material-experimental/menubar/menubar.spec.ts b/src/material-experimental/menubar/menubar.spec.ts index 1c19118599c2..84b088b3b61e 100644 --- a/src/material-experimental/menubar/menubar.spec.ts +++ b/src/material-experimental/menubar/menubar.spec.ts @@ -8,7 +8,6 @@ import {MatMenuBar} from './menubar'; describe('MatMenuBar', () => { let fixture: ComponentFixture; - let matMenubar: MatMenuBar; let nativeMatMenubar: HTMLElement; beforeEach(waitForAsync(() => { @@ -22,7 +21,6 @@ describe('MatMenuBar', () => { fixture = TestBed.createComponent(SimpleMatMenuBar); fixture.detectChanges(); - matMenubar = fixture.componentInstance.matMenubar; nativeMatMenubar = fixture.componentInstance.nativeMatMenubar.nativeElement; }); @@ -39,15 +37,6 @@ describe('MatMenuBar', () => { expect(nativeMatMenubar.getAttribute('tabindex')).toBe('0'); }); - it('should toggle aria-orientation attribute', () => { - expect(nativeMatMenubar.getAttribute('aria-orientation')).toBe('horizontal'); - - matMenubar.orientation = 'vertical'; - fixture.detectChanges(); - - expect(nativeMatMenubar.getAttribute('aria-orientation')).toBe('vertical'); - }); - it('should toggle focused items on left/right click', () => { nativeMatMenubar.focus(); diff --git a/src/material-experimental/menubar/menubar.ts b/src/material-experimental/menubar/menubar.ts index d904c187abbe..0ee5f1c5f33b 100644 --- a/src/material-experimental/menubar/menubar.ts +++ b/src/material-experimental/menubar/menubar.ts @@ -27,10 +27,7 @@ import { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { - 'role': 'menubar', - 'class': 'cdk-menu-bar mat-menubar', - 'tabindex': '0', - '[attr.aria-orientation]': 'orientation', + '[class.mat-menubar]': 'true', }, providers: [ {provide: CdkMenuGroup, useExisting: MatMenuBar},