diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index eb2815696e..79ea4d4856 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 0c5f7244f0..73e1bc56e6 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2692,6 +2692,9 @@ "submenu": { "message": "Submenu" }, + "toggleSideNavigation": { + "message": "Toggle side navigation" + }, "skipToContent": { "message": "Skip to content" }, diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 445a0855c1..563905548d 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -1,12 +1,6 @@ - - + + + + +
{{ "moreFromBitwarden" | i18n }} ({ + 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; let productSwitcherService: MockProxy; diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index a1c1273674..0d2be927ec 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -1,8 +1,6 @@ - diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1d0e882b17..c248b04dc0 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7655,6 +7655,9 @@ "alreadyHaveAccount": { "message": "Already have an account?" }, + "toggleSideNavigation": { + "message": "Toggle side navigation" + }, "skipToContent": { "message": "Skip to content" }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 7ee6a067d4..7d1d195bf9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -1,8 +1,6 @@ - diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html index 462c15311a..ad63d94839 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html @@ -1,4 +1,4 @@ - + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html index ad608ff458..2c7661d13b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -1,8 +1,5 @@ - + + + + + diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index b5af1a1984..2daefce556 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -13,24 +13,28 @@ > -
- +
+
+ + +
+
+
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 9fe3b46ef3..d55ad8493e 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -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(); diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index e85d04af4d..0016cc4183 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -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 */ `
- ${story} -
`, - ), + 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; @@ -47,7 +43,9 @@ type Story = StoryObj; export const Empty: Story = { render: (args) => ({ props: args, - template: /* HTML */ ``, + template: /* HTML */ ` + + `, }), }; @@ -56,13 +54,9 @@ export const WithContent: Story = { props: args, template: /* HTML */ ` - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hello world! `, @@ -147,8 +275,8 @@ export const Secondary: Story = { render: (args) => ({ props: args, template: /* HTML */ ` - - + Hello world! `, diff --git a/libs/components/src/navigation/index.ts b/libs/components/src/navigation/index.ts index 240b832ec7..8f182b1104 100644 --- a/libs/components/src/navigation/index.ts +++ b/libs/components/src/navigation/index.ts @@ -1 +1,2 @@ export * from "./navigation.module"; +export * from "./side-nav.service"; diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 4f77a18a37..224f6ae065 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1 +1 @@ -
+
diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts index e0c5cf98b7..008d3f46c3 100644 --- a/libs/components/src/navigation/nav-divider.component.ts +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -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) {} +} diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index c3863a398e..c22a067ffe 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -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 @@ -
- -
+ +
+ +
+
diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts index 757e0e98db..1ebe733864 100644 --- a/libs/components/src/navigation/nav-group.component.ts +++ b/libs/components/src/navigation/nav-group.component.ts @@ -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; - /** 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(); - 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(); } diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index 15bee43d55..47a600727f 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -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) => `${story}`, + ), 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; diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 7655043165..6b594b3d49 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -1,84 +1,115 @@ -
- -
- -
- -
+
+
+ +
+ +
+ +
+
- + - - - {{ - text - }} - + + +
+ {{ text }} +
+
- - - - - + + + + + + + + + + + + + + +
- - - - - - - - - - -
- + +
-
+ diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index 4132e0b327..8348638568 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -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(); } } diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 98ff68ee35..918fe0c3d3 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -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) => `${story}`, + ), 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: ` -
+ diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts new file mode 100644 index 0000000000..71fdcfa440 --- /dev/null +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -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) {} +} diff --git a/libs/components/src/navigation/navigation.module.ts b/libs/components/src/navigation/navigation.module.ts index 3685c1b935..852bd1c0a2 100644 --- a/libs/components/src/navigation/navigation.module.ts +++ b/libs/components/src/navigation/navigation.module.ts @@ -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 {} diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html new file mode 100644 index 0000000000..326bd9e6da --- /dev/null +++ b/libs/components/src/navigation/side-nav.component.html @@ -0,0 +1,42 @@ + diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts new file mode 100644 index 0000000000..0561e2e603 --- /dev/null +++ b/libs/components/src/navigation/side-nav.component.ts @@ -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; + + constructor(protected sideNavService: SideNavService) {} + + protected handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + this.sideNavService.setClose(); + this.toggleButton?.nativeElement.focus(); + return false; + } + + return true; + }; +} diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts new file mode 100644 index 0000000000..87691244ca --- /dev/null +++ b/libs/components/src/navigation/side-nav.service.ts @@ -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(!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 => { + const mediaQuery = window.matchMedia(query); + return fromEvent(mediaQuery, "change").pipe( + startWith(mediaQuery), + map((list: MediaQueryList) => list.matches), + ); +}; diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index 70adb21191..f9d1e4166f 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -99,14 +99,14 @@ export const Default: Story = { return { props: args, template: /* HTML */ ` - + `, }; diff --git a/libs/components/src/utils/position-fixed-wrapper-decorator.ts b/libs/components/src/utils/position-fixed-wrapper-decorator.ts new file mode 100644 index 0000000000..a3298e6ad0 --- /dev/null +++ b/libs/components/src/utils/position-fixed-wrapper-decorator.ts @@ -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 */ `
+ ${wrapper ? wrapper(story) : story} +
`, + );