[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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",
"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": {
"message": "Skip to content"
},

View File

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

View File

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

View File

@ -12,7 +12,7 @@
<ng-container *ngIf="moreProducts$ | async as moreProducts">
<section
*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>
<a

View File

@ -13,6 +13,20 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
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", () => {
let fixture: ComponentFixture<NavigationProductSwitcherComponent>;
let productSwitcherService: MockProxy<ProductSwitcherService>;

View File

@ -1,8 +1,6 @@
<bit-layout>
<nav slot="sidebar" class="tw-flex tw-flex-col tw-h-full">
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'passwordManager' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
<bit-side-nav>
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<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>
@ -33,10 +31,12 @@
></bit-nav-item>
</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>
</ng-container>
</bit-side-nav>
<app-toggle-width></app-toggle-width>
</nav>
<app-payment-method-warnings
*ngIf="showPaymentMethodWarningBanners$ | async"
></app-payment-method-warnings>

View File

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

View File

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

View File

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

View File

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

View File

@ -13,24 +13,28 @@
>
</nav>
</div>
<div class="tw-flex tw-w-full">
<aside
[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>
<div class="tw-group tw-flex tw-w-full">
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
[id]="mainContentId"
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>
<!-- 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>
</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 { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
export type LayoutVariant = "primary" | "secondary";
@Component({
selector: "bit-layout",
templateUrl: "layout.component.html",
standalone: true,
imports: [SharedModule, LinkModule, RouterModule],
imports: [CommonModule, SharedModule, LinkModule, RouterModule],
})
export class LayoutComponent {
protected mainContentId = "main-content";
@Input() variant: LayoutVariant = "primary";
constructor(protected sideNavService: SideNavService) {}
focusMainContent() {
document.getElementById(this.mainContentId)?.focus();

View File

@ -1,5 +1,5 @@
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 { 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 { NavigationModule } from "../navigation";
import { I18nMockService } from "../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator";
import { LayoutComponent } from "./layout.component";
@ -14,16 +15,7 @@ export default {
title: "Component Library/Layout",
component: LayoutComponent,
decorators: [
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-border-2 tw-border-solid tw-border-[red]">
${story}
</div>`,
),
positionFixedWrapperDecorator(),
moduleMetadata({
imports: [NavigationModule, RouterTestingModule, CalloutModule],
providers: [
@ -31,6 +23,7 @@ export default {
provide: I18nService,
useFactory: () => {
return new I18nMockService({
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
@ -40,6 +33,9 @@ export default {
],
}),
],
parameters: {
chromatic: { viewports: [640, 1280] },
},
} as Meta;
type Story = StoryObj<LayoutComponent>;
@ -47,7 +43,9 @@ type Story = StoryObj<LayoutComponent>;
export const Empty: Story = {
render: (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,
template: /* HTML */ `
<bit-layout>
<nav slot="sidebar">
<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-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-side-nav>
<bit-nav-item text="Item A" route="#" icon="bwi-lock"></bit-nav-item>
<bit-nav-group text="Tree A" icon="bwi-family" [open]="true">
<bit-nav-group
text="Level 1 - with children (empty)"
route="#"
@ -129,7 +123,141 @@ export const WithContent: Story = {
variant="tree"
></bit-nav-item>
</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-layout>
`,
@ -147,8 +275,8 @@ export const Secondary: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout variant="secondary">
<nav slot="sidebar">
<bit-layout>
<bit-side-nav variant="secondary">
<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-divider></bit-nav-divider>
@ -221,7 +349,7 @@ export const Secondary: Story = {
variant="tree"
></bit-nav-item>
</bit-nav-group>
</nav>
</bit-side-nav>
<bit-callout title="Foobar"> Hello world! </bit-callout>
</bit-layout>
`,

View File

@ -1 +1,2 @@
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 { SideNavService } from "./side-nav.service";
@Component({
selector: "bit-nav-divider",
templateUrl: "./nav-divider.component.html",
})
export class NavDividerComponent {}
export class NavDividerComponent {
constructor(protected sideNavService: SideNavService) {}
}

View File

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

View File

@ -11,6 +11,7 @@ import {
} from "@angular/core";
import { NavBaseComponent } from "./nav-base.component";
import { SideNavService } from "./side-nav.service";
@Component({
selector: "bit-nav-group",
@ -23,9 +24,9 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
})
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 {
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()
openChange = new EventEmitter<boolean>();
constructor(@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent) {
constructor(
protected sideNavService: SideNavService,
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
) {
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 {
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 { LayoutComponent } from "../layout";
import { SharedModule } from "../shared/shared.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../utils/position-fixed-wrapper-decorator";
import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module";
@ -20,8 +22,17 @@ export default {
title: "Component Library/Nav/Nav Group",
component: NavGroupComponent,
decorators: [
positionFixedWrapperDecorator(
(story) => `<bit-layout><bit-side-nav>${story}</bit-side-nav></bit-layout>`,
),
moduleMetadata({
imports: [SharedModule, RouterModule, NavigationModule, DummyContentComponent],
imports: [
SharedModule,
RouterModule,
NavigationModule,
DummyContentComponent,
LayoutComponent,
],
providers: [
{
provide: I18nService,
@ -29,6 +40,8 @@ export default {
return new I18nMockService({
submenu: "submenu",
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
});
},
},
@ -53,6 +66,7 @@ export default {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
},
chromatic: { viewports: [640, 1280] },
},
} as Meta;

View File

@ -1,84 +1,115 @@
<div
class="tw-relative"
[ngClass]="[
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
fvwStyles$ | async
]"
<ng-container
*ngIf="{
open: sideNavService.open$ | async
} as data"
>
<div
[ngStyle]="{
'padding-left': (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem'
}"
class="tw-relative tw-flex tw-items-center tw-pr-4"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
*ngIf="data.open || icon"
class="tw-relative"
[ngClass]="[
showActiveStyles
? 'tw-bg-background-alt4'
: 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
fvwStyles$ | async
]"
>
<div
#slotStart
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
>
<ng-content select="[slot=start]"></ng-content>
</div>
<!-- Default content for #slotStart (for consistent sizing) -->
<div
*ngIf="slotStart.childElementCount === 0"
[ngClass]="{
'tw-w-0': variant !== 'tree'
[ngStyle]="{
'padding-left': data.open ? (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' : '0'
}"
class="tw-relative tw-flex"
>
<button
type="button"
class="tw-invisible"
[bitIconButton]="'bwi-angle-down'"
size="small"
aria-hidden="true"
></button>
</div>
<div [ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']">
<div
#slotStart
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
>
<ng-content select="[slot=start]"></ng-content>
</div>
<!-- Default content for #slotStart (for consistent sizing) -->
<div
*ngIf="slotStart.childElementCount === 0"
[ngClass]="{
'tw-w-0': variant !== 'tree'
}"
>
<button
type="button"
class="tw-invisible"
[bitIconButton]="'bwi-angle-down'"
size="small"
aria-hidden="true"
></button>
</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` -->
<ng-template #anchorAndButtonContent>
<i class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"></i
><span [title]="text" [ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'">{{
text
}}</span>
</ng-template>
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[title]="text"
class="tw-truncate"
[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>
<!-- Show if a value was passed to `this.to` -->
<ng-template #isAnchor>
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="fvw tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
[routerLink]="route"
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="routerLinkActiveOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
<!-- Show if a value was passed to `this.to` -->
<ng-template #isAnchor>
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="fvw tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
[routerLink]="route"
[relativeTo]="relativeTo"
[attr.aria-label]="ariaLabel || text"
routerLinkActive
[routerLinkActiveOptions]="routerLinkActiveOptions"
[ariaCurrentWhenActive]="'page'"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.to` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
<button
type="button"
class="fvw tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
<div
#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-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.to` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
<button
type="button"
class="fvw tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&>:not(.bwi)]:hover:tw-underline"
(click)="mainContentClicked.emit()"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
<div
class="tw-flex tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
>
<ng-content select="[slot=end]"></ng-content>
<ng-content select="[slot=end]"></ng-content>
</div>
</div>
</div>
</div>
</ng-container>

View File

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

View File

@ -1,7 +1,12 @@
import { RouterTestingModule } from "@angular/router/testing";
import { StoryObj, Meta, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
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 { NavigationModule } from "./navigation.module";
@ -10,9 +15,25 @@ export default {
title: "Component Library/Nav/Nav Item",
component: NavItemComponent,
decorators: [
positionFixedWrapperDecorator(
(story) => `<bit-layout><bit-side-nav>${story}</bit-side-nav></bit-layout>`,
),
moduleMetadata({
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: {
@ -20,6 +41,7 @@ export default {
type: "figma",
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642",
},
chromatic: { viewports: [640, 1280] },
},
} as Meta;
@ -60,14 +82,6 @@ export const WithChildButtons: Story = {
props: args,
template: `
<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
slot="end"
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 { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { IconModule } from "../icon";
import { IconButtonModule } from "../icon-button/icon-button.module";
import { LinkModule } from "../link";
import { SharedModule } from "../shared/shared.module";
import { NavDividerComponent } from "./nav-divider.component";
import { NavGroupComponent } from "./nav-group.component";
import { NavItemComponent } from "./nav-item.component";
import { NavLogoComponent } from "./nav-logo.component";
import { SideNavComponent } from "./side-nav.component";
@NgModule({
imports: [CommonModule, SharedModule, IconButtonModule, OverlayModule, RouterModule],
declarations: [NavDividerComponent, NavGroupComponent, NavItemComponent],
exports: [NavDividerComponent, NavGroupComponent, NavItemComponent],
imports: [
CommonModule,
SharedModule,
IconButtonModule,
OverlayModule,
RouterModule,
IconModule,
A11yModule,
LinkModule,
],
declarations: [
NavDividerComponent,
NavGroupComponent,
NavItemComponent,
NavLogoComponent,
SideNavComponent,
],
exports: [
NavDividerComponent,
NavGroupComponent,
NavItemComponent,
NavLogoComponent,
SideNavComponent,
],
})
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 {
props: args,
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="Favorites" icon="bwi-collection" variant="tree" [open]="true">
<bit-nav-item text="Bitwarden" route="bitwarden"></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
</bit-nav-group>
</bit-nav-group>
</nav>
</bit-side-nav>
<router-outlet></router-outlet>
</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>`,
);