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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</ng-container>
|
||||||
|
</bit-side-nav>
|
||||||
|
|
||||||
<app-toggle-width></app-toggle-width>
|
|
||||||
</nav>
|
|
||||||
<app-payment-method-warnings
|
<app-payment-method-warnings
|
||||||
*ngIf="showPaymentMethodWarningBanners$ | async"
|
*ngIf="showPaymentMethodWarningBanners$ | async"
|
||||||
></app-payment-method-warnings>
|
></app-payment-method-warnings>
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</ng-container>
|
||||||
|
</bit-side-nav>
|
||||||
|
|
||||||
<app-toggle-width></app-toggle-width>
|
|
||||||
</nav>
|
|
||||||
<app-payment-method-warnings
|
<app-payment-method-warnings
|
||||||
*ngIf="showPaymentMethodWarningBanners$ | async"
|
*ngIf="showPaymentMethodWarningBanners$ | async"
|
||||||
></app-payment-method-warnings>
|
></app-payment-method-warnings>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./navigation.module";
|
export * from "./navigation.module";
|
||||||
|
export * from "./side-nav.service";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,11 +42,13 @@
|
|||||||
</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 -->
|
||||||
<div
|
<ng-container *ngIf="sideNavService.open$ | async">
|
||||||
*ngIf="open"
|
<div
|
||||||
[attr.id]="contentId"
|
*ngIf="open"
|
||||||
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
|
[attr.id]="contentId"
|
||||||
role="group"
|
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
|
||||||
>
|
role="group"
|
||||||
<ng-content></ng-content>
|
>
|
||||||
</div>
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,115 @@
|
|||||||
<div
|
<ng-container
|
||||||
class="tw-relative"
|
*ngIf="{
|
||||||
[ngClass]="[
|
open: sideNavService.open$ | async
|
||||||
showActiveStyles ? 'tw-bg-background-alt4' : 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
|
} as data"
|
||||||
fvwStyles$ | async
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
[ngStyle]="{
|
*ngIf="data.open || icon"
|
||||||
'padding-left': (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem'
|
class="tw-relative"
|
||||||
}"
|
[ngClass]="[
|
||||||
class="tw-relative tw-flex tw-items-center tw-pr-4"
|
showActiveStyles
|
||||||
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
|
? 'tw-bg-background-alt4'
|
||||||
|
: 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
|
||||||
|
fvwStyles$ | async
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
#slotStart
|
[ngStyle]="{
|
||||||
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
|
'padding-left': data.open ? (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' : '0'
|
||||||
>
|
|
||||||
<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'
|
|
||||||
}"
|
}"
|
||||||
|
class="tw-relative tw-flex"
|
||||||
>
|
>
|
||||||
<button
|
<div [ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']">
|
||||||
type="button"
|
<div
|
||||||
class="tw-invisible"
|
#slotStart
|
||||||
[bitIconButton]="'bwi-angle-down'"
|
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
|
||||||
size="small"
|
>
|
||||||
aria-hidden="true"
|
<ng-content select="[slot=start]"></ng-content>
|
||||||
></button>
|
</div>
|
||||||
</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` -->
|
<!-- 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]="[
|
||||||
</ng-template>
|
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` -->
|
<!-- Show if a value was passed to `this.to` -->
|
||||||
<ng-template #isAnchor>
|
<ng-template #isAnchor>
|
||||||
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
|
<!-- The `fvw` class passes focus to `this.focusVisibleWithin$` -->
|
||||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||||
<a
|
<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"
|
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"
|
[routerLink]="route"
|
||||||
[relativeTo]="relativeTo"
|
[relativeTo]="relativeTo"
|
||||||
[attr.aria-label]="ariaLabel || text"
|
[attr.aria-label]="ariaLabel || text"
|
||||||
routerLinkActive
|
routerLinkActive
|
||||||
[routerLinkActiveOptions]="routerLinkActiveOptions"
|
[routerLinkActiveOptions]="routerLinkActiveOptions"
|
||||||
[ariaCurrentWhenActive]="'page'"
|
[ariaCurrentWhenActive]="'page'"
|
||||||
(isActiveChange)="setIsActive($event)"
|
(isActiveChange)="setIsActive($event)"
|
||||||
(click)="mainContentClicked.emit()"
|
(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>
|
<ng-content select="[slot=end]"></ng-content>
|
||||||
</a>
|
</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-container>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
20
libs/components/src/navigation/nav-logo.component.html
Normal file
20
libs/components/src/navigation/nav-logo.component.html
Normal 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>
|
||||||
27
libs/components/src/navigation/nav-logo.component.ts
Normal file
27
libs/components/src/navigation/nav-logo.component.ts
Normal 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) {}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
42
libs/components/src/navigation/side-nav.component.html
Normal file
42
libs/components/src/navigation/side-nav.component.html
Normal 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>
|
||||||
26
libs/components/src/navigation/side-nav.component.ts
Normal file
26
libs/components/src/navigation/side-nav.component.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
43
libs/components/src/navigation/side-nav.service.ts
Normal file
43
libs/components/src/navigation/side-nav.service.ts
Normal 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),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>`,
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user