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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
87
libs/components/src/menu/menu-positions.ts
Normal file
87
libs/components/src/menu/menu-positions.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user