diff --git a/src/cdk-experimental/menu/context-menu.spec.ts b/src/cdk-experimental/menu/context-menu-trigger.spec.ts similarity index 98% rename from src/cdk-experimental/menu/context-menu.spec.ts rename to src/cdk-experimental/menu/context-menu-trigger.spec.ts index 2e9660b5a4de..002b2a7a6a74 100644 --- a/src/cdk-experimental/menu/context-menu.spec.ts +++ b/src/cdk-experimental/menu/context-menu-trigger.spec.ts @@ -2,11 +2,11 @@ import {Component, ViewChild, ElementRef, Type, ViewChildren, QueryList} from '@ import {CdkMenuModule} from './menu-module'; import {TestBed, waitForAsync, ComponentFixture} from '@angular/core/testing'; import {CdkMenu} from './menu'; -import {CdkContextMenuTrigger} from './context-menu'; +import {CdkContextMenuTrigger} from './context-menu-trigger'; import {dispatchKeyboardEvent, dispatchMouseEvent} from '../../cdk/testing/private'; import {By} from '@angular/platform-browser'; import {CdkMenuItem} from './menu-item'; -import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {CdkMenuTrigger} from './menu-trigger'; import {CdkMenuBar} from './menu-bar'; import {LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes'; @@ -491,7 +491,7 @@ class NestedContextMenu { }) class ContextMenuWithSubmenu { @ViewChild(CdkContextMenuTrigger, {read: ElementRef}) context: ElementRef; - @ViewChild(CdkMenuItemTrigger, {read: ElementRef}) triggerNativeElement: ElementRef; + @ViewChild(CdkMenuTrigger, {read: ElementRef}) triggerNativeElement: ElementRef; @ViewChild('cut_menu', {read: CdkMenu}) cutMenu: CdkMenu; @ViewChild('copy_menu', {read: CdkMenu}) copyMenu: CdkMenu; @@ -548,7 +548,7 @@ class ContextMenuWithMenuBarAndInlineMenu { `, }) class MenuBarAndContextTriggerShareMenu { - @ViewChild(CdkMenuItemTrigger) menuBarTrigger: CdkMenuItemTrigger; + @ViewChild(CdkMenuTrigger) menuBarTrigger: CdkMenuTrigger; @ViewChild(CdkContextMenuTrigger) contextTrigger: CdkContextMenuTrigger; @ViewChildren(CdkMenu) menus: QueryList; } diff --git a/src/cdk-experimental/menu/context-menu.ts b/src/cdk-experimental/menu/context-menu-trigger.ts similarity index 68% rename from src/cdk-experimental/menu/context-menu.ts rename to src/cdk-experimental/menu/context-menu-trigger.ts index 41cebf91ccc8..1e30c8253def 100644 --- a/src/cdk-experimental/menu/context-menu.ts +++ b/src/cdk-experimental/menu/context-menu-trigger.ts @@ -23,17 +23,16 @@ import { OverlayConfig, STANDARD_DROPDOWN_BELOW_POSITIONS, } from '@angular/cdk/overlay'; -import {Portal, TemplatePortal} from '@angular/cdk/portal'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {merge, partition} from 'rxjs'; import {skip, takeUntil} from 'rxjs/operators'; import {MENU_STACK, MenuStack} from './menu-stack'; -import {isClickInsideMenuOverlay} from './menu-item-trigger'; -import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; +import {CdkMenuTriggerBase, MENU_TRIGGER} from './menu-trigger-base'; -// In cases where the first menu item in the context menu is a trigger the submenu opens on a -// hover event. We offset the context menu 2px by default to prevent this from occurring. +/** The preferred menu positions for the context menu. */ const CONTEXT_MENU_POSITIONS = STANDARD_DROPDOWN_BELOW_POSITIONS.map(position => { + // In cases where the first menu item in the context menu is a trigger the submenu opens on a + // hover event. We offset the context menu 2px by default to prevent this from occurring. const offsetX = position.overlayX === 'start' ? 2 : -2; const offsetY = position.overlayY === 'top' ? 2 : -2; return {...position, offsetX, offsetY}; @@ -47,7 +46,7 @@ export class ContextMenuTracker { /** * Close the previous open context menu and set the given one as being open. - * @param trigger the trigger for the currently open Context Menu. + * @param trigger The trigger for the currently open Context Menu. */ update(trigger: CdkContextMenuTrigger) { if (ContextMenuTracker._openContextMenuTrigger !== trigger) { @@ -57,29 +56,29 @@ export class ContextMenuTracker { } } -/** The coordinates of where the context menu should open. */ +/** The coordinates where the context menu should open. */ export type ContextMenuCoordinates = {x: number; y: number}; /** - * A directive which when placed on some element opens a the Menu it is bound to when a user - * right-clicks within that element. It is aware of nested Context Menus and the lowest level - * non-disabled context menu will trigger. + * A directive that opens a menu when a user right-clicks within its host element. + * It is aware of nested context menus and will trigger only the lowest level non-disabled context menu. */ @Directive({ selector: '[cdkContextMenuTriggerFor]', exportAs: 'cdkContextMenuTriggerFor', host: { + '[attr.data-cdk-menu-stack-id]': 'null', '(contextmenu)': '_openOnContextMenu($event)', }, - inputs: ['_menuTemplateRef: cdkContextMenuTriggerFor', 'menuPosition: cdkContextMenuPosition'], + inputs: ['menuTemplateRef: cdkContextMenuTriggerFor', 'menuPosition: cdkContextMenuPosition'], outputs: ['opened: cdkContextMenuOpened', 'closed: cdkContextMenuClosed'], providers: [ {provide: MENU_TRIGGER, useExisting: CdkContextMenuTrigger}, {provide: MENU_STACK, useClass: MenuStack}, ], }) -export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { - /** Whether the context menu should be disabled. */ +export class CdkContextMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { + /** Whether the context menu is disabled. */ @Input('cdkContextMenuDisabled') get disabled(): boolean { return this._disabled; @@ -90,15 +89,21 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { private _disabled = false; constructor( + /** The DI injector for this component */ injector: Injector, - protected readonly _viewContainerRef: ViewContainerRef, + /** The view container ref for this component */ + viewContainerRef: ViewContainerRef, + /** The CDK overlay service */ private readonly _overlay: Overlay, + /** The app's context menu tracking registry */ private readonly _contextMenuTracker: ContextMenuTracker, + /** The menu stack this menu is part of. */ @Inject(MENU_STACK) menuStack: MenuStack, + /** The directionality of the current page */ @Optional() private readonly _directionality?: Directionality, ) { - super(injector, menuStack); - this._setMenuStackListener(); + super(injector, viewContainerRef, menuStack); + this._setMenuStackCloseListener(); } /** @@ -109,42 +114,13 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { this._open(coordinates, false); } - private _open(coordinates: ContextMenuCoordinates, ignoreFirstOutsideAuxClick: boolean) { - if (this.disabled) { - return; - } else if (this.isOpen()) { - // since we're moving this menu we need to close any submenus first otherwise they end up - // disconnected from this one. - this.menuStack.closeSubMenuOf(this.childMenu!); - - ( - this._overlayRef!.getConfig().positionStrategy as FlexibleConnectedPositionStrategy - ).setOrigin(coordinates); - this._overlayRef!.updatePosition(); - } else { - this.opened.next(); - - if (this._overlayRef) { - ( - this._overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy - ).setOrigin(coordinates); - this._overlayRef.updatePosition(); - } else { - this._overlayRef = this._overlay.create(this._getOverlayConfig(coordinates)); - } - - this._overlayRef.attach(this._getMenuContent()); - this._subscribeToOutsideClicks(ignoreFirstOutsideAuxClick); - } - } - - /** Close the opened menu. */ + /** Close the currently opened context menu. */ close() { this.menuStack.closeAll(); } /** - * Open the context menu and close any previously open menus. + * Open the context menu and closes any previously open menus. * @param event the mouse event which opens the context menu. */ _openOnContextMenu(event: MouseEvent) { @@ -184,7 +160,7 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { } /** - * Build the position strategy for the overlay which specifies where to place the menu. + * Get the position strategy for the overlay which specifies where to place the menu. * @param coordinates the location to place the opened menu */ private _getOverlayPositionStrategy( @@ -196,30 +172,12 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { .withPositions(this.menuPosition ?? CONTEXT_MENU_POSITIONS); } - /** - * Get the portal to be attached to the overlay which contains the menu. Allows for the menu - * content to change dynamically and be reflected in the application. - */ - private _getMenuContent(): Portal { - const hasMenuContentChanged = this._menuTemplateRef !== this._menuPortal?.templateRef; - if (this._menuTemplateRef && (!this._menuPortal || hasMenuContentChanged)) { - this._menuPortal = new TemplatePortal( - this._menuTemplateRef, - this._viewContainerRef, - undefined, - this.getChildMenuInjector(), - ); - } - - return this._menuPortal; - } - /** Subscribe to the menu stack close events and close this menu when requested. */ - private _setMenuStackListener() { - this.menuStack.closed.pipe(takeUntil(this._destroyed)).subscribe(({item}) => { + private _setMenuStackCloseListener() { + this.menuStack.closed.pipe(takeUntil(this.destroyed)).subscribe(({item}) => { if (item === this.childMenu && this.isOpen()) { this.closed.next(); - this._overlayRef!.detach(); + this.overlayRef!.detach(); } }); } @@ -227,21 +185,57 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy { /** * Subscribe to the overlays outside pointer events stream and handle closing out the stack if a * click occurs outside the menus. + * @param ignoreFirstAuxClick Whether to ignore the first auxclick event outside the menu. */ private _subscribeToOutsideClicks(ignoreFirstAuxClick: boolean) { - if (this._overlayRef) { - let outsideClicks = this._overlayRef.outsidePointerEvents(); + if (this.overlayRef) { + let outsideClicks = this.overlayRef.outsidePointerEvents(); // If the menu was triggered by the `contextmenu` event, skip the first `auxclick` event // because it fires when the mouse is released on the same click that opened the menu. if (ignoreFirstAuxClick) { const [auxClicks, nonAuxClicks] = partition(outsideClicks, ({type}) => type === 'auxclick'); outsideClicks = merge(nonAuxClicks, auxClicks.pipe(skip(1))); } - outsideClicks.pipe(takeUntil(this._stopOutsideClicksListener)).subscribe(event => { - if (!isClickInsideMenuOverlay(event.target as Element)) { + outsideClicks.pipe(takeUntil(this.stopOutsideClicksListener)).subscribe(event => { + if (!this.isElementInsideMenuStack(event.target as Element)) { this.menuStack.closeAll(); } }); } } + + /** + * Open the attached menu at the specified location. + * @param coordinates where to open the context menu + * @param ignoreFirstOutsideAuxClick Whether to ignore the first auxclick outside the menu after opening. + */ + private _open(coordinates: ContextMenuCoordinates, ignoreFirstOutsideAuxClick: boolean) { + if (this.disabled) { + return; + } + if (this.isOpen()) { + // since we're moving this menu we need to close any submenus first otherwise they end up + // disconnected from this one. + this.menuStack.closeSubMenuOf(this.childMenu!); + + ( + this.overlayRef!.getConfig().positionStrategy as FlexibleConnectedPositionStrategy + ).setOrigin(coordinates); + this.overlayRef!.updatePosition(); + } else { + this.opened.next(); + + if (this.overlayRef) { + ( + this.overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy + ).setOrigin(coordinates); + this.overlayRef.updatePosition(); + } else { + this.overlayRef = this._overlay.create(this._getOverlayConfig(coordinates)); + } + + this.overlayRef.attach(this.getMenuContentPortal()); + this._subscribeToOutsideClicks(ignoreFirstOutsideAuxClick); + } + } } diff --git a/src/cdk-experimental/menu/menu-aim.ts b/src/cdk-experimental/menu/menu-aim.ts index 5c9a33d52915..2945050afff1 100644 --- a/src/cdk-experimental/menu/menu-aim.ts +++ b/src/cdk-experimental/menu/menu-aim.ts @@ -19,7 +19,11 @@ import {throwMissingPointerFocusTracker, throwMissingMenuReference} from './menu * order to determine if it may perform its close actions. */ export interface MenuAim { - /** Set the Menu and its PointerFocusTracker. */ + /** + * Set the Menu and its PointerFocusTracker. + * @param menu The menu that this menu aim service controls. + * @param pointerTracker The `PointerFocusTracker` for the given menu. + */ initialize(menu: Menu, pointerTracker: PointerFocusTracker): void; /** @@ -45,11 +49,9 @@ const NUM_POINTS = 5; */ const CLOSE_DELAY = 300; -/** - * An element which when hovered over may perform closing actions on the open submenu and - * potentially open its own menu. - */ +/** An element which when hovered over may open or close a menu. */ export interface Toggler { + /** Gets the open menu, or undefined if no menu is open. */ getMenu(): Menu | undefined; } @@ -72,7 +74,6 @@ type Point = {x: number; y: number}; * @param submenuPoints the submenu DOMRect points. * @param m the slope of the trajectory line. * @param b the y intercept of the trajectory line. - * * @return true if any point on the line falls within the submenu. */ function isWithinSubmenu(submenuPoints: DOMRect, m: number, b: number) { @@ -88,6 +89,7 @@ function isWithinSubmenu(submenuPoints: DOMRect, m: number, b: number) { ((bottom - b) / m >= left && (bottom - b) / m <= right) ); } + /** * TargetMenuAim predicts if a user is moving into a submenu. It calculates the * trajectory of the user's mouse movement in the current menu to determine if the @@ -115,9 +117,21 @@ export class TargetMenuAim implements MenuAim, OnDestroy { /** Emits when this service is destroyed. */ private readonly _destroyed: Subject = new Subject(); - constructor(private readonly _ngZone: NgZone) {} + constructor( + /** The Angular zone. */ + private readonly _ngZone: NgZone, + ) {} + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } - /** Set the Menu and its PointerFocusTracker. */ + /** + * Set the Menu and its PointerFocusTracker. + * @param menu The menu that this menu aim service controls. + * @param pointerTracker The `PointerFocusTracker` for the given menu. + */ initialize(menu: Menu, pointerTracker: PointerFocusTracker) { this._menu = menu; this._pointerTracker = pointerTracker; @@ -157,6 +171,8 @@ export class TargetMenuAim implements MenuAim, OnDestroy { * * The delayed toggle handler executes the `doToggle` callback after some period of time iff the * users mouse is on an item in the current menu. + * + * @param doToggle the function called when the user is not moving towards the submenu. */ private _startTimeout(doToggle: () => void) { // If the users mouse is moving towards a submenu we don't want to immediately resolve. @@ -197,9 +213,7 @@ export class TargetMenuAim implements MenuAim, OnDestroy { /** Get the bounding DOMRect for the open submenu. */ private _getSubmenuBounds(): DOMRect | undefined { - return this._pointerTracker?.previousElement - ?.getMenu() - ?._elementRef.nativeElement.getBoundingClientRect(); + return this._pointerTracker?.previousElement?.getMenu()?.nativeElement.getBoundingClientRect(); } /** @@ -220,7 +234,7 @@ export class TargetMenuAim implements MenuAim, OnDestroy { /** Subscribe to the root menus mouse move events and update the tracked mouse points. */ private _subscribeToMouseMoves() { this._ngZone.runOutsideAngular(() => { - fromEvent(this._menu._elementRef.nativeElement, 'mousemove') + fromEvent(this._menu.nativeElement, 'mousemove') .pipe( filter((_: MouseEvent, index: number) => index % MOUSE_MOVE_SAMPLE_FREQUENCY === 0), takeUntil(this._destroyed), @@ -233,15 +247,10 @@ export class TargetMenuAim implements MenuAim, OnDestroy { }); }); } - - ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); - } } /** - * CdkTargetMenuAim is a provider for the TargetMenuAim service. It should be added to an + * CdkTargetMenuAim is a provider for the TargetMenuAim service. It can be added to an * element with either the `cdkMenu` or `cdkMenuBar` directive and child menu items. */ @Directive({ diff --git a/src/cdk-experimental/menu/menu-bar.spec.ts b/src/cdk-experimental/menu/menu-bar.spec.ts index 56b72dc4e987..631616860ce2 100644 --- a/src/cdk-experimental/menu/menu-bar.spec.ts +++ b/src/cdk-experimental/menu/menu-bar.spec.ts @@ -36,7 +36,7 @@ import {CdkMenuItemRadio} from './menu-item-radio'; import {CdkMenu} from './menu'; import {CdkMenuItem} from './menu-item'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; -import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {CdkMenuTrigger} from './menu-trigger'; import {CdkMenuGroup} from './menu-group'; describe('MenuBar', () => { @@ -759,7 +759,7 @@ describe('MenuBar', () => { let fixture: ComponentFixture; let popoutMenus: CdkMenu[]; - let triggers: CdkMenuItemTrigger[]; + let triggers: CdkMenuTrigger[]; let nativeInlineMenuItem: HTMLElement; /** open the attached menu. */ @@ -770,7 +770,7 @@ describe('MenuBar', () => { /** set the menus and triggers arrays. */ function grabElementsForTesting() { - popoutMenus = fixture.componentInstance.menus.toArray().filter(el => !el._isInline); + popoutMenus = fixture.componentInstance.menus.toArray().filter(el => !el.isInline); triggers = fixture.componentInstance.triggers.toArray(); nativeInlineMenuItem = fixture.componentInstance.nativeInlineMenuItem.nativeElement; } @@ -1244,7 +1244,7 @@ class MenuWithRadioButtons { class MenuBarWithMenusAndInlineMenu { @ViewChildren(CdkMenu) menus: QueryList; - @ViewChildren(CdkMenuItemTrigger) triggers: QueryList; + @ViewChildren(CdkMenuTrigger) triggers: QueryList; @ViewChild('inline_menu_item') nativeInlineMenuItem: ElementRef; } diff --git a/src/cdk-experimental/menu/menu-bar.ts b/src/cdk-experimental/menu/menu-bar.ts index ed11a2beb5e7..9fc0744db416 100644 --- a/src/cdk-experimental/menu/menu-bar.ts +++ b/src/cdk-experimental/menu/menu-bar.ts @@ -12,17 +12,23 @@ import { ElementRef, Inject, NgZone, - OnDestroy, Optional, Self, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; -import {DOWN_ARROW, ESCAPE, LEFT_ARROW, RIGHT_ARROW, TAB, UP_ARROW} from '@angular/cdk/keycodes'; +import { + DOWN_ARROW, + ESCAPE, + hasModifierKey, + LEFT_ARROW, + RIGHT_ARROW, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; import {takeUntil} from 'rxjs/operators'; import {CdkMenuGroup} from './menu-group'; 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 {CdkMenuBase} from './menu-base'; @@ -43,42 +49,39 @@ import {CdkMenuBase} from './menu-base'; providers: [ {provide: CdkMenuGroup, useExisting: CdkMenuBar}, {provide: CDK_MENU, useExisting: CdkMenuBar}, - {provide: MENU_STACK, useFactory: () => MenuStack.inline()}, + {provide: MENU_STACK, useFactory: () => MenuStack.inline('horizontal')}, ], }) -export class CdkMenuBar extends CdkMenuBase implements AfterContentInit, OnDestroy { - override readonly orientation: 'horizontal' | 'vertical' = 'horizontal'; - - override menuStack: MenuStack; +export class CdkMenuBar extends CdkMenuBase implements AfterContentInit { + /** The direction items in the menu flow. */ + override readonly orientation = 'horizontal'; - override _isInline = true; + /** Whether the menu is displayed inline (i.e. always present vs a conditional popup that the user triggers with a trigger element). */ + override readonly isInline = true; constructor( - private readonly _ngZone: NgZone, + /** The host element. */ elementRef: ElementRef, + /** The Angular zone. */ + ngZone: NgZone, + /** The menu stack this menu is part of. */ @Inject(MENU_STACK) menuStack: MenuStack, - @Self() @Optional() @Inject(MENU_AIM) private readonly _menuAim?: MenuAim, + /** The menu aim service used by this menu. */ + @Self() @Optional() @Inject(MENU_AIM) menuAim?: MenuAim, + /** The directionality of the page. */ @Optional() dir?: Directionality, ) { - super(elementRef, menuStack, dir); + super(elementRef, ngZone, menuStack, menuAim, dir); } override ngAfterContentInit() { super.ngAfterContentInit(); this._subscribeToMenuStackEmptied(); - this._subscribeToMouseManager(); - this._menuAim?.initialize(this, this.pointerTracker!); - } - - override ngOnDestroy() { - super.ngOnDestroy(); - this.pointerTracker?.destroy(); } /** - * Handle keyboard events, specifically changing the focused element and/or toggling the active - * items menu. - * @param event the KeyboardEvent to handle. + * Handle keyboard events for the Menu. + * @param event The keyboard event to be handled. */ _handleKeyEvent(event: KeyboardEvent) { const keyManager = this.keyManager; @@ -87,34 +90,37 @@ export class CdkMenuBar extends CdkMenuBase implements AfterContentInit, OnDestr case DOWN_ARROW: case LEFT_ARROW: case RIGHT_ARROW: - const horizontalArrows = event.keyCode === LEFT_ARROW || event.keyCode === RIGHT_ARROW; - // For a horizontal menu if the left/right keys were clicked, or a vertical menu if the - // 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) - ) { - event.preventDefault(); - - const prevIsOpen = keyManager.activeItem?.isMenuOpen(); - keyManager.activeItem?.getMenuTrigger()?.close(); - - keyManager.setFocusOrigin('keyboard'); - keyManager.onKeydown(event); - if (prevIsOpen) { - keyManager.activeItem?.getMenuTrigger()?.open(); + if (!hasModifierKey(event)) { + const horizontalArrows = event.keyCode === LEFT_ARROW || event.keyCode === RIGHT_ARROW; + // For a horizontal menu if the left/right keys were clicked, or a vertical menu if the + // up/down keys were clicked: if the current menu is open, close it then focus and open the + // next menu. + if (horizontalArrows) { + event.preventDefault(); + + const prevIsOpen = keyManager.activeItem?.isMenuOpen(); + keyManager.activeItem?.getMenuTrigger()?.close(); + + keyManager.setFocusOrigin('keyboard'); + keyManager.onKeydown(event); + if (prevIsOpen) { + keyManager.activeItem?.getMenuTrigger()?.open(); + } } } break; case ESCAPE: - event.preventDefault(); - keyManager.activeItem?.getMenuTrigger()?.close(); + if (!hasModifierKey(event)) { + event.preventDefault(); + keyManager.activeItem?.getMenuTrigger()?.close(); + } break; case TAB: - keyManager.activeItem?.getMenuTrigger()?.close(); + if (!hasModifierKey(event, 'altKey', 'metaKey', 'ctrlKey')) { + keyManager.activeItem?.getMenuTrigger()?.close(); + } break; default: @@ -122,23 +128,14 @@ export class CdkMenuBar extends CdkMenuBase implements AfterContentInit, OnDestr } } - /** - * 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.items); - }); - } - /** * Set focus to either the current, previous or next item based on the FocusNext event, then * open the previous or next item. + * @param focusNext The element to focus. */ - private _toggleOpenMenu(event: FocusNext | undefined) { + private _toggleOpenMenu(focusNext: FocusNext | undefined) { const keyManager = this.keyManager; - switch (event) { + switch (focusNext) { case FocusNext.nextItem: keyManager.setFocusOrigin('keyboard'); keyManager.setNextItemActive(); @@ -160,6 +157,7 @@ export class CdkMenuBar extends CdkMenuBase implements AfterContentInit, OnDestr } } + /** Subscribe to the MenuStack emptied events. */ private _subscribeToMenuStackEmptied() { this.menuStack?.emptied .pipe(takeUntil(this.destroyed)) diff --git a/src/cdk-experimental/menu/menu-base.ts b/src/cdk-experimental/menu/menu-base.ts index 0c9350367a43..72a4fb9cfa78 100644 --- a/src/cdk-experimental/menu/menu-base.ts +++ b/src/cdk-experimental/menu/menu-base.ts @@ -14,9 +14,11 @@ import { ElementRef, Inject, Input, + NgZone, OnDestroy, Optional, QueryList, + Self, } from '@angular/core'; import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; import {CdkMenuItem} from './menu-item'; @@ -26,14 +28,23 @@ import {mapTo, mergeAll, mergeMap, startWith, switchMap, takeUntil} from 'rxjs/o import {MENU_STACK, MenuStack, MenuStackItem} from './menu-stack'; import {Menu} from './menu-interface'; import {PointerFocusTracker} from './pointer-focus-tracker'; +import {MENU_AIM, MenuAim} from './menu-aim'; +/** Counter used to create unique IDs for menus. */ let nextId = 0; +/** + * Abstract directive that implements shared logic common to all menus. + * This class can be extended to create custom menu types. + */ @Directive({ host: { - '[tabindex]': '_isInline ? (_hasFocus ? -1 : 0) : null', + 'role': 'menu', + 'class': '', // reset the css class added by the super-class + '[tabindex]': '_getTabIndex()', '[id]': 'id', '[attr.aria-orientation]': 'orientation', + '[attr.data-cdk-menu-stack-id]': 'menuStack.id', '(focus)': 'focusFirstItem()', '(focusin)': 'menuStack.setHasFocus(true)', '(focusout)': 'menuStack.setHasFocus(false)', @@ -43,21 +54,21 @@ export abstract class CdkMenuBase extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { + /** The id of the menu's host element. */ @Input() id = `cdk-menu-${nextId++}`; - /** - * Sets the aria-orientation attribute and determines where menus will be opened. - * Does not affect styling/layout. - */ - orientation: 'horizontal' | 'vertical' = 'vertical'; + /** All child MenuItem elements nested in this Menu. */ + @ContentChildren(CdkMenuItem, {descendants: true}) + readonly items: QueryList; - _isInline = false; + /** The direction items in the menu flow. */ + orientation: 'horizontal' | 'vertical' = 'vertical'; - _hasFocus = false; + /** Whether the menu is displayed inline (i.e. always present vs a conditional popup that the user triggers with a trigger element). */ + isInline = false; - /** All child MenuItem elements nested in this Menu. */ - @ContentChildren(CdkMenuItem, {descendants: true}) - protected readonly items: QueryList; + /** The menu's native DOM host element. */ + readonly nativeElement: HTMLElement; /** Handles keyboard events for the menu. */ protected keyManager: FocusKeyManager; @@ -66,61 +77,81 @@ export abstract class CdkMenuBase protected readonly destroyed: Subject = new Subject(); /** The Menu Item which triggered the open submenu. */ - protected openItem?: CdkMenuItem; + protected triggerItem?: CdkMenuItem; - /** Manages items under mouse focus */ + /** Tracks the users mouse movements over the menu. */ protected pointerTracker?: PointerFocusTracker; + /** Whether this menu's menu stack has focus. */ + private _menuStackHasFocus = false; + protected constructor( - readonly _elementRef: ElementRef, + /** The host element. */ + elementRef: ElementRef, + /** The Angular zone. */ + protected ngZone: NgZone, + /** The stack of menus this menu belongs to. */ @Inject(MENU_STACK) readonly menuStack: MenuStack, + /** The menu aim service used by this menu. */ + @Self() @Optional() @Inject(MENU_AIM) protected readonly menuAim?: MenuAim, + /** The directionality of the current page. */ @Optional() protected readonly dir?: Directionality, ) { super(); + this.nativeElement = elementRef.nativeElement; } ngAfterContentInit() { + if (!this.isInline) { + this.menuStack.push(this); + } this._setKeyManager(); - this._subscribeToHasFocus(); + this._subscribeToMenuStackHasFocus(); this._subscribeToMenuOpen(); this._subscribeToMenuStackClosed(); + this._setUpPointerTracker(); } ngOnDestroy() { this.destroyed.next(); this.destroyed.complete(); + this.pointerTracker?.destroy(); } - /** Place focus on the first MenuItem in the menu and set the focus origin. */ + /** + * Place focus on the first MenuItem in the menu and set the focus origin. + * @param focusOrigin The origin input mode of the focus event. + */ 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. */ + /** + * Place focus on the last MenuItem in the menu and set the focus origin. + * @param focusOrigin The origin input mode of the focus event. + */ 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; + /** Gets the tabindex for this menu. */ + _getTabIndex() { + const tabindexIfInline = this._menuStackHasFocus ? -1 : 0; + return this.isInline ? tabindexIfInline : null; } /** * Close the open menu if the current active item opened the requested MenuStackItem. - * @param menu the MenuStackItem requested to be closed. - * @param focusParentTrigger whether to focus the parent trigger after closing the menu. + * @param menu The menu requested to be closed. + * @param options Options to configure the behavior on close. + * - `focusParentTrigger` Whether to focus the parent trigger after closing the menu. */ - protected closeOpenMenu(menu?: MenuStackItem, focusParentTrigger?: boolean) { + protected closeOpenMenu(menu: MenuStackItem, options?: {focusParentTrigger?: boolean}) { + const {focusParentTrigger} = {...options}; const keyManager = this.keyManager; - const trigger = this.openItem; + const trigger = this.triggerItem; if (menu === trigger?.getMenuTrigger()?.getMenu()) { trigger?.getMenuTrigger()?.close(); // If the user has moused over a sibling item we want to focus the element under mouse focus @@ -139,7 +170,7 @@ export abstract class CdkMenuBase private _setKeyManager() { this.keyManager = new FocusKeyManager(this.items).withWrap().withTypeAhead().withHomeAndEnd(); - if (this.isHorizontal()) { + if (this.orientation === 'horizontal') { this.keyManager.withHorizontalOrientation(this.dir?.value || 'ltr'); } else { this.keyManager.withVerticalOrientation(); @@ -157,31 +188,45 @@ export abstract class CdkMenuBase startWith(this.items), mergeMap((list: QueryList) => list - .filter(item => item.hasMenu()) + .filter(item => item.hasMenu) .map(item => item.getMenuTrigger()!.opened.pipe(mapTo(item), takeUntil(exitCondition))), ), mergeAll(), switchMap((item: CdkMenuItem) => { - this.openItem = item; + this.triggerItem = item; return item.getMenuTrigger()!.closed; }), takeUntil(this.destroyed), ) - .subscribe(() => (this.openItem = undefined)); + .subscribe(() => (this.triggerItem = undefined)); } - /** Subscribe to the MenuStack close and empty observables. */ + /** Subscribe to the MenuStack close events. */ private _subscribeToMenuStackClosed() { this.menuStack.closed .pipe(takeUntil(this.destroyed)) - .subscribe(({item, focusParentTrigger}) => this.closeOpenMenu(item, focusParentTrigger)); + .subscribe(({item, focusParentTrigger}) => this.closeOpenMenu(item, {focusParentTrigger})); } - private _subscribeToHasFocus() { - if (this._isInline) { + /** Subscribe to the MenuStack hasFocus events. */ + private _subscribeToMenuStackHasFocus() { + if (this.isInline) { this.menuStack.hasFocus.pipe(takeUntil(this.destroyed)).subscribe(hasFocus => { - this._hasFocus = hasFocus; + this._menuStackHasFocus = hasFocus; + }); + } + } + + /** + * Set the PointerFocusTracker and ensure that when mouse focus changes the key manager is updated + * with the latest menu item under mouse focus. + */ + private _setUpPointerTracker() { + if (this.menuAim) { + this.ngZone.runOutsideAngular(() => { + this.pointerTracker = new PointerFocusTracker(this.items); }); + this.menuAim.initialize(this, this.pointerTracker!); } } } diff --git a/src/cdk-experimental/menu/menu-group.ts b/src/cdk-experimental/menu/menu-group.ts index dc5be809e003..78f19f58ce07 100644 --- a/src/cdk-experimental/menu/menu-group.ts +++ b/src/cdk-experimental/menu/menu-group.ts @@ -10,8 +10,7 @@ import {Directive} from '@angular/core'; import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; /** - * Directive which acts as a grouping container for `CdkMenuItem` instances with - * `role="menuitemradio"`, similar to a `role="radiogroup"` element. + * A grouping container for `CdkMenuItemRadio` instances, similar to a `role="radiogroup"` element. */ @Directive({ selector: '[cdkMenuGroup]', diff --git a/src/cdk-experimental/menu/menu-interface.ts b/src/cdk-experimental/menu/menu-interface.ts index e1bf115635fb..3687b4c3b5f0 100644 --- a/src/cdk-experimental/menu/menu-interface.ts +++ b/src/cdk-experimental/menu/menu-interface.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {InjectionToken, ElementRef} from '@angular/core'; +import {InjectionToken} from '@angular/core'; import {MenuStackItem} from './menu-stack'; import {FocusOrigin} from '@angular/cdk/a11y'; @@ -15,12 +15,13 @@ export const CDK_MENU = new InjectionToken('cdk-menu'); /** Interface which specifies Menu operations and used to break circular dependency issues */ export interface Menu extends MenuStackItem { + /** The id of the menu's host element. */ id: string; - /** The element the Menu directive is placed on. */ - _elementRef: ElementRef; + /** The menu's native DOM host element. */ + nativeElement: HTMLElement; - /** The orientation of the menu */ + /** The direction items in the menu flow. */ readonly orientation: 'horizontal' | 'vertical'; /** Place focus on the first MenuItem in the menu. */ diff --git a/src/cdk-experimental/menu/menu-item-checkbox.spec.ts b/src/cdk-experimental/menu/menu-item-checkbox.spec.ts index eb3714bec710..d80f3917f0c9 100644 --- a/src/cdk-experimental/menu/menu-item-checkbox.spec.ts +++ b/src/cdk-experimental/menu/menu-item-checkbox.spec.ts @@ -49,7 +49,7 @@ describe('MenuItemCheckbox', () => { }); it('should not have a menu', () => { - expect(checkbox.hasMenu()).toBeFalse(); + expect(checkbox.hasMenu).toBeFalse(); }); it('should toggle the aria checked attribute', () => { diff --git a/src/cdk-experimental/menu/menu-item-checkbox.ts b/src/cdk-experimental/menu/menu-item-checkbox.ts index 9b177edf6939..5edd0aaf9a35 100644 --- a/src/cdk-experimental/menu/menu-item-checkbox.ts +++ b/src/cdk-experimental/menu/menu-item-checkbox.ts @@ -26,7 +26,11 @@ import {CdkMenuItem} from './menu-item'; ], }) export class CdkMenuItemCheckbox extends CdkMenuItemSelectable { - /** Toggle the checked state of the checkbox. */ + /** + * Toggle the checked state of the checkbox. + * @param options Options the configure how the item is triggered + * - keepOpen: specifies that the menu should be kept open after triggering the item. + */ override trigger(options?: {keepOpen: boolean}) { super.trigger(options); diff --git a/src/cdk-experimental/menu/menu-item-radio.spec.ts b/src/cdk-experimental/menu/menu-item-radio.spec.ts index 3583b257fe14..bc39e6c83c8b 100644 --- a/src/cdk-experimental/menu/menu-item-radio.spec.ts +++ b/src/cdk-experimental/menu/menu-item-radio.spec.ts @@ -62,7 +62,7 @@ describe('MenuItemRadio', () => { }); it('should not have a menu', () => { - expect(radioButton.hasMenu()).toBeFalse(); + expect(radioButton.hasMenu).toBeFalse(); }); it('should not toggle checked state when disabled', () => { diff --git a/src/cdk-experimental/menu/menu-item-radio.ts b/src/cdk-experimental/menu/menu-item-radio.ts index 5a6531f56870..27c12de225c7 100644 --- a/src/cdk-experimental/menu/menu-item-radio.ts +++ b/src/cdk-experimental/menu/menu-item-radio.ts @@ -11,7 +11,7 @@ import {Directive, ElementRef, Inject, NgZone, OnDestroy, Optional, Self} from ' import {Directionality} from '@angular/cdk/bidi'; import {CdkMenuItemSelectable} from './menu-item-selectable'; import {CdkMenuItem} from './menu-item'; -import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {CdkMenuTrigger} from './menu-trigger'; import {CDK_MENU, Menu} from './menu-interface'; import {MENU_AIM, MenuAim} from './menu-aim'; import {MENU_STACK, MenuStack} from './menu-stack'; @@ -36,37 +36,47 @@ let nextId = 0; ], }) export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy { + /** An ID to identify this radio item to the `UniqueSelectionDisptcher`. */ private _id = `${nextId++}`; /** Function to unregister the selection dispatcher */ private _removeDispatcherListener: () => void; constructor( - private readonly _selectionDispatcher: UniqueSelectionDispatcher, + /** The host element for this radio item. */ element: ElementRef, + /** The Angular zone. */ ngZone: NgZone, + /** The unique selection dispatcher for this radio's `CdkMenuGroup`. */ + private readonly _selectionDispatcher: UniqueSelectionDispatcher, + /** The menu stack this item belongs to. */ @Inject(MENU_STACK) menuStack: MenuStack, + /** The parent menu for this item. */ @Optional() @Inject(CDK_MENU) parentMenu?: Menu, + /** The menu aim used for this item. */ @Optional() @Inject(MENU_AIM) menuAim?: MenuAim, + /** The directionality of the page. */ @Optional() dir?: Directionality, /** Reference to the CdkMenuItemTrigger directive if one is added to the same element */ - // `CdkMenuItemRadio` is commonly used in combination with a `CdkMenuItemTrigger`. // tslint:disable-next-line: lightweight-tokens - @Self() @Optional() menuTrigger?: CdkMenuItemTrigger, + @Self() @Optional() menuTrigger?: CdkMenuTrigger, ) { super(element, ngZone, menuStack, parentMenu, menuAim, dir, menuTrigger); this._registerDispatcherListener(); } - /** Configure the unique selection dispatcher listener in order to toggle the checked state */ - private _registerDispatcherListener() { - this._removeDispatcherListener = this._selectionDispatcher.listen((id: string) => { - this.checked = this._id === id; - }); + override ngOnDestroy() { + super.ngOnDestroy(); + + this._removeDispatcherListener(); } - /** Toggles the checked state of the radio-button. */ + /** + * Toggles the checked state of the radio-button. + * @param options Options the configure how the item is triggered + * - keepOpen: specifies that the menu should be kept open after triggering the item. + */ override trigger(options?: {keepOpen: boolean}) { super.trigger(options); @@ -75,9 +85,10 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy } } - override ngOnDestroy() { - super.ngOnDestroy(); - - this._removeDispatcherListener(); + /** Configure the unique selection dispatcher listener in order to toggle the checked state */ + private _registerDispatcherListener() { + this._removeDispatcherListener = this._selectionDispatcher.listen((id: string) => { + this.checked = this._id === id; + }); } } diff --git a/src/cdk-experimental/menu/menu-item-selectable.ts b/src/cdk-experimental/menu/menu-item-selectable.ts index 002434819950..117c7b700612 100644 --- a/src/cdk-experimental/menu/menu-item-selectable.ts +++ b/src/cdk-experimental/menu/menu-item-selectable.ts @@ -10,10 +10,7 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {Directive, Input} from '@angular/core'; import {CdkMenuItem} from './menu-item'; -/** - * Base class providing checked state for MenuItems along with outputting a clicked event when the - * element is triggered. It provides functionality for selectable elements. - */ +/** Base class providing checked state for selectable MenuItems. */ @Directive({ host: { '[attr.aria-checked]': '!!checked', @@ -31,5 +28,6 @@ export abstract class CdkMenuItemSelectable extends CdkMenuItem { } private _checked = false; + /** Whether the item should close the menu if triggered by the spacebar. */ protected override closeOnSpacebarTrigger = false; } diff --git a/src/cdk-experimental/menu/menu-item-trigger.ts b/src/cdk-experimental/menu/menu-item-trigger.ts deleted file mode 100644 index 7df648e5e7f4..000000000000 --- a/src/cdk-experimental/menu/menu-item-trigger.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * @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 { - Directive, - ElementRef, - Inject, - Injector, - NgZone, - OnDestroy, - Optional, - ViewContainerRef, -} from '@angular/core'; -import {Directionality} from '@angular/cdk/bidi'; -import {TemplatePortal} from '@angular/cdk/portal'; -import { - ConnectedPosition, - FlexibleConnectedPositionStrategy, - Overlay, - OverlayConfig, - STANDARD_DROPDOWN_ADJACENT_POSITIONS, - STANDARD_DROPDOWN_BELOW_POSITIONS, -} from '@angular/cdk/overlay'; -import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; -import {fromEvent} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; -import {CDK_MENU, Menu} from './menu-interface'; -import {MENU_STACK, MenuStack, PARENT_OR_NEW_MENU_STACK_PROVIDER} from './menu-stack'; -import {MENU_AIM, MenuAim} from './menu-aim'; -import {MENU_TRIGGER, MenuTrigger} from './menu-trigger'; - -/** - * Whether the target element is a menu item to be ignored by the overlay background click handler. - */ -export function isClickInsideMenuOverlay(target: Element): boolean { - while (target?.parentElement) { - const isOpenTrigger = - target.getAttribute('aria-expanded') === 'true' && - target.classList.contains('cdk-menu-trigger'); - const isOverlayMenu = - target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline'); - - if (isOpenTrigger || isOverlayMenu) { - return true; - } - target = target.parentElement; - } - return false; -} - -/** - * A directive to be combined with CdkMenuItem which opens the Menu it is bound to. If the - * element is in a top level MenuBar it will open the menu on click, or if a sibling is already - * opened it will open on hover. If it is inside of a Menu it will open the attached Submenu on - * hover regardless of its sibling state. - * - * The directive must be placed along with the `cdkMenuItem` directive in order to enable full - * functionality. - */ -@Directive({ - selector: '[cdkMenuTriggerFor]', - exportAs: 'cdkMenuTriggerFor', - host: { - 'class': 'cdk-menu-trigger', - 'aria-haspopup': 'menu', - '[attr.aria-expanded]': 'isOpen()', - '(focusin)': '_setHasFocus(true)', - '(focusout)': '_setHasFocus(false)', - '(keydown)': '_toggleOnKeydown($event)', - '(click)': 'toggle()', - }, - inputs: ['_menuTemplateRef: cdkMenuTriggerFor', 'menuPosition: cdkMenuPosition'], - outputs: ['opened: cdkMenuOpened', 'closed: cdkMenuClosed'], - providers: [ - {provide: MENU_TRIGGER, useExisting: CdkMenuItemTrigger}, - PARENT_OR_NEW_MENU_STACK_PROVIDER, - ], -}) -export class CdkMenuItemTrigger extends MenuTrigger implements OnDestroy { - constructor( - injector: Injector, - private readonly _elementRef: ElementRef, - protected readonly _viewContainerRef: ViewContainerRef, - private readonly _overlay: Overlay, - private readonly _ngZone: NgZone, - @Inject(MENU_STACK) menuStack: MenuStack, - @Optional() @Inject(CDK_MENU) private readonly _parentMenu?: Menu, - @Optional() @Inject(MENU_AIM) private readonly _menuAim?: MenuAim, - @Optional() private readonly _directionality?: Directionality, - ) { - super(injector, menuStack); - this._registerCloseHandler(); - this._subscribeToMenuStackClosed(); - this._subscribeToMouseEnter(); - this._subscribeToHasFocus(); - } - - /** Open/close the attached menu if the trigger has been configured with one */ - toggle() { - this.isOpen() ? this.close() : this.open(); - } - - /** Open the attached menu. */ - open() { - if (!this.isOpen()) { - this.opened.next(); - - this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig()); - this._overlayRef.attach(this._getPortal()); - this._subscribeToOutsideClicks(); - } - } - - /** Close the opened menu. */ - close() { - if (this.isOpen()) { - this.closed.next(); - - this._overlayRef!.detach(); - } - this._closeSiblingTriggers(); - } - - /** - * Get a reference to the rendered Menu if the Menu is open and it is visible in the DOM. - * @return the menu if it is open, otherwise undefined. - */ - getMenu(): Menu | undefined { - return this.childMenu; - } - - /** - * Subscribe to the mouseenter events and close any sibling menu items if this element is moused - * into. - */ - private _subscribeToMouseEnter() { - // Closes any sibling menu items and opens the menu associated with this trigger. - const toggleMenus = () => - this._ngZone.run(() => { - this._closeSiblingTriggers(); - this.open(); - }); - - this._ngZone.runOutsideAngular(() => { - fromEvent(this._elementRef.nativeElement, 'mouseenter') - .pipe( - filter(() => !this.menuStack.isEmpty() && !this.isOpen()), - takeUntil(this._destroyed), - ) - .subscribe(() => { - if (this._menuAim) { - this._menuAim.toggle(toggleMenus); - } else { - toggleMenus(); - } - }); - }); - } - - /** - * Handles keyboard events for the menu item, specifically opening/closing the attached menu and - * focusing the appropriate submenu item. - * @param event the keyboard event to handle - */ - _toggleOnKeydown(event: KeyboardEvent) { - const keyCode = event.keyCode; - switch (keyCode) { - case SPACE: - case ENTER: - event.preventDefault(); - this.toggle(); - this.childMenu?.focusFirstItem('keyboard'); - break; - - case RIGHT_ARROW: - if (this._parentMenu && this._isParentVertical() && this._directionality?.value !== 'rtl') { - event.preventDefault(); - this.open(); - this.childMenu?.focusFirstItem('keyboard'); - } - break; - - case LEFT_ARROW: - if (this._parentMenu && this._isParentVertical() && this._directionality?.value === 'rtl') { - event.preventDefault(); - this.open(); - this.childMenu?.focusFirstItem('keyboard'); - } - break; - - case DOWN_ARROW: - case UP_ARROW: - if (!this._isParentVertical()) { - event.preventDefault(); - this.open(); - keyCode === DOWN_ARROW - ? this.childMenu?.focusFirstItem('keyboard') - : this.childMenu?.focusLastItem('keyboard'); - } - break; - } - } - - /** Close out any sibling menu trigger menus. */ - private _closeSiblingTriggers() { - if (this._parentMenu) { - // If nothing was removed from the stack and the last element is not the parent item - // that means that the parent menu is a menu bar since we don't put the menu bar on the - // stack - const isParentMenuBar = - !this.menuStack.closeSubMenuOf(this._parentMenu) && - this.menuStack.peek() !== this._parentMenu; - - if (isParentMenuBar) { - this.menuStack.closeAll(); - } - } else { - this.menuStack.closeAll(); - } - } - - /** Get the configuration object used to create the overlay */ - private _getOverlayConfig() { - return new OverlayConfig({ - positionStrategy: this._getOverlayPositionStrategy(), - scrollStrategy: this._overlay.scrollStrategies.block(), - direction: this._directionality, - }); - } - - /** Build the position strategy for the overlay which specifies where to place the menu */ - private _getOverlayPositionStrategy(): FlexibleConnectedPositionStrategy { - return this._overlay - .position() - .flexibleConnectedTo(this._elementRef) - .withPositions(this._getOverlayPositions()); - } - - /** Determine and return where to position the opened menu relative to the menu item */ - private _getOverlayPositions(): ConnectedPosition[] { - return ( - this.menuPosition ?? - (!this._parentMenu || this._parentMenu.orientation === 'horizontal' - ? STANDARD_DROPDOWN_BELOW_POSITIONS - : STANDARD_DROPDOWN_ADJACENT_POSITIONS) - ); - } - - /** - * Get the portal to be attached to the overlay which contains the menu. Allows for the menu - * content to change dynamically and be reflected in the application. - */ - private _getPortal() { - const hasMenuContentChanged = this._menuTemplateRef !== this._menuPortal?.templateRef; - if (this._menuTemplateRef && (!this._menuPortal || hasMenuContentChanged)) { - this._menuPortal = new TemplatePortal( - this._menuTemplateRef, - this._viewContainerRef, - undefined, - this.getChildMenuInjector(), - ); - } - - return this._menuPortal; - } - - /** - * @return true if if the enclosing parent menu is configured in a vertical orientation. - */ - private _isParentVertical() { - return this._parentMenu?.orientation === 'vertical'; - } - - /** - * Subscribe to the MenuStack close events if this is a standalone trigger and close out the menu - * this triggers when requested. - */ - private _registerCloseHandler() { - if (!this._parentMenu) { - this.menuStack.closed.pipe(takeUntil(this._destroyed)).subscribe(({item}) => { - if (item === this.childMenu) { - this.close(); - } - }); - } - } - - /** - * Subscribe to the overlays outside pointer events stream and handle closing out the stack if a - * click occurs outside the menus. - */ - private _subscribeToOutsideClicks() { - if (this._overlayRef) { - this._overlayRef - .outsidePointerEvents() - .pipe(takeUntil(this._stopOutsideClicksListener)) - .subscribe(event => { - if (!isClickInsideMenuOverlay(event.target as Element)) { - this.menuStack.closeAll(); - } - }); - } - } - - private _subscribeToHasFocus() { - if (!this._parentMenu) { - this.menuStack.hasFocus.pipe(takeUntil(this._destroyed)).subscribe(hasFocus => { - if (!hasFocus) { - this.menuStack.closeAll(); - } - }); - } - } - - _setHasFocus(hasFocus: boolean) { - if (!this._parentMenu) { - this.menuStack.setHasFocus(hasFocus); - } - } - - private _subscribeToMenuStackClosed() { - if (!this._parentMenu) { - this.menuStack.closed.subscribe(({focusParentTrigger}) => { - if (focusParentTrigger && !this.menuStack.length()) { - this._elementRef.nativeElement.focus(); - } - }); - } - } -} diff --git a/src/cdk-experimental/menu/menu-item.spec.ts b/src/cdk-experimental/menu/menu-item.spec.ts index eae87c553170..0b9df0997e52 100644 --- a/src/cdk-experimental/menu/menu-item.spec.ts +++ b/src/cdk-experimental/menu/menu-item.spec.ts @@ -58,7 +58,7 @@ describe('MenuItem', () => { }); it('should not have a menu', () => { - expect(menuItem.hasMenu()).toBeFalse(); + expect(menuItem.hasMenu).toBeFalse(); }); }); @@ -141,7 +141,7 @@ class SingleMenuItem {} @Component({ template: ` - @@ -153,7 +153,7 @@ class MenuItemWithIcon { @Component({ template: ` - @@ -170,7 +170,7 @@ class MenuItemWithBoldElement {} @Component({ template: ` - + diff --git a/src/dev-app/menubar/mat-menubar-demo.ts b/src/dev-app/menubar/mat-menubar-demo.ts index 87f037b4b9ed..38952cbccd84 100644 --- a/src/dev-app/menubar/mat-menubar-demo.ts +++ b/src/dev-app/menubar/mat-menubar-demo.ts @@ -20,10 +20,10 @@ export class MatMenuBarDemo {} exportAs: 'demoMenu', template: '', host: { - '[tabindex]': '_isInline() ? 0 : null', + '[tabindex]': 'isInline() ? 0 : null', 'role': 'menu', 'class': 'cdk-menu mat-menu mat-menu-panel', - '[class.cdk-menu-inline]': '_isInline()', + '[class.cdk-menu-inline]': 'isInline()', '[attr.aria-orientation]': 'orientation', }, providers: [ diff --git a/src/material-experimental/menubar/menubar-item.ts b/src/material-experimental/menubar/menubar-item.ts index 23be1d0e2aa4..62eb09bd2779 100644 --- a/src/material-experimental/menubar/menubar-item.ts +++ b/src/material-experimental/menubar/menubar-item.ts @@ -39,8 +39,8 @@ function removeIcons(element: Element) { }) export class MatMenuBarItem extends CdkMenuItem { override getLabel(): string { - if (this.typeahead !== undefined) { - return this.typeahead; + if (this.typeaheadLabel !== undefined) { + return this.typeaheadLabel || ''; } const clone = this._elementRef.nativeElement.cloneNode(true) as Element; removeIcons(clone);