From 6093f00c034aa784fb908ae61b39b4e7ce8c00ec Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 16 Dec 2025 23:14:59 -0500 Subject: [PATCH] feat: improve submenu support --- .../src/chip-select/chip-select.component.ts | 2 +- libs/components/src/menu/index.ts | 1 + .../src/menu/menu-item.component.ts | 22 ++++- libs/components/src/menu/menu-positions.ts | 87 +++++++++++++++++++ .../src/menu/menu-trigger-for.directive.ts | 82 +++++++++++------ 5 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 libs/components/src/menu/menu-positions.ts diff --git a/libs/components/src/chip-select/chip-select.component.ts b/libs/components/src/chip-select/chip-select.component.ts index 50e462dc815..08d1d39e2f1 100644 --- a/libs/components/src/chip-select/chip-select.component.ts +++ b/libs/components/src/chip-select/chip-select.component.ts @@ -126,7 +126,7 @@ export class ChipSelectComponent 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(); } }); diff --git a/libs/components/src/menu/index.ts b/libs/components/src/menu/index.ts index 69b71b8ee24..f9af5845a2b 100644 --- a/libs/components/src/menu/index.ts +++ b/libs/components/src/menu/index.ts @@ -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"; diff --git a/libs/components/src/menu/menu-item.component.ts b/libs/components/src/menu/menu-item.component.ts index 149fc3ca297..9c9f8974217 100644 --- a/libs/components/src/menu/menu-item.component.ts +++ b/libs/components/src/menu/menu-item.component.ts @@ -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(false); + constructor(public elementRef: ElementRef) {} + onClick(event: MouseEvent) { + if (this.disableClose() || this.bitMenuTriggerFor) { + // Stop propagation to prevent the menu from closing + event.stopPropagation(); + } + } + focus() { this.elementRef.nativeElement.focus(); } diff --git a/libs/components/src/menu/menu-positions.ts b/libs/components/src/menu/menu-positions.ts new file mode 100644 index 00000000000..998953f076b --- /dev/null +++ b/libs/components/src/menu/menu-positions.ts @@ -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", + }, +]; diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 1d79fbc9768..cb517803806 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -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({ 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("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, 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(); }); }