1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[CL-118][CL-164][PM-8019] collapsible side navigation (#6383)

This commit is contained in:
Will Martin
2024-06-17 14:10:50 -04:00
committed by GitHub
parent 3bfdc50d5d
commit 06410a0633
30 changed files with 624 additions and 184 deletions

View File

@@ -2876,6 +2876,9 @@
"message": "Turn off master password re-prompt to edit this field", "message": "Turn off master password re-prompt to edit this field",
"description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item."
}, },
"toggleSideNavigation": {
"message": "Toggle side navigation"
},
"skipToContent": { "skipToContent": {
"message": "Skip to content" "message": "Skip to content"
}, },

View File

@@ -2692,6 +2692,9 @@
"submenu": { "submenu": {
"message": "Submenu" "message": "Submenu"
}, },
"toggleSideNavigation": {
"message": "Toggle side navigation"
},
"skipToContent": { "skipToContent": {
"message": "Skip to content" "message": "Skip to content"
}, },

View File

@@ -1,12 +1,6 @@
<bit-layout variant="secondary"> <bit-layout>
<nav <bit-side-nav variant="secondary" *ngIf="organization$ | async as organization">
slot="sidebar" <bit-nav-logo [openIcon]="logo" route="." [label]="'adminConsole' | i18n"></bit-nav-logo>
*ngIf="organization$ | async as organization"
class="tw-flex tw-flex-col tw-h-full"
>
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'adminConsole' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher> <org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-item <bit-nav-item
@@ -110,10 +104,11 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher> <ng-container slot="footer">
<navigation-product-switcher></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </ng-container>
</bit-side-nav>
<ng-container *ngIf="organization$ | async as organization"> <ng-container *ngIf="organization$ | async as organization">
<bit-banner <bit-banner

View File

@@ -12,7 +12,7 @@
<ng-container *ngIf="moreProducts$ | async as moreProducts"> <ng-container *ngIf="moreProducts$ | async as moreProducts">
<section <section
*ngIf="moreProducts.length > 0" *ngIf="moreProducts.length > 0"
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-alt2" 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> <span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<a <a

View File

@@ -13,6 +13,20 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component"; import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
describe("NavigationProductSwitcherComponent", () => { describe("NavigationProductSwitcherComponent", () => {
let fixture: ComponentFixture<NavigationProductSwitcherComponent>; let fixture: ComponentFixture<NavigationProductSwitcherComponent>;
let productSwitcherService: MockProxy<ProductSwitcherService>; let productSwitcherService: MockProxy<ProductSwitcherService>;

View File

@@ -1,8 +1,6 @@
<bit-layout> <bit-layout>
<nav slot="sidebar" class="tw-flex tw-flex-col tw-h-full"> <bit-side-nav>
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'passwordManager' | i18n"> <bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<bit-icon [icon]="logo"></bit-icon>
</a>
<bit-nav-item icon="bwi-collection" [text]="'vaults' | i18n" route="vault"></bit-nav-item> <bit-nav-item icon="bwi-collection" [text]="'vaults' | i18n" route="vault"></bit-nav-item>
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item> <bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item>
@@ -33,10 +31,12 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher> <ng-container slot="footer">
<navigation-product-switcher></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </ng-container>
</bit-side-nav>
<app-payment-method-warnings <app-payment-method-warnings
*ngIf="showPaymentMethodWarningBanners$ | async" *ngIf="showPaymentMethodWarningBanners$ | async"
></app-payment-method-warnings> ></app-payment-method-warnings>

View File

@@ -7655,6 +7655,9 @@
"alreadyHaveAccount": { "alreadyHaveAccount": {
"message": "Already have an account?" "message": "Already have an account?"
}, },
"toggleSideNavigation": {
"message": "Toggle side navigation"
},
"skipToContent": { "skipToContent": {
"message": "Skip to content" "message": "Skip to content"
}, },

View File

@@ -1,8 +1,6 @@
<bit-layout variant="secondary"> <bit-layout variant="secondary">
<nav slot="sidebar" *ngIf="provider$ | async as provider" class="tw-flex tw-flex-col tw-h-full"> <bit-side-nav *ngIf="provider$ | async as provider">
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'providerPortal' | i18n"> <bit-nav-logo [openIcon]="logo" route="." [label]="'providerPortal' | i18n"></bit-nav-logo>
<bit-icon [icon]="logo"></bit-icon>
</a>
<bit-nav-item <bit-nav-item
icon="bwi-bank" icon="bwi-bank"
@@ -43,10 +41,12 @@
*ngIf="showSettingsTab(provider)" *ngIf="showSettingsTab(provider)"
></bit-nav-item> ></bit-nav-item>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher> <ng-container slot="footer">
<navigation-product-switcher></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </ng-container>
</bit-side-nav>
<app-payment-method-warnings <app-payment-method-warnings
*ngIf="showPaymentMethodWarningBanners$ | async" *ngIf="showPaymentMethodWarningBanners$ | async"
></app-payment-method-warnings> ></app-payment-method-warnings>

View File

@@ -1,4 +1,4 @@
<bit-layout> <bit-layout>
<router-outlet slot="sidebar" name="sidebar"></router-outlet> <router-outlet name="sidebar" slot="side-nav"></router-outlet>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</bit-layout> </bit-layout>

View File

@@ -1,8 +1,5 @@
<nav class="tw-flex tw-flex-col tw-h-full"> <bit-side-nav>
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block"> <bit-nav-logo [openIcon]="logo" route="." [label]="'secretsManager' | i18n"></bit-nav-logo>
<bit-icon [icon]="logo"></bit-icon>
</a>
<org-switcher [filter]="orgFilter" [hideNewButton]="true"></org-switcher> <org-switcher [filter]="orgFilter" [hideNewButton]="true"></org-switcher>
<bit-nav-item <bit-nav-item
icon="bwi-collection" icon="bwi-collection"
@@ -35,7 +32,13 @@
[relativeTo]="route.parent" [relativeTo]="route.parent"
*ngIf="isAdmin$ | async" *ngIf="isAdmin$ | async"
></bit-nav-item> ></bit-nav-item>
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" *ngIf="isAdmin$ | async"> <bit-nav-group
icon="bwi-cog"
[text]="'settings' | i18n"
*ngIf="isAdmin$ | async"
route="settings/import"
[relativeTo]="route.parent"
>
<bit-nav-item <bit-nav-item
[text]="'importData' | i18n" [text]="'importData' | i18n"
route="settings/import" route="settings/import"
@@ -48,7 +51,8 @@
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher> <ng-container slot="footer">
<navigation-product-switcher></navigation-product-switcher>
<app-toggle-width></app-toggle-width> <app-toggle-width></app-toggle-width>
</nav> </ng-container>
</bit-side-nav>

View File

@@ -13,24 +13,28 @@
> >
</nav> </nav>
</div> </div>
<div class="tw-flex tw-w-full"> <div class="tw-group tw-flex tw-w-full">
<aside <ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
[ngStyle]="
variant === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)'
}
"
class="tw-sticky tw-inset-y-0 tw-h-screen tw-w-60 tw-overflow-auto tw-bg-background-alt3"
>
<ng-content select="[slot=sidebar]"></ng-content>
</aside>
<main <main
[id]="mainContentId" [id]="mainContentId"
tabindex="-1" tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6" class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ml-0 tw-ml-16"
> >
<ng-content></ng-content> <ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
<div
*ngIf="{
open: sideNavService.open$ | async
} as data"
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
<div
*ngIf="data.open"
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-h-full tw-w-full"
></div>
</div>
</main> </main>
</div> </div>

View File

@@ -1,21 +1,21 @@
import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { LinkModule } from "../link"; import { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared"; import { SharedModule } from "../shared";
export type LayoutVariant = "primary" | "secondary";
@Component({ @Component({
selector: "bit-layout", selector: "bit-layout",
templateUrl: "layout.component.html", templateUrl: "layout.component.html",
standalone: true, standalone: true,
imports: [SharedModule, LinkModule, RouterModule], imports: [CommonModule, SharedModule, LinkModule, RouterModule],
}) })
export class LayoutComponent { export class LayoutComponent {
protected mainContentId = "main-content"; protected mainContentId = "main-content";
@Input() variant: LayoutVariant = "primary"; constructor(protected sideNavService: SideNavService) {}
focusMainContent() { focusMainContent() {
document.getElementById(this.mainContentId)?.focus(); document.getElementById(this.mainContentId)?.focus();

View File

@@ -1,5 +1,5 @@
import { RouterTestingModule } from "@angular/router/testing"; import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { userEvent } from "@storybook/testing-library"; import { userEvent } from "@storybook/testing-library";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -7,6 +7,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { CalloutModule } from "../callout"; import { CalloutModule } from "../callout";
import { NavigationModule } from "../navigation"; import { NavigationModule } from "../navigation";
import { I18nMockService } from "../utils/i18n-mock.service"; import { I18nMockService } from "../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator";
import { LayoutComponent } from "./layout.component"; import { LayoutComponent } from "./layout.component";
@@ -14,16 +15,7 @@ export default {
title: "Component Library/Layout", title: "Component Library/Layout",
component: LayoutComponent, component: LayoutComponent,
decorators: [ decorators: [
componentWrapperDecorator( positionFixedWrapperDecorator(),
/**
* Applying a CSS transform makes a `position: fixed` element act like it is `position: relative`
* https://github.com/storybookjs/storybook/issues/8011#issue-490251969
*/
(story) =>
/* HTML */ `<div class="tw-scale-100 tw-border-2 tw-border-solid tw-border-[red]">
${story}
</div>`,
),
moduleMetadata({ moduleMetadata({
imports: [NavigationModule, RouterTestingModule, CalloutModule], imports: [NavigationModule, RouterTestingModule, CalloutModule],
providers: [ providers: [
@@ -31,6 +23,7 @@ export default {
provide: I18nService, provide: I18nService,
useFactory: () => { useFactory: () => {
return new I18nMockService({ return new I18nMockService({
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content", skipToContent: "Skip to content",
submenu: "submenu", submenu: "submenu",
toggleCollapse: "toggle collapse", toggleCollapse: "toggle collapse",
@@ -40,6 +33,9 @@ export default {
], ],
}), }),
], ],
parameters: {
chromatic: { viewports: [640, 1280] },
},
} as Meta; } as Meta;
type Story = StoryObj<LayoutComponent>; type Story = StoryObj<LayoutComponent>;
@@ -47,7 +43,9 @@ type Story = StoryObj<LayoutComponent>;
export const Empty: Story = { export const Empty: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,
template: /* HTML */ `<bit-layout></bit-layout>`, template: /* HTML */ `<bit-layout>
<bit-side-nav></bit-side-nav>
</bit-layout>`,
}), }),
}; };
@@ -56,13 +54,9 @@ export const WithContent: Story = {
props: args, props: args,
template: /* HTML */ ` template: /* HTML */ `
<bit-layout> <bit-layout>
<nav slot="sidebar"> <bit-side-nav>
<bit-nav-item text="Item A" icon="bwi-collection"></bit-nav-item> <bit-nav-item text="Item A" route="#" icon="bwi-lock"></bit-nav-item>
<bit-nav-item text="Item B" icon="bwi-collection"></bit-nav-item> <bit-nav-group text="Tree A" icon="bwi-family" [open]="true">
<bit-nav-divider></bit-nav-divider>
<bit-nav-item text="Item C" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Item D" icon="bwi-collection"></bit-nav-item>
<bit-nav-group text="Tree example" icon="bwi-collection" [open]="true">
<bit-nav-group <bit-nav-group
text="Level 1 - with children (empty)" text="Level 1 - with children (empty)"
route="#" route="#"
@@ -129,7 +123,141 @@ export const WithContent: Story = {
variant="tree" variant="tree"
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
</nav> <bit-nav-group text="Tree B" icon="bwi-collection" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-group text="Tree C" icon="bwi-key" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 1 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-group
text="Level 2 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 3 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
<bit-nav-group
text="Level 3 - with children"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
>
<bit-nav-item
text="Level 4 - no children, no icon"
route="#"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
<bit-nav-group
text="Level 2 - with children (empty)"
route="#"
icon="bwi-collection"
variant="tree"
[open]="true"
></bit-nav-group>
<bit-nav-item
text="Level 2 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
<bit-nav-item
text="Level 1 - no children"
route="#"
icon="bwi-collection"
variant="tree"
></bit-nav-item>
</bit-nav-group>
</bit-side-nav>
<bit-callout title="Foobar"> Hello world! </bit-callout> <bit-callout title="Foobar"> Hello world! </bit-callout>
</bit-layout> </bit-layout>
`, `,
@@ -147,8 +275,8 @@ export const Secondary: Story = {
render: (args) => ({ render: (args) => ({
props: args, props: args,
template: /* HTML */ ` template: /* HTML */ `
<bit-layout variant="secondary"> <bit-layout>
<nav slot="sidebar"> <bit-side-nav variant="secondary">
<bit-nav-item text="Item A" icon="bwi-collection"></bit-nav-item> <bit-nav-item text="Item A" icon="bwi-collection"></bit-nav-item>
<bit-nav-item text="Item B" icon="bwi-collection"></bit-nav-item> <bit-nav-item text="Item B" icon="bwi-collection"></bit-nav-item>
<bit-nav-divider></bit-nav-divider> <bit-nav-divider></bit-nav-divider>
@@ -221,7 +349,7 @@ export const Secondary: Story = {
variant="tree" variant="tree"
></bit-nav-item> ></bit-nav-item>
</bit-nav-group> </bit-nav-group>
</nav> </bit-side-nav>
<bit-callout title="Foobar"> Hello world! </bit-callout> <bit-callout title="Foobar"> Hello world! </bit-callout>
</bit-layout> </bit-layout>
`, `,

View File

@@ -1 +1,2 @@
export * from "./navigation.module"; export * from "./navigation.module";
export * from "./side-nav.service";

View File

@@ -1 +1 @@
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div> <div *ngIf="sideNavService.open$ | async" class="tw-h-px tw-w-full tw-bg-secondary-300"></div>

View File

@@ -1,7 +1,11 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { SideNavService } from "./side-nav.service";
@Component({ @Component({
selector: "bit-nav-divider", selector: "bit-nav-divider",
templateUrl: "./nav-divider.component.html", templateUrl: "./nav-divider.component.html",
}) })
export class NavDividerComponent {} export class NavDividerComponent {
constructor(protected sideNavService: SideNavService) {}
}

View File

@@ -6,9 +6,8 @@
[relativeTo]="relativeTo" [relativeTo]="relativeTo"
[routerLinkActiveOptions]="routerLinkActiveOptions" [routerLinkActiveOptions]="routerLinkActiveOptions"
[variant]="variant" [variant]="variant"
(mainContentClicked)="toggle()"
[treeDepth]="treeDepth" [treeDepth]="treeDepth"
(mainContentClicked)="mainContentClicked.emit()" (mainContentClicked)="handleMainContentClicked()"
[ariaLabel]="ariaLabel" [ariaLabel]="ariaLabel"
[hideActiveStyles]="parentHideActiveStyles" [hideActiveStyles]="parentHideActiveStyles"
> >
@@ -43,6 +42,7 @@
</bit-nav-item> </bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element --> <!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
<ng-container *ngIf="sideNavService.open$ | async">
<div <div
*ngIf="open" *ngIf="open"
[attr.id]="contentId" [attr.id]="contentId"
@@ -51,3 +51,4 @@
> >
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>
</ng-container>

View File

@@ -11,6 +11,7 @@ import {
} from "@angular/core"; } from "@angular/core";
import { NavBaseComponent } from "./nav-base.component"; import { NavBaseComponent } from "./nav-base.component";
import { SideNavService } from "./side-nav.service";
@Component({ @Component({
selector: "bit-nav-group", selector: "bit-nav-group",
@@ -23,9 +24,9 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
}) })
nestedNavComponents!: QueryList<NavBaseComponent>; nestedNavComponents!: QueryList<NavBaseComponent>;
/** The parent nav item should not show active styles when open. */ /** When the side nav is open, the parent nav item should not show active styles when open. */
protected get parentHideActiveStyles(): boolean { protected get parentHideActiveStyles(): boolean {
return this.hideActiveStyles || this.open; return this.hideActiveStyles || (this.open && this.sideNavService.open);
} }
/** /**
@@ -42,7 +43,10 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
@Output() @Output()
openChange = new EventEmitter<boolean>(); openChange = new EventEmitter<boolean>();
constructor(@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent) { constructor(
protected sideNavService: SideNavService,
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
) {
super(); super();
} }
@@ -69,6 +73,18 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
}); });
} }
protected handleMainContentClicked() {
if (!this.sideNavService.open) {
if (!this.route) {
this.sideNavService.setOpen();
}
this.open = true;
} else {
this.toggle();
}
this.mainContentClicked.emit();
}
ngAfterContentInit(): void { ngAfterContentInit(): void {
this.initNestedStyles(); this.initNestedStyles();
} }

View File

@@ -4,8 +4,10 @@ import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/an
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LayoutComponent } from "../layout";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
import { I18nMockService } from "../utils/i18n-mock.service"; import { I18nMockService } from "../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator";
import { NavGroupComponent } from "./nav-group.component"; import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module"; import { NavigationModule } from "./navigation.module";
@@ -20,8 +22,17 @@ export default {
title: "Component Library/Nav/Nav Group", title: "Component Library/Nav/Nav Group",
component: NavGroupComponent, component: NavGroupComponent,
decorators: [ decorators: [
positionFixedWrapperDecorator(
(story) => `<bit-layout><bit-side-nav>${story}</bit-side-nav></bit-layout>`,
),
moduleMetadata({ moduleMetadata({
imports: [SharedModule, RouterModule, NavigationModule, DummyContentComponent], imports: [
SharedModule,
RouterModule,
NavigationModule,
DummyContentComponent,
LayoutComponent,
],
providers: [ providers: [
{ {
provide: I18nService, provide: I18nService,
@@ -29,6 +40,8 @@ export default {
return new I18nMockService({ return new I18nMockService({
submenu: "submenu", submenu: "submenu",
toggleCollapse: "toggle collapse", toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
}); });
}, },
}, },
@@ -53,6 +66,7 @@ export default {
type: "figma", type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642", url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
}, },
chromatic: { viewports: [640, 1280] },
}, },
} as Meta; } as Meta;

View File

@@ -1,17 +1,25 @@
<ng-container
*ngIf="{
open: sideNavService.open$ | async
} as data"
>
<div <div
*ngIf="data.open || icon"
class="tw-relative" class="tw-relative"
[ngClass]="[ [ngClass]="[
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3 hover:tw-bg-primary-300/60', showActiveStyles
? 'tw-bg-background-alt4'
: 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
fvwStyles$ | async fvwStyles$ | async
]" ]"
> >
<div <div
[ngStyle]="{ [ngStyle]="{
'padding-left': (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' 'padding-left': data.open ? (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' : '0'
}" }"
class="tw-relative tw-flex tw-items-center tw-pr-4" class="tw-relative tw-flex"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
> >
<div [ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']">
<div <div
#slotStart #slotStart
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2" class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
@@ -33,15 +41,31 @@
aria-hidden="true" aria-hidden="true"
></button> ></button>
</div> </div>
</div>
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container> <ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
<!-- Main content of `NavItem` --> <!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent> <ng-template #anchorAndButtonContent>
<i class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"></i <div
><span [title]="text" [ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'">{{ [title]="text"
text class="tw-truncate"
}}</span> [ngClass]="[
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
data.open ? 'tw-pr-4' : 'tw-text-center'
]"
>
<i
class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"
[attr.aria-hidden]="data.open"
[attr.aria-label]="text"
></i
><span
*ngIf="data.open"
[ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'"
>{{ text }}</span
>
</div>
</ng-template> </ng-template>
<!-- Show if a value was passed to `this.to` --> <!-- Show if a value was passed to `this.to` -->
@@ -76,9 +100,16 @@
</ng-template> </ng-template>
<div <div
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2" #endSlot
*ngIf="data.open"
class="tw-flex -tw-ml-3 tw-pr-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
[ngClass]="[
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
endSlot.childElementCount === 0 ? 'tw-hidden' : ''
]"
> >
<ng-content select="[slot=end]"></ng-content> <ng-content select="[slot=end]"></ng-content>
</div> </div>
</div> </div>
</div> </div>
</ng-container>

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, map } from "rxjs";
import { NavBaseComponent } from "./nav-base.component"; import { NavBaseComponent } from "./nav-base.component";
import { NavGroupComponent } from "./nav-group.component"; import { NavGroupComponent } from "./nav-group.component";
import { SideNavService } from "./side-nav.service";
@Component({ @Component({
selector: "bit-nav-item", selector: "bit-nav-item",
@@ -49,7 +50,10 @@ export class NavItemComponent extends NavBaseComponent {
this.focusVisibleWithin$.next(false); this.focusVisibleWithin$.next(false);
} }
constructor(@Optional() private parentNavGroup: NavGroupComponent) { constructor(
protected sideNavService: SideNavService,
@Optional() private parentNavGroup: NavGroupComponent,
) {
super(); super();
} }
} }

View File

@@ -1,7 +1,12 @@
import { RouterTestingModule } from "@angular/router/testing"; import { RouterTestingModule } from "@angular/router/testing";
import { StoryObj, Meta, moduleMetadata } from "@storybook/angular"; import { StoryObj, Meta, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconButtonModule } from "../icon-button"; import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { I18nMockService } from "../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator";
import { NavItemComponent } from "./nav-item.component"; import { NavItemComponent } from "./nav-item.component";
import { NavigationModule } from "./navigation.module"; import { NavigationModule } from "./navigation.module";
@@ -10,9 +15,25 @@ export default {
title: "Component Library/Nav/Nav Item", title: "Component Library/Nav/Nav Item",
component: NavItemComponent, component: NavItemComponent,
decorators: [ decorators: [
positionFixedWrapperDecorator(
(story) => `<bit-layout><bit-side-nav>${story}</bit-side-nav></bit-layout>`,
),
moduleMetadata({ moduleMetadata({
declarations: [], declarations: [],
imports: [RouterTestingModule, IconButtonModule, NavigationModule], imports: [RouterTestingModule, IconButtonModule, NavigationModule, LayoutComponent],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
submenu: "submenu",
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
});
},
},
],
}), }),
], ],
parameters: { parameters: {
@@ -20,6 +41,7 @@ export default {
type: "figma", type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642", url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
}, },
chromatic: { viewports: [640, 1280] },
}, },
} as Meta; } as Meta;
@@ -60,14 +82,6 @@ export const WithChildButtons: Story = {
props: args, props: args,
template: ` template: `
<bit-nav-item text="Hello World" [route]="['']" icon="bwi-collection"> <bit-nav-item text="Hello World" [route]="['']" icon="bwi-collection">
<button
slot="start"
class="tw-ml-auto"
[bitIconButton]="'bwi-clone'"
[buttonType]="'light'"
size="small"
aria-label="option 1"
></button>
<button <button
slot="end" slot="end"
class="tw-ml-auto" class="tw-ml-auto"

View File

@@ -0,0 +1,20 @@
<div *ngIf="sideNavService.open" class="tw-sticky tw-top-0 tw-z-50">
<a
[routerLink]="route"
class="tw-px-5 tw-pb-5 tw-pt-7 tw-block tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2"
[attr.aria-label]="label"
[title]="label"
routerLinkActive
[ariaCurrentWhenActive]="'page'"
>
<bit-icon [icon]="openIcon"></bit-icon>
</a>
</div>
<bit-nav-item
class="tw-block tw-pt-7"
[hideActiveStyles]="true"
[route]="route"
[icon]="closedIcon"
*ngIf="!sideNavService.open"
[text]="label"
></bit-nav-item>

View File

@@ -0,0 +1,27 @@
import { Component, Input } from "@angular/core";
import { Icon } from "../icon";
import { SideNavService } from "./side-nav.service";
@Component({
selector: "bit-nav-logo",
templateUrl: "./nav-logo.component.html",
})
export class NavLogoComponent {
/** Icon that is displayed when the side nav is closed */
@Input() closedIcon = "bwi-shield";
/** Icon that is displayed when the side nav is open */
@Input({ required: true }) openIcon: Icon;
/**
* Route to be passed to internal `routerLink`
*/
@Input({ required: true }) route: string | any[];
/** Passed to `attr.aria-label` and `attr.title` */
@Input({ required: true }) label: string;
constructor(protected sideNavService: SideNavService) {}
}

View File

@@ -1,18 +1,44 @@
import { A11yModule } from "@angular/cdk/a11y";
import { OverlayModule } from "@angular/cdk/overlay"; import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { IconModule } from "../icon";
import { IconButtonModule } from "../icon-button/icon-button.module"; import { IconButtonModule } from "../icon-button/icon-button.module";
import { LinkModule } from "../link";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
import { NavDividerComponent } from "./nav-divider.component"; import { NavDividerComponent } from "./nav-divider.component";
import { NavGroupComponent } from "./nav-group.component"; import { NavGroupComponent } from "./nav-group.component";
import { NavItemComponent } from "./nav-item.component"; import { NavItemComponent } from "./nav-item.component";
import { NavLogoComponent } from "./nav-logo.component";
import { SideNavComponent } from "./side-nav.component";
@NgModule({ @NgModule({
imports: [CommonModule, SharedModule, IconButtonModule, OverlayModule, RouterModule], imports: [
declarations: [NavDividerComponent, NavGroupComponent, NavItemComponent], CommonModule,
exports: [NavDividerComponent, NavGroupComponent, NavItemComponent], SharedModule,
IconButtonModule,
OverlayModule,
RouterModule,
IconModule,
A11yModule,
LinkModule,
],
declarations: [
NavDividerComponent,
NavGroupComponent,
NavItemComponent,
NavLogoComponent,
SideNavComponent,
],
exports: [
NavDividerComponent,
NavGroupComponent,
NavItemComponent,
NavLogoComponent,
SideNavComponent,
],
}) })
export class NavigationModule {} export class NavigationModule {}

View File

@@ -0,0 +1,42 @@
<nav
*ngIf="{
open: sideNavService.open$ | async,
isOverlay: sideNavService.isOverlay$ | async
} as data"
id="bit-side-nav"
class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]="
variant === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)'
}
"
[cdkTrapFocus]="data.isOverlay"
[attr.role]="data.isOverlay ? 'dialog' : null"
[attr.aria-modal]="data.isOverlay"
(keydown)="handleKeyDown($event)"
>
<ng-content></ng-content>
<div class="tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3">
<bit-nav-divider></bit-nav-divider>
<ng-container *ngIf="data.open">
<ng-content select="[slot=footer]"></ng-content>
</ng-container>
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
<button
#toggleButton
type="button"
class="tw-mx-auto tw-block tw-max-w-fit"
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="light"
size="small"
(click)="sideNavService.toggle()"
[attr.aria-label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="data.open"
aria-controls="bit-side-nav"
></button>
</div>
</div>
</nav>

View File

@@ -0,0 +1,26 @@
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
import { SideNavService } from "./side-nav.service";
@Component({
selector: "bit-side-nav",
templateUrl: "side-nav.component.html",
})
export class SideNavComponent {
@Input() variant: "primary" | "secondary" = "primary";
@ViewChild("toggleButton", { read: ElementRef, static: true })
private toggleButton: ElementRef<HTMLButtonElement>;
constructor(protected sideNavService: SideNavService) {}
protected handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.sideNavService.setClose();
this.toggleButton?.nativeElement.focus();
return false;
}
return true;
};
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from "@angular/core";
import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs";
@Injectable({
providedIn: "root",
})
export class SideNavService {
private _open$ = new BehaviorSubject<boolean>(!window.matchMedia("(max-width: 768px)").matches);
open$ = this._open$.asObservable();
isOverlay$ = combineLatest([this.open$, media("(max-width: 768px)")]).pipe(
map(([open, isSmallScreen]) => open && isSmallScreen),
);
get open() {
return this._open$.getValue();
}
setOpen() {
this._open$.next(true);
}
setClose() {
this._open$.next(false);
}
toggle() {
const curr = this._open$.getValue();
if (curr) {
this.setClose();
} else {
this.setOpen();
}
}
}
export const media = (query: string): Observable<boolean> => {
const mediaQuery = window.matchMedia(query);
return fromEvent<MediaQueryList>(mediaQuery, "change").pipe(
startWith(mediaQuery),
map((list: MediaQueryList) => list.matches),
);
};

View File

@@ -99,14 +99,14 @@ export const Default: Story = {
return { return {
props: args, props: args,
template: /* HTML */ `<bit-layout> template: /* HTML */ `<bit-layout>
<nav slot="sidebar"> <bit-side-nav>
<bit-nav-group text="Password Managers" icon="bwi-collection" [open]="true"> <bit-nav-group text="Password Managers" icon="bwi-collection" [open]="true">
<bit-nav-group text="Favorites" icon="bwi-collection" variant="tree" [open]="true"> <bit-nav-group text="Favorites" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Bitwarden" route="bitwarden"></bit-nav-item> <bit-nav-item text="Bitwarden" route="bitwarden"></bit-nav-item>
<bit-nav-divider></bit-nav-divider> <bit-nav-divider></bit-nav-divider>
</bit-nav-group> </bit-nav-group>
</bit-nav-group> </bit-nav-group>
</nav> </bit-side-nav>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</bit-layout>`, </bit-layout>`,
}; };

View File

@@ -0,0 +1,17 @@
import { componentWrapperDecorator } from "@storybook/angular";
/**
* Render a story that uses `position: fixed`
* Used in layout and navigation components
**/
export const positionFixedWrapperDecorator = (wrapper?: (story: string) => string) =>
componentWrapperDecorator(
/**
* Applying a CSS transform makes a `position: fixed` element act like it is `position: relative`
* https://github.com/storybookjs/storybook/issues/8011#issue-490251969
*/
(story) =>
/* HTML */ `<div class="tw-scale-100 tw-h-screen tw-border-2 tw-border-solid tw-border-[red]">
${wrapper ? wrapper(story) : story}
</div>`,
);