[CL-118][CL-164][PM-8019] collapsible side navigation (#6383)
This commit is contained in:
parent
3bfdc50d5d
commit
06410a0633
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -2692,6 +2692,9 @@
|
|||
"submenu": {
|
||||
"message": "Submenu"
|
||||
},
|
||||
"toggleSideNavigation": {
|
||||
"message": "Toggle side navigation"
|
||||
},
|
||||
"skipToContent": {
|
||||
"message": "Skip to content"
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7655,6 +7655,9 @@
|
|||
"alreadyHaveAccount": {
|
||||
"message": "Already have an account?"
|
||||
},
|
||||
"toggleSideNavigation": {
|
||||
"message": "Toggle side navigation"
|
||||
},
|
||||
"skipToContent": {
|
||||
"message": "Skip to content"
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
`,
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
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 { 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) {}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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 { 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 {}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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 {
|
||||
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>`,
|
||||
};
|
||||
|
|
|
@ -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>`,
|
||||
);
|
Loading…
Reference in New Issue