1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 16:23:53 +00:00
This commit is contained in:
William Martin
2025-12-19 11:10:44 -05:00
parent 1d1eca472e
commit 0c62ec0992
11 changed files with 210 additions and 70 deletions

View File

@@ -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"),

View File

@@ -258,6 +258,7 @@ export class Main {
this.updaterMain,
this.desktopSettingsService,
this.versionMain,
globalStateProvider,
);
this.trayMain = new TrayMain(

View File

@@ -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);
}
}

View File

@@ -29,7 +29,7 @@ export interface IMenubarMenu {
}
export class Menubar {
private readonly items: IMenubarMenu[];
readonly items: IMenubarMenu[];
get menu(): Menu {
const template: MenuItemConstructorOptions[] = [];

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

@@ -1,6 +1,6 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-size-full">
<div class="tw-flex tw-size-full" cdkTrapFocus>
<div class="tw-flex tw-size-full tw-flex-col" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
@@ -17,33 +17,42 @@
>
</nav>
</div>
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
#main
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>
</main>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
<div class="tw-group/bit-layout-title-bar empty:tw-hidden">
<ng-content select="[slot=title-bar]"></ng-content>
</div>
<!-- todo add bg color -->
<div class="tw-flex tw-size-full tw-bg-background-alt3">
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
#main
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="group-empty/bit-layout-title-bar:tw-rounded-tl-none tw-rounded-tl-3xl tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>
</main>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-size-full"
></div>
}
</div>
}
</div>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>

View File

@@ -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";

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

@@ -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<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 });
// 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<HTMLElement>,
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();
});
}

View File

@@ -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");

View File

@@ -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"],