1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-10128] [BEEEP] Add Right-Click Menu Options to Vault (#10954)

* Added right click functionality on cipher row

* Updated menu directive to position menu option on mouse event location

* Updated menu directive to reopen menu option on new mouse event location and close previously opened menu-option

* removed preventdefault call

* Added new events for favorite and edit cipher

* Added new menu options favorite, edit cipher

Added new copy options for the other cipher types

Simplified the copy by using the copy cipher field directive

* Listen to new events

* Refactored parameter to be MouseEvent

* Added locales

* Remove the backdrop from `MenuTriggerForDirective`

* Handle the Angular overlay's outside pointer events

* Cleaned up cipher row component as copy functions and disable menu functions would not be needed anymore

* Fixed bug with right clicking on a row

* Add right click to collections

* Disable backdrop on right click

* Fixed bug where dvivided didn't show for secure notes

* Added comments to enable to disable context menu

* Removed conditionals

* Removed preferences setting to enable to disable setting

* Removed setting from right click listener

* improve context menu positioning to prevent viewport clipping

* Keep icon consisten when favorite or not

* fixed prettier issues

* removed duplicate translation keys

* Fix favorite status not persisting by toggling in handleFavoriteEvent

* Addressed claude comments

* Added comment to variable

---------

Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
SmithThe4th
2025-10-20 09:41:50 -04:00
committed by GitHub
parent 97906fffd9
commit 7c972906aa
8 changed files with 339 additions and 70 deletions

View File

@@ -93,7 +93,7 @@
</span> </span>
</button> </button>
</bit-menu> </bit-menu>
@if (!decryptionFailure && !hideMenu) { @if (!decryptionFailure) {
<button <button
[bitMenuTriggerFor]="cipherOptions" [bitMenuTriggerFor]="cipherOptions"
[disabled]="disabled" [disabled]="disabled"
@@ -104,16 +104,16 @@
label="{{ 'options' | i18n }}" label="{{ 'options' | i18n }}"
></button> ></button>
<bit-menu #cipherOptions> <bit-menu #cipherOptions>
<ng-container *ngIf="isActiveLoginCipher"> <ng-container *ngIf="isLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy"> <button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }} {{ "copyUsername" | i18n }}
</button> </button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy"> <button bitMenuItem type="button" appCopyField="password" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }} {{ "copyPassword" | i18n }}
</button> </button>
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton"> <button bitMenuItem type="button" appCopyField="totp" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }} {{ "copyVerificationCode" | i18n }}
</button> </button>
@@ -130,6 +130,53 @@
</a> </a>
</ng-container> </ng-container>
<ng-container *ngIf="isCardCipher">
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNumber" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySecurityCode" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="isIdentityCipher">
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyEmail" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPhone" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyAddress" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="isSecureNoteCipher">
<button type="button" bitMenuItem appCopyField="secureNote" [cipher]="cipher">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNote" | i18n }}
</button>
</ng-container>
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()"> <button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }} {{ "attachments" | i18n }}

View File

@@ -1,15 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import {
Component,
EventEmitter,
HostListener,
Input,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common"; import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { import {
CipherViewLike, CipherViewLike,
CipherViewLikeUtils, CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuTriggerForDirective } from "@bitwarden/components";
import { import {
convertToPermission, convertToPermission,
@@ -26,6 +36,8 @@ import { RowHeightClass } from "./vault-items.component";
export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit { export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() cipher: C; @Input() cipher: C;
@Input() showOwner: boolean; @Input() showOwner: boolean;
@@ -73,7 +85,10 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
]; ];
protected organization?: Organization; protected organization?: Organization;
constructor(private i18nService: I18nService) {} constructor(
private i18nService: I18nService,
private vaultSettingsService: VaultSettingsService,
) {}
/** /**
* Lifecycle hook for component initialization. * Lifecycle hook for component initialization.
@@ -180,7 +195,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.useEvents && this.cipher.organizationId; return this.useEvents && this.cipher.organizationId;
} }
protected get isActiveLoginCipher() { protected get isLoginCipher() {
return ( return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login && CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
!CipherViewLikeUtils.isDeleted(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher) &&
@@ -230,43 +245,63 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.i18nService.t("noAccess"); return this.i18nService.t("noAccess");
} }
protected get showCopyUsername(): boolean { protected get hasVisibleLoginOptions() {
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); return (
return this.isActiveLoginCipher && usernameCopy; this.isLoginCipher &&
} (CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
(this.cipher.viewPassword &&
protected get showCopyPassword(): boolean { CipherViewLikeUtils.hasCopyableValue(this.cipher, "password")) ||
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); this.showTotpCopyButton ||
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy; this.canLaunch)
}
protected get showCopyTotp(): boolean {
return this.isActiveLoginCipher && this.showTotpCopyButton;
}
protected get showLaunchUri(): boolean {
return this.isActiveLoginCipher && this.canLaunch;
}
protected get isDeletedCanRestore(): boolean {
return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher;
}
protected get hideMenu() {
return !(
this.isDeletedCanRestore ||
this.showCopyUsername ||
this.showCopyPassword ||
this.showCopyTotp ||
this.showLaunchUri ||
this.showAttachments ||
this.showClone ||
this.canEditCipher
); );
} }
protected copy(field: "username" | "password" | "totp") { protected get isCardCipher(): boolean {
this.onEvent.emit({ type: "copyField", item: this.cipher, field }); return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Card && !this.isDeleted;
}
protected get hasVisibleCardOptions(): boolean {
return (
this.isCardCipher &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "cardNumber") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "securityCode"))
);
}
protected get isIdentityCipher() {
return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Identity && !this.isDeleted;
}
protected get hasVisibleIdentityOptions(): boolean {
return (
this.isIdentityCipher &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher, "address") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "email") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "username") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher, "phone"))
);
}
protected get isSecureNoteCipher() {
return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.SecureNote &&
!(this.isDeleted && this.canRestoreCipher)
);
}
protected get hasVisibleSecureNoteOptions(): boolean {
return (
this.isSecureNoteCipher && CipherViewLikeUtils.hasCopyableValue(this.cipher, "secureNote")
);
}
protected get showMenuDivider() {
return (
this.hasVisibleLoginOptions ||
this.hasVisibleCardOptions ||
this.hasVisibleIdentityOptions ||
this.hasVisibleSecureNoteOptions
);
} }
protected clone() { protected clone() {
@@ -308,4 +343,26 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword); return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword);
} }
protected toggleFavorite() {
this.onEvent.emit({
type: "toggleFavorite",
item: this.cipher,
});
}
protected editCipher() {
this.onEvent.emit({ type: "editCipher", item: this.cipher });
}
@HostListener("contextmenu", ["$event"])
protected onRightClick(event: MouseEvent) {
if (event.shiftKey && event.ctrlKey) {
return;
}
if (!this.disabled && this.menuTrigger) {
this.menuTrigger.toggleMenuOnRightClick(event);
}
}
} }

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, HostListener, Input, Output, ViewChild } from "@angular/core";
import { import {
CollectionAdminView, CollectionAdminView,
@@ -11,6 +11,7 @@ import {
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuTriggerForDirective } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
@@ -33,6 +34,8 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected CollectionPermission = CollectionPermission; protected CollectionPermission = CollectionPermission;
protected DefaultCollectionType = CollectionTypes.DefaultUserCollection; protected DefaultCollectionType = CollectionTypes.DefaultUserCollection;
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() collection: CollectionView; @Input() collection: CollectionView;
@Input() showOwner: boolean; @Input() showOwner: boolean;
@@ -122,4 +125,15 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected deleteCollection() { protected deleteCollection() {
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] }); this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
} }
@HostListener("contextmenu", ["$event"])
protected onRightClick(event: MouseEvent) {
if (event.shiftKey && event.ctrlKey) {
return;
}
if (!this.disabled && this.menuTrigger) {
this.menuTrigger.toggleMenuOnRightClick(event);
}
}
} }

View File

@@ -22,4 +22,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
| { type: "moveToFolder"; items: C[] } | { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] } | { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] } | { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] }; | { type: "unarchive"; items: C[] }
| { type: "toggleFavorite"; item: C }
| { type: "editCipher"; item: C };

View File

@@ -4,6 +4,7 @@ import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections"; import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections";
import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module"; import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module";
@@ -26,6 +27,7 @@ import { VaultItemsComponent } from "./vault-items.component";
CollectionNameBadgeComponent, CollectionNameBadgeComponent,
GroupBadgeModule, GroupBadgeModule,
PipesModule, PipesModule,
CopyCipherFieldDirective,
ScrollLayoutDirective, ScrollLayoutDirective,
], ],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],

View File

@@ -679,6 +679,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
await this.bulkUnarchive(event.items); await this.bulkUnarchive(event.items);
} }
break; break;
case "toggleFavorite":
await this.handleFavoriteEvent(event.item);
break;
case "editCipher":
await this.editCipher(event.item);
break;
} }
} finally { } finally {
this.processingEvent = false; this.processingEvent = false;
@@ -1452,6 +1458,27 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
} }
} }
/**
* Toggles the favorite status of the cipher and updates it on the server.
*/
async handleFavoriteEvent(cipher: C) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
cipherFullView.favorite = !cipherFullView.favorite;
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
cipherFullView.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),
});
this.refresh();
}
protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) { protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) {
return permanent return permanent
? this.cipherService.deleteWithServer(id, userId) ? this.cipherService.deleteWithServer(id, userId)

View File

@@ -11028,6 +11028,15 @@
"domainClaimed": { "domainClaimed": {
"message": "Domain claimed" "message": "Domain claimed"
}, },
"itemAddedToFavorites": {
"message": "Item added to favorites"
},
"itemRemovedFromFavorites": {
"message": "Item removed from favorites"
},
"copyNote": {
"message": "Copy note"
},
"organizationNameMaxLength": { "organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters." "message": "Organization name cannot exceed 50 characters."
}, },

View File

@@ -1,5 +1,5 @@
import { hasModifierKey } from "@angular/cdk/keycodes"; import { hasModifierKey } from "@angular/cdk/keycodes";
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
import { TemplatePortal } from "@angular/cdk/portal"; import { TemplatePortal } from "@angular/cdk/portal";
import { import {
Directive, Directive,
@@ -10,11 +10,46 @@ import {
ViewContainerRef, ViewContainerRef,
input, input,
} from "@angular/core"; } from "@angular/core";
import { Observable, Subscription } from "rxjs"; import { merge, Subscription } from "rxjs";
import { filter, mergeWith } from "rxjs/operators"; import { filter, skip, takeUntil } from "rxjs/operators";
import { MenuComponent } from "./menu.component"; import { MenuComponent } from "./menu.component";
/**
* Position strategies for context menus.
* Tries positions in order: below-right, above-right, below-left, above-left
*/
const CONTEXT_MENU_POSITIONS: ConnectedPosition[] = [
// below-right
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "top",
},
// above-right
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "bottom",
},
// below-left
{
originX: "end",
originY: "top",
overlayX: "end",
overlayY: "top",
},
// above-left
{
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "bottom",
},
];
@Directive({ @Directive({
selector: "[bitMenuTriggerFor]", selector: "[bitMenuTriggerFor]",
exportAs: "menuTrigger", exportAs: "menuTrigger",
@@ -52,6 +87,7 @@ export class MenuTriggerForDirective implements OnDestroy {
}; };
private closedEventsSub: Subscription | null = null; private closedEventsSub: Subscription | null = null;
private keyDownEventsSub: Subscription | null = null; private keyDownEventsSub: Subscription | null = null;
private menuCloseListenerSub: Subscription | null = null;
constructor( constructor(
private elementRef: ElementRef<HTMLElement>, private elementRef: ElementRef<HTMLElement>,
@@ -63,37 +99,49 @@ export class MenuTriggerForDirective implements OnDestroy {
this.isOpen ? this.destroyMenu() : this.openMenu(); this.isOpen ? this.destroyMenu() : this.openMenu();
} }
/**
* Toggles the menu on right click event.
* If the menu is already open, it updates the menu position.
* @param event The MouseEvent from the right-click interaction
*/
toggleMenuOnRightClick(event: MouseEvent) {
event.preventDefault(); // Prevent default context menu
this.isOpen ? this.updateMenuPosition(event) : this.openMenu(event);
}
ngOnDestroy() { ngOnDestroy() {
this.disposeAll(); this.disposeAll();
} }
private openMenu() { private openMenu(event?: MouseEvent) {
const menu = this.menu(); const menu = this.menu();
if (menu == null) { if (menu == null) {
throw new Error("Cannot find bit-menu element"); throw new Error("Cannot find bit-menu element");
} }
this.isOpen = true; this.isOpen = true;
this.overlayRef = this.overlay.create(this.defaultMenuConfig);
const positionStrategy = event
? this.overlay
.position()
.flexibleConnectedTo({ x: event.clientX, y: event.clientY })
.withPositions(CONTEXT_MENU_POSITIONS)
.withLockedPosition(false)
.withFlexibleDimensions(false)
.withPush(true)
: this.defaultMenuConfig.positionStrategy;
const config = { ...this.defaultMenuConfig, positionStrategy, hasBackdrop: !event };
this.overlayRef = this.overlay.create(config);
const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef); const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef);
this.overlayRef.attach(templatePortal); this.overlayRef.attach(templatePortal);
this.closedEventsSub = // Context menus are opened with a MouseEvent
this.getClosedEvents()?.subscribe((event: KeyboardEvent | undefined) => { const isContextMenu = !!event;
// Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key this.setupClosingActions(isContextMenu);
// from doing its normal default action, which would otherwise cause a parent component this.setupMenuCloseListener();
// (like a dialog) or extension window to close
if (event?.key === "Escape" && !hasModifierKey(event)) {
event.preventDefault();
}
if (event?.key && ["Tab", "Escape"].includes(event.key)) {
// Required to ensure tab order resumes correctly
this.elementRef.nativeElement.focus();
}
this.destroyMenu();
}) ?? null;
if (menu.keyManager) { if (menu.keyManager) {
menu.keyManager.setFirstItemActive(); menu.keyManager.setFirstItemActive();
@@ -103,6 +151,32 @@ export class MenuTriggerForDirective implements OnDestroy {
} }
} }
/**
* Updates the position of the menu overlay based on the mouse event coordinates.
* This is typically called when the menu is already open and the user right-clicks again,
* allowing the menu to reposition itself to the new cursor location.
* @param event The MouseEvent containing the new clientX and clientY coordinates
*/
private updateMenuPosition(event: MouseEvent) {
if (this.overlayRef == null) {
return;
}
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo({ x: event.clientX, y: event.clientY })
.withPositions([
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "top",
},
]);
this.overlayRef.updatePositionStrategy(positionStrategy);
}
private destroyMenu() { private destroyMenu() {
if (this.overlayRef == null || !this.isOpen) { if (this.overlayRef == null || !this.isOpen) {
return; return;
@@ -113,26 +187,63 @@ export class MenuTriggerForDirective implements OnDestroy {
this.menu().closed.emit(); this.menu().closed.emit();
} }
private getClosedEvents(): Observable<any> | null { private setupClosingActions(isContextMenu: boolean) {
if (!this.overlayRef) { if (!this.overlayRef) {
return null; return;
} }
const detachments = this.overlayRef.detachments();
const escKey = this.overlayRef.keydownEvents().pipe( const escKey = this.overlayRef.keydownEvents().pipe(
filter((event: KeyboardEvent) => { filter((event: KeyboardEvent) => {
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"]; const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
return keys.includes(event.key); return keys.includes(event.key);
}), }),
); );
const backdrop = this.overlayRef.backdropClick();
const menuClosed = this.menu().closed; const menuClosed = this.menu().closed;
const detachments = this.overlayRef.detachments();
return detachments.pipe(mergeWith(escKey, backdrop, menuClosed)); const closeEvents = isContextMenu
? merge(detachments, escKey, menuClosed)
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
this.closedEventsSub = closeEvents
.pipe(takeUntil(this.overlayRef.detachments()))
.subscribe((event) => {
// Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key
// from doing its normal default action, which would otherwise cause a parent component
// (like a dialog) or extension window to close
if (event instanceof KeyboardEvent && event.key === "Escape" && !hasModifierKey(event)) {
event.preventDefault();
}
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
this.elementRef.nativeElement.focus();
}
this.destroyMenu();
});
}
/**
* Sets up a listener for clicks outside the menu overlay.
* We skip(1) because the initial right-click event that opens the menu is also
* considered an outside click event, which would immediately close the menu
*/
private setupMenuCloseListener() {
if (!this.overlayRef) {
return;
}
this.menuCloseListenerSub = this.overlayRef
.outsidePointerEvents()
.pipe(skip(1), takeUntil(this.overlayRef.detachments()))
.subscribe((_) => {
this.destroyMenu();
});
} }
private disposeAll() { private disposeAll() {
this.closedEventsSub?.unsubscribe(); this.closedEventsSub?.unsubscribe();
this.overlayRef?.dispose();
this.keyDownEventsSub?.unsubscribe(); this.keyDownEventsSub?.unsubscribe();
this.menuCloseListenerSub?.unsubscribe();
this.overlayRef?.dispose();
} }
} }