mirror of
https://github.com/bitwarden/browser
synced 2026-01-30 16:23:53 +00:00
wip
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -258,6 +258,7 @@ export class Main {
|
||||
this.updaterMain,
|
||||
this.desktopSettingsService,
|
||||
this.versionMain,
|
||||
globalStateProvider,
|
||||
);
|
||||
|
||||
this.trayMain = new TrayMain(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface IMenubarMenu {
|
||||
}
|
||||
|
||||
export class Menubar {
|
||||
private readonly items: IMenubarMenu[];
|
||||
readonly items: IMenubarMenu[];
|
||||
|
||||
get menu(): Menu {
|
||||
const template: MenuItemConstructorOptions[] = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user