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:
@@ -11,6 +11,8 @@
|
||||
.vault > .groupings > .content > .inner-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
--bit-sidenav-macos-extra-top-padding: 28px;
|
||||
}
|
||||
|
||||
.environment-selector-btn {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? []
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./root-font-size";
|
||||
export * from "./shared.module";
|
||||
|
||||
7
libs/components/src/shared/root-font-size.ts
Normal file
7
libs/components/src/shared/root-font-size.ts
Normal 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;
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -627,4 +627,8 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
router-outlet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user