diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 316bb23c4f8..ab1c6377e6a 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -4888,5 +4888,14 @@
},
"beta": {
"message": "Beta"
+ },
+ "extensionWidth": {
+ "message": "Extension width"
+ },
+ "wide": {
+ "message": "Wide"
+ },
+ "extraWide": {
+ "message": "Extra wide"
}
}
diff --git a/apps/browser/src/platform/popup/browser-popup-utils.ts b/apps/browser/src/platform/popup/browser-popup-utils.ts
index fb53d3451f2..83fde24ac6f 100644
--- a/apps/browser/src/platform/popup/browser-popup-utils.ts
+++ b/apps/browser/src/platform/popup/browser-popup-utils.ts
@@ -1,6 +1,7 @@
import { BrowserApi } from "../browser/browser-api";
import { ScrollOptions } from "./abstractions/browser-popup-utils.abstractions";
+import { PopupWidthOptions } from "./layout/popup-width.service";
class BrowserPopupUtils {
/**
@@ -108,7 +109,7 @@ class BrowserPopupUtils {
const defaultPopoutWindowOptions: chrome.windows.CreateData = {
type: "popup",
focused: true,
- width: 380,
+ width: Math.max(PopupWidthOptions.default, document.body.clientWidth),
height: 630,
};
const offsetRight = 15;
diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
index e80ac249ac1..1aaea85e4a1 100644
--- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
+++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts
@@ -514,3 +514,25 @@ export const TransparentHeader: Story = {
`,
}),
};
+
+export const WidthOptions: Story = {
+ render: (args) => ({
+ props: args,
+ template: /* HTML */ `
+
+
Default:
+
+
+
+
Wide:
+
+
+
+
Extra wide:
+
+
+
+
+ `,
+ }),
+};
diff --git a/apps/browser/src/platform/popup/layout/popup-width.service.ts b/apps/browser/src/platform/popup/layout/popup-width.service.ts
new file mode 100644
index 00000000000..cc41b1d9d4a
--- /dev/null
+++ b/apps/browser/src/platform/popup/layout/popup-width.service.ts
@@ -0,0 +1,63 @@
+import { inject, Injectable } from "@angular/core";
+import { map, Observable } from "rxjs";
+
+import {
+ GlobalStateProvider,
+ KeyDefinition,
+ POPUP_STYLE_DISK,
+} from "@bitwarden/common/platform/state";
+
+/**
+ *
+ * Value represents width in pixels
+ */
+export const PopupWidthOptions = Object.freeze({
+ default: 380,
+ wide: 480,
+ "extra-wide": 600,
+});
+
+type PopupWidthOptions = typeof PopupWidthOptions;
+export type PopupWidthOption = keyof PopupWidthOptions;
+
+const POPUP_WIDTH_KEY_DEF = new KeyDefinition(POPUP_STYLE_DISK, "popup-width", {
+ deserializer: (s) => s,
+});
+
+/**
+ * Updates the extension popup width based on a user setting
+ **/
+@Injectable({ providedIn: "root" })
+export class PopupWidthService {
+ private static readonly LocalStorageKey = "bw-popup-width";
+ private readonly state = inject(GlobalStateProvider).get(POPUP_WIDTH_KEY_DEF);
+
+ readonly width$: Observable = this.state.state$.pipe(
+ map((state) => state ?? "default"),
+ );
+
+ async setWidth(width: PopupWidthOption) {
+ await this.state.update(() => width);
+ }
+
+ /** Begin listening for state changes */
+ init() {
+ this.width$.subscribe((width: PopupWidthOption) => {
+ PopupWidthService.setStyle(width);
+ localStorage.setItem(PopupWidthService.LocalStorageKey, width);
+ });
+ }
+
+ private static setStyle(width: PopupWidthOption) {
+ const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default;
+ document.body.style.width = `${pxWidth}px`;
+ }
+
+ /**
+ * To keep the popup size from flickering on bootstrap, we store the width in `localStorage` so we can quickly & synchronously reference it.
+ **/
+ static initBodyWidthFromLocalStorage() {
+ const storedValue = localStorage.getItem(PopupWidthService.LocalStorageKey);
+ this.setStyle(storedValue as any);
+ }
+}
diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts
index c23da5ca7ce..31f610b7e74 100644
--- a/apps/browser/src/popup/app.component.ts
+++ b/apps/browser/src/popup/app.component.ts
@@ -25,6 +25,7 @@ import {
import { flagEnabled } from "../platform/flags";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
+import { PopupWidthService } from "../platform/popup/layout/popup-width.service";
import { PopupViewCacheService } from "../platform/popup/view-cache/popup-view-cache.service";
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
import { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service";
@@ -44,6 +45,7 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
export class AppComponent implements OnInit, OnDestroy {
private viewCacheService = inject(PopupViewCacheService);
private compactModeService = inject(PopupCompactModeService);
+ private widthService = inject(PopupWidthService);
private lastActivity: Date;
private activeUserId: UserId;
@@ -99,6 +101,7 @@ export class AppComponent implements OnInit, OnDestroy {
await this.viewCacheService.init();
this.compactModeService.init();
+ this.widthService.init();
// Component states must not persist between closing and reopening the popup, otherwise they become dead objects
// Clear them aggressively to make sure this doesn't occur
diff --git a/apps/browser/src/popup/main.ts b/apps/browser/src/popup/main.ts
index c98e30e6bca..db634ea2e2c 100644
--- a/apps/browser/src/popup/main.ts
+++ b/apps/browser/src/popup/main.ts
@@ -1,6 +1,7 @@
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
+import { PopupWidthService } from "../platform/popup/layout/popup-width.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
require("./scss/popup.scss");
@@ -8,7 +9,8 @@ require("./scss/tailwind.css");
import { AppModule } from "./app.module";
-// We put this first to minimize the delay in window changing.
+// We put these first to minimize the delay in window changing.
+PopupWidthService.initBodyWidthFromLocalStorage();
// Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861]
if (BrowserPlatformUtilsService.shouldApplySafariHeightFix(window)) {
document.documentElement.classList.add("safari_height_fix");
diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss
index 89b8816567d..9412b911ee2 100644
--- a/apps/browser/src/popup/scss/base.scss
+++ b/apps/browser/src/popup/scss/base.scss
@@ -19,7 +19,7 @@ body {
}
body {
- width: 380px !important;
+ min-width: 380px !important;
height: 600px !important;
position: relative;
min-height: 100vh;
diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance-v2.component.html
index 466cffa216a..0fc6297b503 100644
--- a/apps/browser/src/vault/popup/settings/appearance-v2.component.html
+++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.html
@@ -18,6 +18,11 @@
+
+ {{ "extensionWidth" | i18n }}
+
+
+
{
const setEnableRoutingAnimation = jest.fn().mockResolvedValue(undefined);
const setEnableCompactMode = jest.fn().mockResolvedValue(undefined);
+ const mockWidthService: Partial = {
+ width$: new BehaviorSubject("default"),
+ setWidth: jest.fn().mockResolvedValue(undefined),
+ };
+
beforeEach(async () => {
setSelectedTheme.mockClear();
setShowFavicons.mockClear();
@@ -78,6 +84,10 @@ describe("AppearanceV2Component", () => {
provide: PopupCompactModeService,
useValue: { enabled$: enableCompactMode$, setEnabled: setEnableCompactMode },
},
+ {
+ provide: PopupWidthService,
+ useValue: mockWidthService,
+ },
],
})
.overrideComponent(AppearanceV2Component, {
@@ -102,6 +112,7 @@ describe("AppearanceV2Component", () => {
enableBadgeCounter: true,
theme: ThemeType.Nord,
enableCompactMode: false,
+ width: "default",
});
});
diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
index c20bc48bfea..31f7f31148f 100644
--- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
+++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts
@@ -12,7 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
-import { BadgeModule, CheckboxModule } from "@bitwarden/components";
+import { BadgeModule, CheckboxModule, Option } from "@bitwarden/components";
import { CardComponent } from "../../../../../../libs/components/src/card/card.component";
import { FormFieldModule } from "../../../../../../libs/components/src/form-field/form-field.module";
@@ -21,6 +21,10 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupCompactModeService } from "../../../platform/popup/layout/popup-compact-mode.service";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
+import {
+ PopupWidthOption,
+ PopupWidthService,
+} from "../../../platform/popup/layout/popup-width.service";
@Component({
standalone: true,
@@ -41,6 +45,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
})
export class AppearanceV2Component implements OnInit {
private compactModeService = inject(PopupCompactModeService);
+ private popupWidthService = inject(PopupWidthService);
+ private i18nService = inject(I18nService);
appearanceForm = this.formBuilder.group({
enableFavicon: false,
@@ -48,6 +54,7 @@ export class AppearanceV2Component implements OnInit {
theme: ThemeType.System,
enableAnimations: true,
enableCompactMode: false,
+ width: "default" as PopupWidthOption,
});
/** To avoid flashes of inaccurate values, only show the form after the entire form is populated. */
@@ -56,6 +63,13 @@ export class AppearanceV2Component implements OnInit {
/** Available theme options */
themeOptions: { name: string; value: ThemeType }[];
+ /** Available width options */
+ protected readonly widthOptions: Option[] = [
+ { label: this.i18nService.t("default"), value: "default" },
+ { label: this.i18nService.t("wide"), value: "wide" },
+ { label: this.i18nService.t("extraWide"), value: "extra-wide" },
+ ];
+
constructor(
private messagingService: MessagingService,
private domainSettingsService: DomainSettingsService,
@@ -81,6 +95,7 @@ export class AppearanceV2Component implements OnInit {
this.animationControlService.enableRoutingAnimation$,
);
const enableCompactMode = await firstValueFrom(this.compactModeService.enabled$);
+ const width = await firstValueFrom(this.popupWidthService.width$);
// Set initial values for the form
this.appearanceForm.setValue({
@@ -89,6 +104,7 @@ export class AppearanceV2Component implements OnInit {
theme,
enableAnimations,
enableCompactMode,
+ width,
});
this.formLoading = false;
@@ -122,6 +138,12 @@ export class AppearanceV2Component implements OnInit {
.subscribe((enableCompactMode) => {
void this.updateCompactMode(enableCompactMode);
});
+
+ this.appearanceForm.controls.width.valueChanges
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((width) => {
+ void this.updateWidth(width);
+ });
}
async updateFavicon(enableFavicon: boolean) {
@@ -144,4 +166,8 @@ export class AppearanceV2Component implements OnInit {
async updateCompactMode(enableCompactMode: boolean) {
await this.compactModeService.setEnabled(enableCompactMode);
}
+
+ async updateWidth(width: PopupWidthOption) {
+ await this.popupWidthService.setWidth(width);
+ }
}
diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts
index 0779d80982b..a600901df4f 100644
--- a/libs/common/src/platform/state/state-definitions.ts
+++ b/libs/common/src/platform/state/state-definitions.ts
@@ -121,6 +121,10 @@ export const TRANSLATION_DISK = new StateDefinition("translation", "disk", { web
export const ANIMATION_DISK = new StateDefinition("animation", "disk");
export const TASK_SCHEDULER_DISK = new StateDefinition("taskScheduler", "disk");
+// Design System
+
+export const POPUP_STYLE_DISK = new StateDefinition("popupStyle", "disk");
+
// Secrets Manager
export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
diff --git a/libs/components/src/select/index.ts b/libs/components/src/select/index.ts
index faebfee9bb8..8ef9993efda 100644
--- a/libs/components/src/select/index.ts
+++ b/libs/components/src/select/index.ts
@@ -1,3 +1,4 @@
export * from "./select.module";
export * from "./select.component";
+export * from "./option";
export * from "./option.component";