1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 16:23:53 +00:00

feat: improve submenu support

This commit is contained in:
William Martin
2025-12-16 23:14:59 -05:00
parent 1d1eca472e
commit 6093f00c03
5 changed files with 167 additions and 27 deletions

View File

@@ -126,7 +126,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
// Note: `isOpen` is intentionally accessed outside signal tracking (via `trigger?.isOpen`)
// to avoid re-focusing when the menu state changes. We only want to focus during
// submenu navigation, not on initial open/close.
if (items.length > 0 && trigger?.isOpen) {
if (items.length > 0 && trigger?.isOpen()) {
currentMenu?.keyManager?.setFirstItemActive();
}
});

View File

@@ -3,3 +3,4 @@ export * from "./menu.component";
export * from "./menu-trigger-for.directive";
export * from "./menu-item.component";
export * from "./menu-divider.component";
export * from "./menu-positions";

View File

@@ -1,7 +1,9 @@
import { FocusableOption } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { NgClass } from "@angular/common";
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { Component, ElementRef, HostBinding, inject, Input, input } from "@angular/core";
import { MenuTriggerForDirective } from "./menu-trigger-for.directive";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -9,8 +11,13 @@ import { Component, ElementRef, HostBinding, Input } from "@angular/core";
selector: "[bitMenuItem]",
templateUrl: "menu-item.component.html",
imports: [NgClass],
host: {
"(click)": "onClick($event)",
},
})
export class MenuItemComponent implements FocusableOption {
private bitMenuTriggerFor = inject(MenuTriggerForDirective, { optional: true });
@HostBinding("class") classList = [
"tw-block",
"tw-w-full",
@@ -48,8 +55,21 @@ export class MenuItemComponent implements FocusableOption {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: coerceBooleanProperty }) disabled?: boolean = false;
/**
* When true, clicking this menu item will not close the parent menu.
* Useful for items that open submenus or perform actions that should keep the menu open.
*/
readonly disableClose = input<boolean>(false);
constructor(public elementRef: ElementRef<HTMLButtonElement>) {}
onClick(event: MouseEvent) {
if (this.disableClose() || this.bitMenuTriggerFor) {
// Stop propagation to prevent the menu from closing
event.stopPropagation();
}
}
focus() {
this.elementRef.nativeElement.focus();
}

View File

@@ -0,0 +1,87 @@
import { ConnectedPosition } from "@angular/cdk/overlay";
export type MenuPositionIdentifier =
| "below-left"
| "below-right"
| "above-left"
| "above-right"
| "right-top"
| "right-bottom"
| "left-top"
| "left-bottom";
export interface MenuPosition extends ConnectedPosition {
id: MenuPositionIdentifier;
}
export const menuPositions: MenuPosition[] = [
/**
* The order of these positions matters. The Menu component will use
* the first position that fits within the viewport.
*/
// Menu opens below trigger, aligned to left edge
{
id: "below-left",
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
},
// Menu opens below trigger, aligned to right edge
{
id: "below-right",
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "top",
},
// Menu opens above trigger, aligned to left edge
{
id: "above-left",
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
// Menu opens above trigger, aligned to right edge
{
id: "above-right",
originX: "end",
originY: "top",
overlayX: "end",
overlayY: "bottom",
},
// Menu opens to right of trigger, aligned to top edge (for submenus)
{
id: "right-top",
originX: "end",
originY: "top",
overlayX: "start",
overlayY: "top",
},
// Menu opens to right of trigger, aligned to bottom edge (for submenus)
{
id: "right-bottom",
originX: "end",
originY: "bottom",
overlayX: "start",
overlayY: "bottom",
},
// Menu opens to left of trigger, aligned to top edge (for submenus)
{
id: "left-top",
originX: "start",
originY: "top",
overlayX: "end",
overlayY: "top",
},
// Menu opens to left of trigger, aligned to bottom edge (for submenus)
{
id: "left-bottom",
originX: "start",
originY: "bottom",
overlayX: "end",
overlayY: "bottom",
},
];

View File

@@ -7,12 +7,16 @@ import {
HostBinding,
HostListener,
OnDestroy,
OnInit,
ViewContainerRef,
input,
inject,
model,
} from "@angular/core";
import { merge, Subscription } from "rxjs";
import { filter, skip, takeUntil } from "rxjs/operators";
import { MenuPositionIdentifier, menuPositions } from "./menu-positions";
import { MenuComponent } from "./menu.component";
/**
@@ -56,8 +60,12 @@ const CONTEXT_MENU_POSITIONS: ConnectedPosition[] = [
standalone: true,
host: { "[attr.role]": "this.role()" },
})
export class MenuTriggerForDirective implements OnDestroy {
@HostBinding("attr.aria-expanded") isOpen = false;
export class MenuTriggerForDirective implements OnDestroy, OnInit {
readonly isOpen = model(false);
@HostBinding("attr.aria-expanded") get ariaExpanded() {
return this.isOpen();
}
@HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
return this.menu()?.ariaRole() || "menu";
}
@@ -66,37 +74,55 @@ export class MenuTriggerForDirective implements OnDestroy {
readonly menu = input.required<MenuComponent>({ alias: "bitMenuTriggerFor" });
/**
* The preferred position for the menu overlay.
* The menu will try this position first, then fallback to other positions if it doesn't fit.
* @default "below-left"
*/
readonly menuPosition = input<MenuPositionIdentifier>("below-left");
private overlayRef: OverlayRef | null = null;
private defaultMenuConfig: OverlayConfig = {
panelClass: "bit-menu-panel",
hasBackdrop: true,
backdropClass: ["cdk-overlay-transparent-backdrop", "bit-menu-panel-backdrop"],
scrollStrategy: this.overlay.scrollStrategies.reposition(),
positionStrategy: this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions([
{ originX: "start", originY: "bottom", overlayX: "start", overlayY: "top" },
{ originX: "end", originY: "bottom", overlayX: "end", overlayY: "top" },
{ originX: "start", originY: "top", overlayX: "start", overlayY: "bottom" },
{ originX: "end", originY: "top", overlayX: "end", overlayY: "bottom" },
])
.withLockedPosition(true)
.withFlexibleDimensions(false)
.withPush(true),
};
private positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withLockedPosition(true)
.withFlexibleDimensions(false)
.withPush(true);
private closedEventsSub: Subscription | null = null;
private keyDownEventsSub: Subscription | null = null;
private menuCloseListenerSub: Subscription | null = null;
// Detect if this trigger is inside a parent menu (for submenu scenario)
private parentMenu = inject(MenuComponent, { optional: true, skipSelf: true });
constructor(
private elementRef: ElementRef<HTMLElement>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay,
) {}
ngOnInit() {
this.positionStrategy.withPositions(this.computePositions(this.menuPosition()));
}
private computePositions(menuPosition: MenuPositionIdentifier): ConnectedPosition[] {
const chosenPosition = menuPositions.find((position) => position.id === menuPosition);
return chosenPosition ? [chosenPosition, ...menuPositions] : menuPositions;
}
private get defaultMenuConfig(): OverlayConfig {
return {
panelClass: "bit-menu-panel",
hasBackdrop: true,
backdropClass: ["cdk-overlay-transparent-backdrop", "bit-menu-panel-backdrop"],
scrollStrategy: this.overlay.scrollStrategies.reposition(),
positionStrategy: this.positionStrategy,
};
}
@HostListener("click") toggleMenu() {
this.isOpen ? this.destroyMenu() : this.openMenu();
this.isOpen() ? this.destroyMenu() : this.openMenu();
}
/**
@@ -106,7 +132,7 @@ export class MenuTriggerForDirective implements OnDestroy {
*/
toggleMenuOnRightClick(event: MouseEvent) {
event.preventDefault(); // Prevent default context menu
this.isOpen ? this.updateMenuPosition(event) : this.openMenu(event);
this.isOpen() ? this.updateMenuPosition(event) : this.openMenu(event);
}
ngOnDestroy() {
@@ -119,7 +145,7 @@ export class MenuTriggerForDirective implements OnDestroy {
throw new Error("Cannot find bit-menu element");
}
this.isOpen = true;
this.isOpen.set(true);
const positionStrategy = event
? this.overlay
@@ -178,11 +204,11 @@ export class MenuTriggerForDirective implements OnDestroy {
}
private destroyMenu() {
if (this.overlayRef == null || !this.isOpen) {
if (this.overlayRef == null || !this.isOpen()) {
return;
}
this.isOpen = false;
this.isOpen.set(false);
this.disposeAll();
this.menu().closed.emit();
}
@@ -218,6 +244,12 @@ export class MenuTriggerForDirective implements OnDestroy {
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
this.elementRef.nativeElement.focus();
}
// If this is a submenu (has a parent menu), close the parent too
if (this.parentMenu) {
this.parentMenu.closed.emit();
}
this.destroyMenu();
});
}