1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 16:43:27 +00:00

[CL-971] update responsive behavior of three panel layout (#19086)

* update responsive behavior of three panel layout; give sidenav extra top padding on electron; add stories that show mix of drawer and sidenav states

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Will Martin
2026-02-23 11:56:01 -05:00
committed by GitHub
parent 27fd6be5ec
commit c5e73b4b8c
22 changed files with 601 additions and 221 deletions

View File

@@ -11,6 +11,8 @@
.vault > .groupings > .content > .inner-content {
padding-top: 0;
}
--bit-sidenav-macos-extra-top-padding: 28px;
}
.environment-selector-btn {

View File

@@ -25,7 +25,7 @@ const render: Story["render"] = (args) => ({
...args,
},
template: `
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding" disableAnimations>
<bit-dialog disableAnimations>
<span bitDialogTitle>Access selector</span>
<span bitDialogContent>
<bit-access-selector

View File

@@ -1,68 +1,66 @@
<div class="tw-mt-auto">
@let accessibleProducts = accessibleProducts$ | async;
@if (accessibleProducts && accessibleProducts.length > 1) {
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
focusAfterNavTarget="body"
>
</bit-nav-item>
}
@let accessibleProducts = accessibleProducts$ | async;
@if (accessibleProducts && accessibleProducts.length > 1) {
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
focusAfterNavTarget="body"
>
</bit-nav-item>
}
@if (shouldShowPremiumUpgradeButton$ | async) {
<app-upgrade-nav-button></app-upgrade-nav-button>
}
@if (shouldShowPremiumUpgradeButton$ | async) {
<app-upgrade-nav-button></app-upgrade-nav-button>
}
@let moreProducts = moreProducts$ | async;
@if (moreProducts && moreProducts.length > 0) {
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<ng-container *ngFor="let more of moreProducts">
<div class="tw-ps-2 tw-pe-2">
<!-- <a> for when the marketing route is external -->
<a
*ngIf="more.marketingRoute.external"
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
@let moreProducts = moreProducts$ | async;
@if (moreProducts && moreProducts.length > 0) {
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<ng-container *ngFor="let more of moreProducts">
<div class="tw-ps-2 tw-pe-2">
<!-- <a> for when the marketing route is external -->
<a
*ngIf="more.marketingRoute.external"
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</a>
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</a>
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-px-3 tw-py-2 tw-rounded-md tw-font-medium !tw-text-alt2 !tw-no-underline hover:tw-bg-hover-contrast [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</a>
</div>
</ng-container>
</section>
}
</div>
</div>
</a>
</div>
</ng-container>
</section>
}

View File

@@ -8,7 +8,7 @@ import { BehaviorSubject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { IconButtonModule, NavigationModule } from "@bitwarden/components";
import { IconButtonModule, NavigationModule, SideNavService } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";
@@ -86,6 +86,9 @@ describe("NavigationProductSwitcherComponent", () => {
beforeEach(() => {
fixture = TestBed.createComponent(NavigationProductSwitcherComponent);
// SideNavService.open starts false (managed by LayoutComponent's ResizeObserver in a real
// app). Set it to true so NavItemComponent renders text labels (used in text-content checks).
TestBed.inject(SideNavService).open.set(true);
fixture.detectChanges();
});

View File

@@ -1,8 +1,10 @@
import { Component, Directive, importProvidersFrom, Input } from "@angular/core";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BehaviorSubject, Observable, of } from "rxjs";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
@@ -17,13 +19,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import {
I18nMockService,
LayoutComponent,
NavigationModule,
StorybookGlobalStateProvider,
} from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "@bitwarden/components/src/stories/storybook-decorators";
import { GlobalStateProvider } from "@bitwarden/state";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -109,15 +111,6 @@ class MockConfigService implements Partial<ConfigService> {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
standalone: false,
})
class StoryLayoutComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -132,17 +125,23 @@ const translations: Record<string, string> = {
secureYourInfrastructure: "Secure your infrastructure",
protectYourFamilyOrBusiness: "Protect your family or business",
skipToContent: "Skip to content",
toggleSideNavigation: "Toggle side navigation",
resizeSideNavigation: "Resize side navigation",
submenu: "submenu",
toggleCollapse: "toggle collapse",
close: "Close",
loading: "Loading",
};
export default {
title: "Web/Navigation Product Switcher",
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
declarations: [
NavigationProductSwitcherComponent,
MockOrganizationService,
MockProviderService,
StoryLayoutComponent,
StoryContentComponent,
],
imports: [NavigationModule, RouterModule, LayoutComponent, I18nPipe],
@@ -174,19 +173,11 @@ export default {
}),
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(
RouterModule.forRoot([
{
path: "",
component: StoryLayoutComponent,
children: [
{
path: "**",
component: StoryContentComponent,
},
],
},
]),
RouterModule.forRoot([{ path: "**", component: StoryContentComponent }], {
useHash: true,
}),
),
{
provide: GlobalStateProvider,
@@ -203,12 +194,47 @@ type Story = StoryObj<
const Template: Story = {
render: (args) => ({
props: args,
props: { ...args, logo: PasswordManagerLogo },
template: `
<router-outlet [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></router-outlet>
<div class="tw-bg-background-alt3 tw-w-60">
<navigation-product-switcher></navigation-product-switcher>
</div>
<bit-layout>
<bit-side-nav>
<bit-nav-logo [openIcon]="logo" route="." label="Bitwarden"></bit-nav-logo>
<bit-nav-item text="Vault" icon="bwi-lock"></bit-nav-item>
<bit-nav-item text="Send" icon="bwi-send"></bit-nav-item>
<bit-nav-group text="Tools" icon="bwi-key" [open]="true">
<bit-nav-item text="Generator"></bit-nav-item>
<bit-nav-item text="Import"></bit-nav-item>
<bit-nav-item text="Export"></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Organizations" icon="bwi-business" [open]="true">
<bit-nav-item text="Acme Corp" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Acme Corp — Vault" variant="tree"></bit-nav-item>
<bit-nav-item text="Acme Corp — Members" variant="tree"></bit-nav-item>
<bit-nav-item text="Acme Corp — Settings" variant="tree"></bit-nav-item>
<bit-nav-item text="My Family" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="My Family — Vault" variant="tree"></bit-nav-item>
<bit-nav-item text="My Family — Members" variant="tree"></bit-nav-item>
<bit-nav-item text="Initech" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Initech — Vault" variant="tree"></bit-nav-item>
<bit-nav-item text="Initech — Members" variant="tree"></bit-nav-item>
<bit-nav-item text="Initech — Settings" variant="tree"></bit-nav-item>
<bit-nav-item text="Umbrella Corp" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Umbrella Corp — Vault" variant="tree"></bit-nav-item>
<bit-nav-item text="Umbrella Corp — Members" variant="tree"></bit-nav-item>
<bit-nav-item text="Umbrella Corp — Settings" variant="tree"></bit-nav-item>
<bit-nav-item text="Stark Industries" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Stark Industries — Vault" variant="tree"></bit-nav-item>
<bit-nav-item text="Stark Industries — Members" variant="tree"></bit-nav-item>
<bit-nav-item text="Stark Industries — Settings" variant="tree"></bit-nav-item>
</bit-nav-group>
<bit-nav-item text="Settings" icon="bwi-cog"></bit-nav-item>
<ng-container slot="product-switcher">
<bit-nav-divider></bit-nav-divider>
<navigation-product-switcher [mockOrgs]="mockOrgs" [mockProviders]="mockProviders"></navigation-product-switcher>
</ng-container>
</bit-side-nav>
<router-outlet></router-outlet>
</bit-layout>
`,
}),
};

View File

@@ -1,8 +1,12 @@
<bit-side-nav [variant]="variant">
<ng-content></ng-content>
<ng-container slot="footer">
<ng-container slot="product-switcher">
<bit-nav-divider></bit-nav-divider>
<navigation-product-switcher></navigation-product-switcher>
</ng-container>
<ng-container slot="footer">
<app-toggle-width></app-toggle-width>
</ng-container>
</bit-side-nav>

View File

@@ -14,7 +14,6 @@ import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } fro
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
@@ -190,7 +189,6 @@ export class DialogService {
private injector = inject(Injector);
private router = inject(Router, { optional: true });
private authService = inject(AuthService, { optional: true });
private i18nService = inject(I18nService);
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
private defaultScrollStrategy = new CustomBlockScrollStrategy();

View File

@@ -1,8 +1,10 @@
@let isDrawer = dialogRef?.isDrawer;
<!-- Storybook/Angular bugs out without this nullcheck. See the Access Selector Dialog story when removed. -->
@let widthClass = width() ?? "";
<section
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
[ngClass]="[
width(),
widthClass,
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
]"
cdkTrapFocus

View File

@@ -3,6 +3,7 @@ import { CdkScrollable } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import {
Component,
effect,
inject,
viewChild,
input,
@@ -21,6 +22,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { queryForAutofocusDescendents } from "../../input";
import { getRootFontSizePx } from "../../shared";
import { SpinnerComponent } from "../../spinner";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrollableContent$ } from "../../utils/";
@@ -28,6 +30,7 @@ import { hasScrolledFrom } from "../../utils/has-scrolled-from";
import { DialogRef } from "../dialog.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
import { DrawerService } from "../drawer.service";
type DialogSize = "small" | "default" | "large";
@@ -43,6 +46,13 @@ const drawerSizeToWidth = {
large: "md:tw-max-w-2xl",
} as const;
/** Width in rem for each drawer size, used to declare push-mode column widths. */
export const drawerSizeToWidthRem: Record<string, number> = {
small: 24,
default: 32,
large: 42,
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -69,6 +79,18 @@ export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
private readonly ngZone = inject(NgZone);
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly drawerService = inject(DrawerService);
constructor() {
effect(() => {
if (!this.dialogRef?.isDrawer) {
return;
}
const size = this.dialogSize();
const rootFontSizePx = getRootFontSizePx();
this.drawerService.declarePushWidth((drawerSizeToWidthRem[size] ?? 32) * rootFontSizePx);
});
}
private readonly dialogHeader =
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
@@ -122,23 +144,31 @@ export class DialogComponent implements AfterViewInit {
private readonly animationCompleted = signal(false);
/** Max width class */
protected readonly width = computed(() => {
const size = this.dialogSize() ?? "default";
const isDrawer = this.dialogRef?.isDrawer;
const size = this.dialogSize();
if (isDrawer) {
return drawerSizeToWidth[size];
if (this.dialogRef?.isDrawer) {
return this.drawerService.isPushMode() ? drawerSizeToWidth[size] : "";
}
return dialogSizeToWidth[size];
});
protected readonly classes = computed(() => {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"];
const isDrawer = this.dialogRef?.isDrawer;
// Drawers use tw-w-full (100% of column) so the element fills its grid track
// without overflowing — the column itself is capped by the grid template.
// Regular dialogs use tw-w-screen for full-width mobile presentation.
const widthClass = isDrawer ? "tw-w-full" : "tw-w-screen";
const baseClasses = ["tw-flex", "tw-flex-col", widthClass];
const sizeClasses = isDrawer
? ["tw-h-full"]
: [
"md:tw-p-4",
"tw-max-h-[90vh]", // needed to prevent dialogs from overlapping the desktop header
];
const size = this.dialogSize() ?? "default";
const size = this.dialogSize();
const animationClasses =
this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer
? []

View File

@@ -3,18 +3,40 @@ import { Injectable, signal } from "@angular/core";
@Injectable({ providedIn: "root" })
export class DrawerService {
private readonly _portal = signal<Portal<unknown> | undefined>(undefined);
/** The portal to display */
portal = this._portal.asReadonly();
readonly portal = signal<Portal<unknown> | undefined>(undefined);
/**
* The drawer's preferred push-mode column width in px.
* Declared by the drawer content (e.g. bit-dialog) via declarePushWidth().
* Zero when no drawer is active or the width has not been declared yet.
*/
readonly pushWidthPx = signal(0);
/**
* Whether the drawer is currently in push mode (occupying its own grid column).
* Set by LayoutComponent via ResizeObserver; read by the drawer content for display.
*/
readonly isPushMode = signal(false);
open(portal: Portal<unknown>) {
this._portal.set(portal);
this.portal.set(portal);
}
close(portal: Portal<unknown>) {
if (portal === this.portal()) {
this._portal.set(undefined);
this.portal.set(undefined);
this.pushWidthPx.set(0);
this.isPushMode.set(false);
}
}
/**
* Called by drawer content components (e.g. bit-dialog) to declare their natural
* push-mode column width so LayoutComponent can make accurate push/overlay decisions
* without measuring the DOM (which is unreliable when the column is 1fr).
*/
declarePushWidth(px: number) {
this.pushWidthPx.set(px);
}
}

View File

@@ -1,45 +1,68 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
<div class="tw-flex tw-size-full" 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"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
#skipLink
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</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"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>
</main>
<!-- overlay backdrop for side-nav -->
<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"
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
>
@if (sideNavService.open()) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
<div
#container
class="tw-grid tw-relative tw-size-full tw-overflow-hidden tw-grid-rows-[minmax(0,1fr)]"
[style.grid-template-columns]="gridTemplateColumns()"
[class.tw-bg-background-alt3]="rounded()"
cdkTrapFocus
>
<!-- Skip-to-content link -->
<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"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
#skipLink
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
<!-- Col 1 -->
<ng-content select="bit-side-nav, [slot=side-nav]">
<ng-container #sideNavSlotFallback></ng-container>
</ng-content>
<!-- Siderail width placeholder — keeps the col 1 auto track stable when the nav
is position:fixed (overlay) and therefore out of the grid's normal flow. -->
@if (sideNavService.isOverlay() && siderailIsPushMode()) {
<div
aria-hidden="true"
class="tw-pointer-events-none tw-w-[3.75rem] tw-mx-0.5 tw-col-start-1 tw-row-start-1"
></div>
}
<!-- Main content (always col 2) -->
<main
#main
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="tw-col-start-2 tw-row-start-1 tw-overflow-auto tw-max-h-full tw-min-w-96 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>
</main>
<!-- Overlay backdrop for side-nav (fixed, z-40 — below nav overlay z-50) -->
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-40 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors"
[class.tw-bg-opacity-30]="sideNavService.isOverlay()"
>
@if (sideNavService.isOverlay()) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
<!-- Drawer (always col 3; col 3 track is auto when push, 1fr when overlay, 0px when closed) -->
<div #drawerContainer class="tw-col-start-3 tw-row-start-1 tw-relative tw-z-30 tw-h-full">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>
</div>

View File

@@ -1,16 +1,35 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import {
afterNextRender,
booleanAttribute,
Component,
computed,
DestroyRef,
ElementRef,
inject,
input,
signal,
viewChild,
} from "@angular/core";
import { RouterModule } from "@angular/router";
import { drawerSizeToWidthRem } from "../dialog/dialog/dialog.component";
import { DrawerService } from "../dialog/drawer.service";
import { LinkComponent, LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
import { getRootFontSizePx, SharedModule } from "../shared";
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
/** Matches tw-min-w-96 on <main>. */
const MAIN_MIN_WIDTH_REM = 24;
/** Approximate rendered width of the closed nav (siderail / icon strip).
* Derived from tw-w-[3.75rem] + tw-mx-0.5 margins in side-nav.component.html. */
const SIDERAIL_WIDTH_REM = 4;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -33,9 +52,217 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
})
export class LayoutComponent {
protected sideNavService = inject(SideNavService);
protected drawerPortal = inject(DrawerService).portal;
private readonly drawerService = inject(DrawerService);
protected drawerPortal = this.drawerService.portal;
/** Rendered only when nothing is projected into the side-nav slot (ng-content fallback). */
private readonly sideNavSlotFallback = viewChild<ElementRef>("sideNavSlotFallback");
protected readonly hasSideNav = computed(() => this.sideNavSlotFallback() == null);
/**
* True as soon as a portal is active; false when no drawer is open.
* Derived directly from the portal signal so col 3 gets a non-zero track
* immediately on open — without waiting for the ResizeObserver to fire.
* This breaks the chicken-and-egg: col 3 = 0px → no resize event → drawer
* never appears.
*/
private readonly drawerIsActive = computed(() => this.drawerPortal() != null);
private readonly destroyRef = inject(DestroyRef);
private readonly container = viewChild.required<ElementRef<HTMLElement>>("container");
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
private readonly drawerContainer = viewChild.required<ElementRef<HTMLElement>>("drawerContainer");
/**
* Container width in px, updated by the ResizeObserver on every layout change.
* Exposed as a signal so gridTemplateColumns can reactively compute push vs
* overlay for the drawer without waiting for a ResizeObserver tick.
*/
private readonly containerWidthPx = signal(0);
/**
* Whether the siderail (closed-nav icon strip) fits in its own column.
* Has a lower threshold than full-nav isPushMode because the siderail is
* much narrower — it should remain visible on intermediate viewport widths.
*/
protected readonly siderailIsPushMode = signal(false);
/**
* The CSS grid-template-columns value for the three-panel layout.
*
* Column 1 (nav): navWidthRem when nav is push+open
* auto when nav is push+closed (icon strip) OR
* when only the siderail fits; a dummy placeholder
* div keeps col 1 stable when the nav is fixed (overlay)
* 0px when even the siderail doesn't fit
* Column 2 (main): minmax(mainMinWidthPx, 1fr) normally — the minmax base reserves
* space for main so CSS grid can shrink col 3 without JS arithmetic;
* 0px when drawer is in overlay mode (drawer takes the full row)
* Column 3 (drawer): auto when push (CSS shrinks naturally from declared max down to
* drawerMinPushWidthPx before JS switches to overlay);
* 1fr when overlay (takes over main's space); 0px when no drawer
*/
protected readonly gridTemplateColumns = computed(() => {
const navOpen = this.sideNavService.open();
const navPush = this.sideNavService.isPushMode();
const siderailPush = this.siderailIsPushMode();
// --- Drawer push/shrink/overlay ---
const drawerActive = this.drawerIsActive();
const declaredDrawerWidth = this.drawerService.pushWidthPx();
const containerWidth = this.containerWidthPx();
const rootFontSizePx = getRootFontSizePx();
const siderailWidthPx = SIDERAIL_WIDTH_REM * rootFontSizePx;
const drawerMinWidthPx = drawerSizeToWidthRem.small * rootFontSizePx;
const mainMinWidthPx = MAIN_MIN_WIDTH_REM * rootFontSizePx;
// Push vs overlay: switch to overlay only when the minimum push width won't fit.
// The shrink zone between the declared max-width and the minimum is handled
// entirely by CSS grid: col2 uses minmax(mainMinWidthPx, 1fr) so its base
// size reserves space for main before col3 auto grows. When the container
// shrinks, col3 naturally receives less free space and narrows without any JS
// pixel arithmetic.
//
// dialog.component declares its push width via an effect() that runs during
// Angular's CD — before the ResizeObserver fires and before the browser paints.
// Falls back to the ResizeObserver-driven signal when not yet declared.
let drawerPush: boolean;
if (!drawerActive) {
drawerPush = false;
} else if (declaredDrawerWidth > 0 && containerWidth > 0) {
drawerPush = containerWidth - siderailWidthPx - drawerMinWidthPx >= mainMinWidthPx;
} else {
drawerPush = this.drawerService.isPushMode();
}
// --- Col 1 (nav / siderail) ---
// When the nav enters overlay mode (position:fixed) it leaves the grid's normal
// flow. A dummy placeholder div in the template keeps the col 1 auto track
// stable without needing an explicit px value here.
let col1: string;
if (!this.hasSideNav()) {
col1 = "0px"; // no side nav projected — collapse the column entirely
} else if (navOpen && navPush) {
col1 = `${this.sideNavService.widthRem()}rem`; // full nav, push+open
} else if (navPush || siderailPush) {
col1 = "auto"; // siderail in flow, size naturally
} else {
col1 = "0px"; // viewport too narrow even for siderail
}
// col3: minmax(0px, declaredMax) instead of "auto" so the track is sized by its
// explicit bounds rather than by the item's content-based size. This lets CSS
// grid shrink the drawer column down to 0 when the available space is limited,
// while col2's minmax base reserves mainMinWidthPx for main first.
// The dialog uses tw-w-full so it fills the column without overflowing it.
let col3: string;
if (!drawerActive) {
col3 = "0px";
} else if (!drawerPush) {
col3 = "1fr";
} else if (declaredDrawerWidth > 0) {
col3 = `minmax(0px, ${declaredDrawerWidth}px)`;
} else {
col3 = "auto"; // fallback before dialog's effect declares its width
}
const col2 = !drawerActive || drawerPush ? `minmax(${mainMinWidthPx}px, 1fr)` : "0px";
return `${col1} ${col2} ${col3}`;
});
constructor() {
afterNextRender(() => {
const container = this.container().nativeElement;
const drawerContainer = this.drawerContainer().nativeElement;
const update = () => {
const rootFontSizePx = getRootFontSizePx();
const containerWidth = container.clientWidth;
const siderailPx = SIDERAIL_WIDTH_REM * rootFontSizePx;
const mainMinPx = MAIN_MIN_WIDTH_REM * rootFontSizePx;
const navWidthPx = this.sideNavService.widthRem() * rootFontSizePx;
const drawerMinPx = drawerSizeToWidthRem.small * rootFontSizePx;
this.containerWidthPx.set(containerWidth);
// Use the push width declared by the drawer content (e.g. bit-dialog) via
// DrawerService.declarePushWidth(). This is more reliable than DOM measurement
// because the drawerContainer's firstElementChild is the outer portal host
// component (e.g. app-vault-item), which fills the full 1fr column in overlay
// mode — making its offsetWidth useless for push-vs-overlay decisions.
const drawerWidthPx = this.drawerService.pushWidthPx();
// Can the full nav push alongside main (ignoring the drawer)?
const navAloneCanPush = containerWidth - navWidthPx >= mainMinPx;
// Can the drawer push at full width with the full nav?
const drawerFullWidthNavCanPush =
drawerWidthPx > 0 && containerWidth - navWidthPx - drawerWidthPx >= mainMinPx;
// Can the drawer push at full width with just the siderail?
const drawerFullWidthSiderailCanPush =
drawerWidthPx > 0 && containerWidth - siderailPx - drawerWidthPx >= mainMinPx;
// Can the drawer push at minimum width with the full nav (shrink zone)?
const drawerMinWithNavCanPush =
drawerWidthPx > 0 && containerWidth - navWidthPx - drawerMinPx >= mainMinPx;
// Can the drawer push at minimum width with just the siderail (shrink zone)?
const drawerMinWithSiderailCanPush =
drawerWidthPx > 0 && containerWidth - siderailPx - drawerMinPx >= mainMinPx;
// When the drawer is open and space is limited, the full nav yields first —
// it closes to its siderail so the drawer can remain in push mode. When even
// the minimum push width doesn't fit, the drawer goes overlay.
// drawerPush: true if the drawer fits at any width (full or shrunk) alongside either nav or siderail.
// navPush: true if the full nav fits alongside the drawer; false if only the siderail fits.
// When no drawer is active, falls back to navAloneCanPush.
const drawerPush =
drawerFullWidthNavCanPush ||
drawerFullWidthSiderailCanPush ||
drawerMinWithNavCanPush ||
drawerMinWithSiderailCanPush;
const navPush = drawerPush
? drawerFullWidthNavCanPush || drawerMinWithNavCanPush
: navAloneCanPush && drawerWidthPx === 0;
// In shrink-push mode the drawer occupies less than its declared max, so use
// the actual available space as the effective drawer width for the siderail check.
const drawerEffectivePx = drawerPush
? Math.min(drawerWidthPx, Math.max(0, containerWidth - siderailPx - mainMinPx))
: 0;
const siderailCanPush = drawerPush
? containerWidth - siderailPx - drawerEffectivePx >= mainMinPx
: containerWidth - siderailPx >= mainMinPx;
const wasInPushMode = this.sideNavService.isPushMode();
// Transitioning out of push mode → close the nav.
// (If already in overlay and open, leave it — it's intentionally overlaying content.)
if (!navPush && this.sideNavService.open() && wasInPushMode) {
this.sideNavService.open.set(false);
}
// Transitioning into push mode → reopen unless the user explicitly closed it.
if (
navPush &&
!wasInPushMode &&
this.sideNavService.userCollapsePreference() !== "closed"
) {
this.sideNavService.open.set(true);
}
this.sideNavService.isPushMode.set(navPush);
this.siderailIsPushMode.set(siderailCanPush);
this.drawerService.isPushMode.set(drawerPush);
};
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
resizeObserver.observe(drawerContainer);
this.destroyRef.onDestroy(() => resizeObserver.disconnect());
});
}
/**
* Rounded top left corner for the main content area

View File

@@ -146,28 +146,3 @@ export const ForceActiveStyles: Story = {
`,
}),
};
export const CollapsedNavItems: Story = {
render: (args) => ({
props: args,
template: `
<bit-nav-item text="First Nav" icon="bwi-collection-shared"></bit-nav-item>
<bit-nav-item text="Active Nav" icon="bwi-collection-shared" [forceActiveStyles]="true"></bit-nav-item>
<bit-nav-item text="Third Nav" icon="bwi-collection-shared"></bit-nav-item>
`,
}),
play: async () => {
const toggleButton = document.querySelector(
"[aria-label='Toggle side navigation']",
) as HTMLButtonElement;
if (toggleButton) {
toggleButton.click();
}
},
parameters: {
chromatic: {
delay: 1000,
},
},
};

View File

@@ -1,5 +1,5 @@
<div
class="tw-px-2 tw-pt-2"
class="tw-px-2 tw-pt-[calc(0.5rem_+_var(--bit-sidenav-macos-extra-top-padding,_0px))] tw-bg-bg-sidenav"
[class]="
sideNavService.open()
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'

View File

@@ -12,6 +12,7 @@ import { SideNavService } from "./side-nav.service";
templateUrl: "./nav-logo.component.html",
imports: [RouterLinkActive, RouterLink, SvgComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: "tw-contents" },
})
export class NavLogoComponent {
protected readonly sideNavService = inject(SideNavService);

View File

@@ -4,7 +4,7 @@
<div class="tw-relative tw-h-full">
<nav
id="bit-side-nav"
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overflow-hidden tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
[style]="
variant() === 'secondary'
@@ -16,12 +16,24 @@
[attr.aria-modal]="isOverlay ? 'true' : null"
(keydown)="handleKeyDown($event)"
>
<ng-content></ng-content>
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
class="tw-flex-1 tw-min-h-0 tw-overflow-auto tw-overscroll-none"
style="container-type: size"
>
<div class="tw-flex tw-flex-col tw-min-h-full">
<div class="tw-flex-1">
<ng-content></ng-content>
</div>
@if (open) {
<div
class="tw-w-full tw-bg-bg-sidenav [@container_(min-height:600px)]:tw-sticky [@container_(min-height:600px)]:tw-bottom-0"
>
<ng-content select="[slot=product-switcher]"></ng-content>
</div>
}
</div>
</div>
<div class="tw-w-full tw-bg-bg-sidenav">
<bit-nav-divider></bit-nav-divider>
@if (open) {
<ng-content select="[slot=footer]"></ng-content>

View File

@@ -34,7 +34,12 @@ export type SideNavVariant = "primary" | "secondary";
AsyncPipe,
],
host: {
class: "tw-block tw-h-full",
// Grid placement: always col 1. In overlay mode the element is also
// switched to position:fixed so it escapes the grid's stacking context
// and renders above the scrim (z-40) and the drawer.
class: "tw-block tw-h-full tw-col-start-1 tw-row-start-1",
"[class]":
"sideNavService.isOverlay() ? 'tw-fixed tw-top-0 tw-bottom-0 tw-left-0 tw-z-50' : ''",
},
changeDetection: ChangeDetectionStrategy.OnPush,
})

View File

@@ -1,12 +1,10 @@
import { computed, effect, inject, Injectable, signal } from "@angular/core";
import { computed, inject, Injectable, signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
type CollapsePreference = "open" | "closed" | null;
import { getRootFontSizePx } from "../shared";
const BIT_SIDE_NAV_WIDTH_KEY_DEF = new KeyDefinition<number>(BIT_SIDE_NAV_DISK, "side-nav-width", {
deserializer: (s) => s,
@@ -26,14 +24,24 @@ export class SideNavService {
/**
* Whether the side navigation is open or closed.
*/
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
readonly open = signal(false);
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
/**
* Whether the nav is in push mode (occupies its own grid column).
* Set by LayoutComponent via ResizeObserver.
*/
readonly isPushMode = signal(false);
readonly userCollapsePreference = signal<CollapsePreference>(null);
/**
* True when the nav is open but not in push mode — it overlays the content.
*/
readonly isOverlay = computed(() => this.open() && !this.isPushMode());
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
/**
* Explicit user preference for open/closed state, set when the user manually
* toggles the nav. Null means no preference (auto-open when push mode allows).
*/
readonly userCollapsePreference = signal<"open" | "closed" | null>(null);
/**
* Local component state width
@@ -43,6 +51,9 @@ export class SideNavService {
private readonly _width$ = new BehaviorSubject<number>(this.DEFAULT_OPEN_WIDTH);
readonly width$ = this._width$.asObservable();
/** Current nav width as a signal, for use in grid column calculations. */
readonly widthRem = toSignal(this.width$, { initialValue: this.DEFAULT_OPEN_WIDTH });
/**
* State provider width
*
@@ -56,17 +67,7 @@ export class SideNavService {
constructor() {
// Get computed root font size to support user-defined a11y font increases
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
// Handle open/close state
effect(() => {
if (!this.isLargeScreen()) {
this.open.set(false);
} else if (this.userCollapsePreference() !== "closed") {
// Auto-open when user hasn't set preference (null) or prefers open
this.open.set(true);
}
});
this.rootFontSizePx = getRootFontSizePx();
// Initialize the resizable width from state provider
this.widthState$.pipe(first()).subscribe((width: number) => {
@@ -83,9 +84,7 @@ export class SideNavService {
* Toggle the open/close state of the side nav
*/
toggle() {
// Store user's preference based on what state they're toggling TO
this.userCollapsePreference.set(this.open() ? "closed" : "open");
this.open.set(!this.open());
}

View File

@@ -1 +1,2 @@
export * from "./root-font-size";
export * from "./shared.module";

View File

@@ -0,0 +1,7 @@
/**
* Returns the root font size in pixels, falling back to 16 if unavailable (e.g. SSR).
*/
export const getRootFontSizePx = (): number =>
typeof document !== "undefined"
? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
: 16;

View File

@@ -2,14 +2,7 @@ import { importProvidersFrom } from "@angular/core";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import {
userEvent,
getAllByRole,
getByRole,
fireEvent,
getByText,
getAllByLabelText,
} from "storybook/test";
import { userEvent, getAllByRole, getByRole, fireEvent, getAllByLabelText } from "storybook/test";
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -90,6 +83,22 @@ export default {
type Story = StoryObj<LayoutComponent>;
type KitchenSinkRoute = "/bitwarden" | "/virtual-scroll";
async function navigateTo(path: KitchenSinkRoute) {
window.location.hash = path;
await new Promise((resolve) => setTimeout(resolve, 50));
}
/** Waits for the ResizeObserver + Angular CD to settle, then opens the side nav if it's closed. */
async function openSideNav(canvas: HTMLElement) {
await new Promise((resolve) => setTimeout(resolve, 200));
const toggleButton = getByRole(canvas, "button", { name: "Toggle side navigation" });
if (toggleButton.getAttribute("aria-expanded") === "false") {
await userEvent.click(toggleButton);
}
}
export const Default: Story = {
render: (args) => {
return {
@@ -130,6 +139,7 @@ export const MenuOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const table = getByRole(canvas, "table");
const menuButton = getAllByRole(table, "button")[0];
@@ -144,6 +154,7 @@ export const DialogOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const dialogButton = getByRole(canvas, "button", {
name: "Open Dialog",
});
@@ -157,6 +168,7 @@ export const DrawerOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const drawerButton = getByRole(canvas, "button", {
name: "Open Drawer",
});
@@ -170,6 +182,7 @@ export const PopoverOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const popoverLink = getByRole(canvas, "button", {
name: "Popover trigger link",
});
@@ -182,6 +195,7 @@ export const SimpleDialogOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const submitButton = getByRole(canvas, "button", {
name: "Submit",
});
@@ -195,6 +209,7 @@ export const EmptyTab: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
const emptyTab = getByRole(canvas, "tab", { name: "Empty tab" });
await userEvent.click(emptyTab);
},
@@ -204,8 +219,7 @@ export const VirtualScrollBlockingDialog: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
const navItem = getByText(canvas, "Virtual Scroll");
await userEvent.click(navItem);
await navigateTo("/virtual-scroll");
const htmlEl = canvas.ownerDocument.documentElement;
htmlEl.scrollTop = 2000;
@@ -216,11 +230,38 @@ export const VirtualScrollBlockingDialog: Story = {
},
};
export const SideNavOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
await navigateTo("/bitwarden");
await openSideNav(canvas);
},
parameters: {
chromatic: { viewports: [640, 1024, 1280] },
},
};
export const DrawerOpenBeforeSideNavOpen: Story = {
render: Default.render,
play: async (context) => {
const canvas = context.canvasElement;
// workaround for userEvent not firing in FF https://github.com/testing-library/user-event/issues/1075
await fireEvent.click(getByRole(canvas, "button", { name: "Open Drawer" }));
await navigateTo("/bitwarden");
await openSideNav(canvas);
},
parameters: {
chromatic: { viewports: [640, 1024, 1280, 1440] },
},
};
export const ResponsiveSidebar: Story = {
render: Default.render,
parameters: {
chromatic: {
viewports: [640, 1280],
viewports: [640, 1024, 1280, 1440],
},
},
};

View File

@@ -627,4 +627,8 @@
height: 100%;
overflow: hidden;
}
router-outlet {
display: none;
}
}