/** * @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.dev/license */ import { AfterContentInit, ChangeDetectionStrategy, Component, ContentChild, ContentChildren, ElementRef, EventEmitter, InjectionToken, Input, OnDestroy, Output, TemplateRef, QueryList, ViewChild, ViewEncapsulation, OnInit, ChangeDetectorRef, booleanAttribute, afterNextRender, AfterRenderRef, inject, Injector, } from '@angular/core'; import {AnimationEvent} from '@angular/animations'; import {_IdGenerator, FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; import {Direction} from '@angular/cdk/bidi'; import { ESCAPE, LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, UP_ARROW, hasModifierKey, } from '@angular/cdk/keycodes'; import {merge, Observable, Subject} from 'rxjs'; import {startWith, switchMap} from 'rxjs/operators'; import {MatMenuItem} from './menu-item'; import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel'; import {MenuPositionX, MenuPositionY} from './menu-positions'; import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors'; import {MatMenuContent, MAT_MENU_CONTENT} from './menu-content'; import {matMenuAnimations} from './menu-animations'; /** Reason why the menu was closed. */ export type MenuCloseReason = void | 'click' | 'keydown' | 'tab'; /** Default `mat-menu` options that can be overridden. */ export interface MatMenuDefaultOptions { /** The x-axis position of the menu. */ xPosition: MenuPositionX; /** The y-axis position of the menu. */ yPosition: MenuPositionY; /** Whether the menu should overlap the menu trigger. */ overlapTrigger: boolean; /** Class to be applied to the menu's backdrop. */ backdropClass: string; /** Class or list of classes to be applied to the menu's overlay panel. */ overlayPanelClass?: string | string[]; /** Whether the menu has a backdrop. */ hasBackdrop?: boolean; } /** Injection token to be used to override the default options for `mat-menu`. */ export const MAT_MENU_DEFAULT_OPTIONS = new InjectionToken( 'mat-menu-default-options', { providedIn: 'root', factory: MAT_MENU_DEFAULT_OPTIONS_FACTORY, }, ); /** @docs-private */ export function MAT_MENU_DEFAULT_OPTIONS_FACTORY(): MatMenuDefaultOptions { return { overlapTrigger: false, xPosition: 'after', yPosition: 'below', backdropClass: 'cdk-overlay-transparent-backdrop', }; } @Component({ selector: 'mat-menu', templateUrl: 'menu.html', styleUrl: 'menu.css', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, exportAs: 'matMenu', host: { '[attr.aria-label]': 'null', '[attr.aria-labelledby]': 'null', '[attr.aria-describedby]': 'null', }, animations: [matMenuAnimations.transformMenu, matMenuAnimations.fadeInItems], providers: [{provide: MAT_MENU_PANEL, useExisting: MatMenu}], }) export class MatMenu implements AfterContentInit, MatMenuPanel, OnInit, OnDestroy { private _elementRef = inject>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); private _keyManager: FocusKeyManager; private _xPosition: MenuPositionX; private _yPosition: MenuPositionY; private _firstItemFocusRef?: AfterRenderRef; /** All items inside the menu. Includes items nested inside another menu. */ @ContentChildren(MatMenuItem, {descendants: true}) _allItems: QueryList; /** Only the direct descendant menu items. */ _directDescendantItems = new QueryList(); /** Classes to be applied to the menu panel. */ _classList: {[key: string]: boolean} = {}; /** Current state of the panel animation. */ _panelAnimationState: 'void' | 'enter' = 'void'; /** Emits whenever an animation on the menu completes. */ readonly _animationDone = new Subject(); /** Whether the menu is animating. */ _isAnimating: boolean; /** Parent menu of the current menu panel. */ parentMenu: MatMenuPanel | undefined; /** Layout direction of the menu. */ direction: Direction; /** Class or list of classes to be added to the overlay panel. */ overlayPanelClass: string | string[]; /** Class to be added to the backdrop element. */ @Input() backdropClass: string; /** aria-label for the menu panel. */ @Input('aria-label') ariaLabel: string; /** aria-labelledby for the menu panel. */ @Input('aria-labelledby') ariaLabelledby: string; /** aria-describedby for the menu panel. */ @Input('aria-describedby') ariaDescribedby: string; /** Position of the menu in the X axis. */ @Input() get xPosition(): MenuPositionX { return this._xPosition; } set xPosition(value: MenuPositionX) { if ( value !== 'before' && value !== 'after' && (typeof ngDevMode === 'undefined' || ngDevMode) ) { throwMatMenuInvalidPositionX(); } this._xPosition = value; this.setPositionClasses(); } /** Position of the menu in the Y axis. */ @Input() get yPosition(): MenuPositionY { return this._yPosition; } set yPosition(value: MenuPositionY) { if (value !== 'above' && value !== 'below' && (typeof ngDevMode === 'undefined' || ngDevMode)) { throwMatMenuInvalidPositionY(); } this._yPosition = value; this.setPositionClasses(); } /** @docs-private */ @ViewChild(TemplateRef) templateRef: TemplateRef; /** * List of the items inside of a menu. * @deprecated * @breaking-change 8.0.0 */ @ContentChildren(MatMenuItem, {descendants: false}) items: QueryList; /** * Menu content that will be rendered lazily. * @docs-private */ @ContentChild(MAT_MENU_CONTENT) lazyContent: MatMenuContent; /** Whether the menu should overlap its trigger. */ @Input({transform: booleanAttribute}) overlapTrigger: boolean; /** Whether the menu has a backdrop. */ @Input({transform: (value: any) => (value == null ? null : booleanAttribute(value))}) hasBackdrop?: boolean; /** * This method takes classes set on the host mat-menu element and applies them on the * menu template that displays in the overlay container. Otherwise, it's difficult * to style the containing menu from outside the component. * @param classes list of class names */ @Input('class') set panelClass(classes: string) { const previousPanelClass = this._previousPanelClass; const newClassList = {...this._classList}; if (previousPanelClass && previousPanelClass.length) { previousPanelClass.split(' ').forEach((className: string) => { newClassList[className] = false; }); } this._previousPanelClass = classes; if (classes && classes.length) { classes.split(' ').forEach((className: string) => { newClassList[className] = true; }); this._elementRef.nativeElement.className = ''; } this._classList = newClassList; } private _previousPanelClass: string; /** * This method takes classes set on the host mat-menu element and applies them on the * menu template that displays in the overlay container. Otherwise, it's difficult * to style the containing menu from outside the component. * @deprecated Use `panelClass` instead. * @breaking-change 8.0.0 */ @Input() get classList(): string { return this.panelClass; } set classList(classes: string) { this.panelClass = classes; } /** Event emitted when the menu is closed. */ @Output() readonly closed: EventEmitter = new EventEmitter(); /** * Event emitted when the menu is closed. * @deprecated Switch to `closed` instead * @breaking-change 8.0.0 */ @Output() readonly close: EventEmitter = this.closed; readonly panelId: string = inject(_IdGenerator).getId('mat-menu-panel-'); private _injector = inject(Injector); constructor(...args: unknown[]); constructor() { const defaultOptions = inject(MAT_MENU_DEFAULT_OPTIONS); this.overlayPanelClass = defaultOptions.overlayPanelClass || ''; this._xPosition = defaultOptions.xPosition; this._yPosition = defaultOptions.yPosition; this.backdropClass = defaultOptions.backdropClass; this.overlapTrigger = defaultOptions.overlapTrigger; this.hasBackdrop = defaultOptions.hasBackdrop; } ngOnInit() { this.setPositionClasses(); } ngAfterContentInit() { this._updateDirectDescendants(); this._keyManager = new FocusKeyManager(this._directDescendantItems) .withWrap() .withTypeAhead() .withHomeAndEnd(); this._keyManager.tabOut.subscribe(() => this.closed.emit('tab')); // If a user manually (programmatically) focuses a menu item, we need to reflect that focus // change back to the key manager. Note that we don't need to unsubscribe here because _focused // is internal and we know that it gets completed on destroy. this._directDescendantItems.changes .pipe( startWith(this._directDescendantItems), switchMap(items => merge(...items.map((item: MatMenuItem) => item._focused))), ) .subscribe(focusedItem => this._keyManager.updateActiveItem(focusedItem as MatMenuItem)); this._directDescendantItems.changes.subscribe((itemsList: QueryList) => { // Move focus to another item, if the active item is removed from the list. // We need to debounce the callback, because multiple items might be removed // in quick succession. const manager = this._keyManager; if (this._panelAnimationState === 'enter' && manager.activeItem?._hasFocus()) { const items = itemsList.toArray(); const index = Math.max(0, Math.min(items.length - 1, manager.activeItemIndex || 0)); if (items[index] && !items[index].disabled) { manager.setActiveItem(index); } else { manager.setNextItemActive(); } } }); } ngOnDestroy() { this._keyManager?.destroy(); this._directDescendantItems.destroy(); this.closed.complete(); this._firstItemFocusRef?.destroy(); } /** Stream that emits whenever the hovered menu item changes. */ _hovered(): Observable { // Coerce the `changes` property because Angular types it as `Observable` const itemChanges = this._directDescendantItems.changes as Observable>; return itemChanges.pipe( startWith(this._directDescendantItems), switchMap(items => merge(...items.map((item: MatMenuItem) => item._hovered))), ) as Observable; } /* * Registers a menu item with the menu. * @docs-private * @deprecated No longer being used. To be removed. * @breaking-change 9.0.0 */ addItem(_item: MatMenuItem) {} /** * Removes an item from the menu. * @docs-private * @deprecated No longer being used. To be removed. * @breaking-change 9.0.0 */ removeItem(_item: MatMenuItem) {} /** Handle a keyboard event from the menu, delegating to the appropriate action. */ _handleKeydown(event: KeyboardEvent) { const keyCode = event.keyCode; const manager = this._keyManager; switch (keyCode) { case ESCAPE: if (!hasModifierKey(event)) { event.preventDefault(); this.closed.emit('keydown'); } break; case LEFT_ARROW: if (this.parentMenu && this.direction === 'ltr') { this.closed.emit('keydown'); } break; case RIGHT_ARROW: if (this.parentMenu && this.direction === 'rtl') { this.closed.emit('keydown'); } break; default: if (keyCode === UP_ARROW || keyCode === DOWN_ARROW) { manager.setFocusOrigin('keyboard'); } manager.onKeydown(event); return; } } /** * Focus the first item in the menu. * @param origin Action from which the focus originated. Used to set the correct styling. */ focusFirstItem(origin: FocusOrigin = 'program'): void { // Wait for `afterNextRender` to ensure iOS VoiceOver screen reader focuses the first item (#24735). this._firstItemFocusRef?.destroy(); this._firstItemFocusRef = afterNextRender( () => { let menuPanel: HTMLElement | null = null; if (this._directDescendantItems.length) { // Because the `mat-menuPanel` is at the DOM insertion point, not inside the overlay, we don't // have a nice way of getting a hold of the menuPanel panel. We can't use a `ViewChild` either // because the panel is inside an `ng-template`. We work around it by starting from one of // the items and walking up the DOM. menuPanel = this._directDescendantItems.first!._getHostElement().closest('[role="menu"]'); } // If an item in the menuPanel is already focused, avoid overriding the focus. if (!menuPanel || !menuPanel.contains(document.activeElement)) { const manager = this._keyManager; manager.setFocusOrigin(origin).setFirstItemActive(); // If there's no active item at this point, it means that all the items are disabled. // Move focus to the menuPanel panel so keyboard events like Escape still work. Also this will // give _some_ feedback to screen readers. if (!manager.activeItem && menuPanel) { menuPanel.focus(); } } }, {injector: this._injector}, ); } /** * Resets the active item in the menu. This is used when the menu is opened, allowing * the user to start from the first option when pressing the down arrow. */ resetActiveItem() { this._keyManager.setActiveItem(-1); } /** * @deprecated No longer used and will be removed. * @breaking-change 21.0.0 */ setElevation(_depth: number): void {} /** * Adds classes to the menu panel based on its position. Can be used by * consumers to add specific styling based on the position. * @param posX Position of the menu along the x axis. * @param posY Position of the menu along the y axis. * @docs-private */ setPositionClasses(posX: MenuPositionX = this.xPosition, posY: MenuPositionY = this.yPosition) { this._classList = { ...this._classList, ['mat-menu-before']: posX === 'before', ['mat-menu-after']: posX === 'after', ['mat-menu-above']: posY === 'above', ['mat-menu-below']: posY === 'below', }; this._changeDetectorRef.markForCheck(); } /** Starts the enter animation. */ _startAnimation() { // @breaking-change 8.0.0 Combine with _resetAnimation. this._panelAnimationState = 'enter'; } /** Resets the panel animation to its initial state. */ _resetAnimation() { // @breaking-change 8.0.0 Combine with _startAnimation. this._panelAnimationState = 'void'; } /** Callback that is invoked when the panel animation completes. */ _onAnimationDone(event: AnimationEvent) { this._animationDone.next(event); this._isAnimating = false; } _onAnimationStart(event: AnimationEvent) { this._isAnimating = true; // Scroll the content element to the top as soon as the animation starts. This is necessary, // because we move focus to the first item while it's still being animated, which can throw // the browser off when it determines the scroll position. Alternatively we can move focus // when the animation is done, however moving focus asynchronously will interrupt screen // readers which are in the process of reading out the menu already. We take the `element` // from the `event` since we can't use a `ViewChild` to access the pane. if (event.toState === 'enter' && this._keyManager.activeItemIndex === 0) { event.element.scrollTop = 0; } } /** * Sets up a stream that will keep track of any newly-added menu items and will update the list * of direct descendants. We collect the descendants this way, because `_allItems` can include * items that are part of child menus, and using a custom way of registering items is unreliable * when it comes to maintaining the item order. */ private _updateDirectDescendants() { this._allItems.changes .pipe(startWith(this._allItems)) .subscribe((items: QueryList) => { this._directDescendantItems.reset(items.filter(item => item._parentMenu === this)); this._directDescendantItems.notifyOnChanges(); }); } }