From 0c62ec0992b34865a8cfd8e66b31b4f4202ba232 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 19 Dec 2025 11:10:44 -0500 Subject: [PATCH] wip --- .storybook/main.ts | 1 + apps/desktop/src/main.ts | 1 + apps/desktop/src/main/menu/menu.main.ts | 80 ++++++++++--- apps/desktop/src/main/menu/menubar.ts | 2 +- .../src/chip-select/chip-select.component.ts | 2 +- .../src/layout/layout.component.html | 63 ++++++----- libs/components/src/menu/index.ts | 2 + .../src/menu/menu-item.component.ts | 22 +++- .../src/menu/menu-trigger-for.directive.ts | 105 +++++++++++++----- libs/state/src/core/state-definitions.ts | 1 + tsconfig.base.json | 1 + 11 files changed, 210 insertions(+), 70 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index e1f3561a1b7..46386e70559 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -25,6 +25,7 @@ const config: StorybookConfig = { "../bitwarden_license/bit-web/src/**/*.mdx", "../bitwarden_license/bit-web/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/angular/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/desktop-ui/src/**/*.stories.@(js|jsx|ts|tsx)" ], addons: [ getAbsolutePath("@storybook/addon-links"), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fbb83a1bf56..277f264c599 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -258,6 +258,7 @@ export class Main { this.updaterMain, this.desktopSettingsService, this.versionMain, + globalStateProvider, ); this.trayMain = new TrayMain( diff --git a/apps/desktop/src/main/menu/menu.main.ts b/apps/desktop/src/main/menu/menu.main.ts index eafadf3bfb5..a17428782df 100644 --- a/apps/desktop/src/main/menu/menu.main.ts +++ b/apps/desktop/src/main/menu/menu.main.ts @@ -1,9 +1,15 @@ -import { app, Menu } from "electron"; +import { app, Menu, MenuItemConstructorOptions } from "electron"; import { firstValueFrom } from "rxjs"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +import { + APPLICATION_MENU_KEY, + SerializableMenu, + SerializableMenuItem, +} from "@bitwarden/desktop-ui"; import { VersionMain } from "../../platform/main/version.main"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; @@ -16,6 +22,9 @@ import { Menubar } from "./menubar"; const cloudWebVaultUrl = "https://vault.bitwarden.com"; export class MenuMain { + private currentMenubar: Menubar | null = null; + private menuState = this.globalStateProvider.get(APPLICATION_MENU_KEY); + constructor( private i18nService: I18nService, private messagingService: MessagingService, @@ -24,6 +33,7 @@ export class MenuMain { private updaterMain: UpdaterMain, private desktopSettingsService: DesktopSettingsService, private versionMain: VersionMain, + private globalStateProvider: GlobalStateProvider, ) {} async init() { @@ -36,20 +46,28 @@ export class MenuMain { } private async setMenu(updateRequest?: MenuUpdateRequest) { - Menu.setApplicationMenu( - new Menubar( - this.i18nService, - this.messagingService, - this.desktopSettingsService, - this.updaterMain, - this.windowMain, - await this.getWebVaultUrl(), - app.getVersion(), - await firstValueFrom(this.desktopSettingsService.hardwareAcceleration$), - this.versionMain, - updateRequest, - ).menu, + this.currentMenubar = new Menubar( + this.i18nService, + this.messagingService, + this.desktopSettingsService, + this.updaterMain, + this.windowMain, + await this.getWebVaultUrl(), + app.getVersion(), + await firstValueFrom(this.desktopSettingsService.hardwareAcceleration$), + this.versionMain, + updateRequest, ); + Menu.setApplicationMenu(this.currentMenubar.menu); + + // Update the state with the serialized menu structure + const serializedMenus = this.currentMenubar.items.map((menu) => ({ + id: menu.id, + label: menu.label, + visible: menu.visible ?? true, + items: this.convertMenuItems(menu.items), + })); + await this.menuState.update(() => serializedMenus); } private async getWebVaultUrl() { @@ -136,4 +154,38 @@ export class MenuMain { } }); } + + private convertMenuItems(items: MenuItemConstructorOptions[]): SerializableMenuItem[] { + return items + .map((item): SerializableMenuItem | null => { + // Skip items that shouldn't be visible + if (item.visible === false) { + return null; + } + + // Filter supported types, default to "normal" + const supportedTypes = ["normal", "separator", "submenu", "checkbox", "radio"]; + const menuType = + item.type && supportedTypes.includes(item.type) ? item.type : "normal"; + + const serializedItem: SerializableMenuItem = { + id: item.id, + label: item.label, + type: menuType as "normal" | "separator" | "submenu" | "checkbox" | "radio", + enabled: item.enabled ?? true, // Default to true if not specified + visible: item.visible ?? true, // Default to true if not specified + checked: item.checked, + accelerator: item.accelerator, + role: item.role, + }; + + // Recursively convert submenu items + if (item.submenu && Array.isArray(item.submenu)) { + serializedItem.submenu = this.convertMenuItems(item.submenu); + } + + return serializedItem; + }) + .filter((item): item is SerializableMenuItem => item !== null); + } } diff --git a/apps/desktop/src/main/menu/menubar.ts b/apps/desktop/src/main/menu/menubar.ts index 0a00a67b84a..aeaf3b74b1a 100644 --- a/apps/desktop/src/main/menu/menubar.ts +++ b/apps/desktop/src/main/menu/menubar.ts @@ -29,7 +29,7 @@ export interface IMenubarMenu { } export class Menubar { - private readonly items: IMenubarMenu[]; + readonly items: IMenubarMenu[]; get menu(): Menu { const template: MenuItemConstructorOptions[] = []; 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/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 255799b6690..1abfcf9db6d 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,6 +1,6 @@ @let mainContentId = "main-content";
-
+
@@ -17,33 +17,42 @@ >
- -
- - -
- - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
+ +
+ +
+ +
- @if (data.open) { -
- } -
- } + + + + + @if ( + { + open: sideNavService.open$ | async, + }; + as data + ) { +
+ @if (data.open) { +
+ } +
+ } +
diff --git a/libs/components/src/menu/index.ts b/libs/components/src/menu/index.ts index 69b71b8ee24..0c601495a66 100644 --- a/libs/components/src/menu/index.ts +++ b/libs/components/src/menu/index.ts @@ -3,3 +3,5 @@ export * from "./menu.component"; export * from "./menu-trigger-for.directive"; export * from "./menu-item.component"; export * from "./menu-divider.component"; +export * from "./menu-positions"; +export * from "./menu-item-group.directive"; 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-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 1d79fbc9768..e091c68fab5 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -7,12 +7,17 @@ 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 { MenuItemGroupDirective } from "./menu-item-group.directive"; +import { MenuPositionIdentifier, menuPositions } from "./menu-positions"; import { MenuComponent } from "./menu.component"; /** @@ -56,8 +61,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,39 +75,75 @@ 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 }); + + // Detect if this trigger is part of a menu item group (for coordinated hover behavior) + private menuItemGroup = inject(MenuItemGroupDirective, { optional: true }); + constructor( private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, private overlay: Overlay, ) {} - @HostListener("click") toggleMenu() { - this.isOpen ? this.destroyMenu() : this.openMenu(); + 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(); + } + + // @HostListener("mouseenter") onMouseEnter() { + // // If part of a menu group and any menu is open, open this one on hover + // if (this.menuItemGroup?.anyMenuOpen() && !this.isOpen()) { + // this.openMenu(); + // } + // } + + // TODO: + // @HostListener("mouseleave") onMouseLeave() { + // // If part of a menu group and this menu is open, close it on leave + // if (this.menuItemGroup && this.isOpen()) { + // this.destroyMenu(); + // } + // } + /** * Toggles the menu on right click event. * If the menu is already open, it updates the menu position. @@ -106,7 +151,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 +164,8 @@ export class MenuTriggerForDirective implements OnDestroy { throw new Error("Cannot find bit-menu element"); } - this.isOpen = true; + this.isOpen.set(true); + this.menuItemGroup?.registerOpen(); const positionStrategy = event ? this.overlay @@ -178,11 +224,12 @@ 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.menuItemGroup?.registerClosed(); this.disposeAll(); this.menu().closed.emit(); } @@ -218,6 +265,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(); }); } diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 156c03620b7..f1728f0bb0a 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -117,6 +117,7 @@ export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CONFIG_DISK = new StateDefinition("config", "disk", { web: "disk-local", }); +export const APPLICATION_MENU_MEMORY = new StateDefinition("applicationMenu", "memory"); export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory"); diff --git a/tsconfig.base.json b/tsconfig.base.json index ae4b9f5f601..abb0c4cfe0d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,6 +33,7 @@ "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], "@bitwarden/core-test-utils": ["libs/core-test-utils/src/index.ts"], + "@bitwarden/desktop-ui": ["./libs/desktop-ui/src"], "@bitwarden/dirt-card": ["./libs/dirt/card/src"], "@bitwarden/generator-components": ["./libs/tools/generator/components/src"], "@bitwarden/generator-core": ["./libs/tools/generator/core/src"],