Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cdk-experimental/menu): API, code, and docs cleanup pass #24745

Merged
merged 2 commits into from
Apr 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -491,7 +491,7 @@ class NestedContextMenu {
})
class ContextMenuWithSubmenu {
@ViewChild(CdkContextMenuTrigger, {read: ElementRef}) context: ElementRef<HTMLElement>;
@ViewChild(CdkMenuItemTrigger, {read: ElementRef}) triggerNativeElement: ElementRef<HTMLElement>;
@ViewChild(CdkMenuTrigger, {read: ElementRef}) triggerNativeElement: ElementRef<HTMLElement>;

@ViewChild('cut_menu', {read: CdkMenu}) cutMenu: CdkMenu;
@ViewChild('copy_menu', {read: CdkMenu}) copyMenu: CdkMenu;
Expand Down Expand Up @@ -548,7 +548,7 @@ class ContextMenuWithMenuBarAndInlineMenu {
`,
})
class MenuBarAndContextTriggerShareMenu {
@ViewChild(CdkMenuItemTrigger) menuBarTrigger: CdkMenuItemTrigger;
@ViewChild(CdkMenuTrigger) menuBarTrigger: CdkMenuTrigger;
@ViewChild(CdkContextMenuTrigger) contextTrigger: CdkContextMenuTrigger;
@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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();
}

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -196,52 +172,70 @@ 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<unknown> {
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();
}
});
}

/**
* 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);
}
}
}
45 changes: 27 additions & 18 deletions src/cdk-experimental/menu/menu-aim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusableElement & Toggler>): void;

/**
Expand All @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -115,9 +117,21 @@ export class TargetMenuAim implements MenuAim, OnDestroy {
/** Emits when this service is destroyed. */
private readonly _destroyed: Subject<void> = 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<FocusableElement & Toggler>) {
this._menu = menu;
this._pointerTracker = pointerTracker;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -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<MouseEvent>(this._menu._elementRef.nativeElement, 'mousemove')
fromEvent<MouseEvent>(this._menu.nativeElement, 'mousemove')
.pipe(
filter((_: MouseEvent, index: number) => index % MOUSE_MOVE_SAMPLE_FREQUENCY === 0),
takeUntil(this._destroyed),
Expand All @@ -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({
Expand Down
Loading