diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2b103555604..95d3f662994 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6039,5 +6039,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index 74cddd02495..253444232e5 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -4,7 +4,9 @@ import { RouterModule } from "@angular/router"; import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { NavigationModule } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; @@ -36,6 +38,8 @@ describe("DesktopLayoutComponent", () => { let component: DesktopLayoutComponent; let fixture: ComponentFixture; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule], @@ -44,6 +48,10 @@ describe("DesktopLayoutComponent", () => { provide: I18nService, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }) .overrideComponent(DesktopLayoutComponent, { diff --git a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts index 4d5c3a90253..9b99dbf09c2 100644 --- a/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-side-nav.component.spec.ts @@ -2,7 +2,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { NavigationModule } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; @@ -24,6 +26,8 @@ describe("DesktopSideNavComponent", () => { let component: DesktopSideNavComponent; let fixture: ComponentFixture; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [DesktopSideNavComponent, NavigationModule], @@ -32,6 +36,10 @@ describe("DesktopSideNavComponent", () => { provide: I18nService, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index 95ba5c53e36..ab881e5b57b 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -5,9 +5,11 @@ import { RouterTestingHarness } from "@angular/router/testing"; import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; +import { GlobalStateProvider } from "@bitwarden/state"; import { SendFiltersNavComponent } from "./send-filters-nav.component"; @@ -35,6 +37,8 @@ describe("SendFiltersNavComponent", () => { let filterFormValueSubject: BehaviorSubject<{ sendType: SendType | null }>; let mockSendListFiltersService: Partial; + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + beforeEach(async () => { filterFormValueSubject = new BehaviorSubject<{ sendType: SendType | null }>({ sendType: null, @@ -72,6 +76,10 @@ describe("SendFiltersNavComponent", () => { t: jest.fn((key) => key), }, }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index d26a46a9efe..9be96a62589 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4388,6 +4388,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index 9f6c8f6b194..9a6de3ad9af 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -7,10 +7,12 @@ import { BehaviorSubject } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { IconButtonModule, NavigationModule } from "@bitwarden/components"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service"; @@ -59,6 +61,8 @@ describe("NavigationProductSwitcherComponent", () => { productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$; mockProducts$.next({ bento: [], other: [] }); + const fakeGlobalStateProvider = new FakeGlobalStateProvider(); + await TestBed.configureTestingModule({ imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent], declarations: [NavigationProductSwitcherComponent, I18nPipe], @@ -72,6 +76,10 @@ describe("NavigationProductSwitcherComponent", () => { provide: ActivatedRoute, useValue: mock(), }, + { + provide: GlobalStateProvider, + useValue: fakeGlobalStateProvider, + }, ], }).compileComponents(); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index 33c10309108..ba36063fb7b 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -16,10 +16,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; -import { LayoutComponent, NavigationModule } from "@bitwarden/components"; +import { + LayoutComponent, + NavigationModule, + StorybookGlobalStateProvider, +} from "@bitwarden/components"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { I18nPipe } from "@bitwarden/ui-common"; import { ProductSwitcherService } from "../shared/product-switcher.service"; @@ -183,6 +188,10 @@ export default { }, ]), ), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index a71427cf475..9c56df0db59 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -39,7 +39,8 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { LayoutComponent } from "@bitwarden/components"; +import { LayoutComponent, StorybookGlobalStateProvider } from "@bitwarden/components"; +import { GlobalStateProvider } from "@bitwarden/state"; import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -168,6 +169,10 @@ export default { providers: [ importProvidersFrom(RouterModule.forRoot([], { useHash: true })), importProvidersFrom(PreloadedEnglishI18nModule), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4721c971dcc..98f847e1d36 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12308,6 +12308,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index 3b5bdc4d4e9..4e5c718e494 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -6,13 +6,14 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { getAllByRole, userEvent } from "storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { I18nMockService } from "../utils/i18n-mock.service"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { DialogModule } from "./dialog.module"; import { DialogService } from "./dialog.service"; @@ -161,6 +162,10 @@ export default { }); }, }, + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts index 727d16b5481..9904b77ee9f 100644 --- a/libs/components/src/drawer/drawer.stories.ts +++ b/libs/components/src/drawer/drawer.stories.ts @@ -1,7 +1,8 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { ButtonModule } from "../button"; import { CalloutModule } from "../callout"; @@ -9,7 +10,7 @@ import { LayoutComponent } from "../layout"; import { mockLayoutI18n } from "../layout/mocks"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { TypographyModule } from "../typography"; -import { I18nMockService } from "../utils"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { DrawerBodyComponent } from "./drawer-body.component"; import { DrawerHeaderComponent } from "./drawer-header.component"; @@ -47,6 +48,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], } as Meta; diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index d2c197d0088..9498c163da7 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -1,16 +1,23 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; +import { + Meta, + StoryObj, + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { AvatarModule } from "../avatar"; import { BadgeModule } from "../badge"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { TypographyModule } from "../typography"; -import { I18nMockService } from "../utils/i18n-mock.service"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { ItemActionComponent } from "./item-action.component"; import { ItemContentComponent } from "./item-content.component"; @@ -50,6 +57,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), componentWrapperDecorator((story) => `
${story}
`), ], parameters: { diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index a059fd61b92..59770c21d2e 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -1,13 +1,15 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { userEvent } from "storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { CalloutModule } from "../callout"; import { NavigationModule } from "../navigation"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { LayoutComponent } from "./layout.component"; import { mockLayoutI18n } from "./mocks"; @@ -28,6 +30,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], parameters: { chromatic: { viewports: [640, 1280] }, diff --git a/libs/components/src/layout/mocks.ts b/libs/components/src/layout/mocks.ts index 8b001eb8fd1..15b126ca718 100644 --- a/libs/components/src/layout/mocks.ts +++ b/libs/components/src/layout/mocks.ts @@ -5,4 +5,5 @@ export const mockLayoutI18n = { submenu: "submenu", toggleCollapse: "toggle collapse", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }; diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index c0111c23fc1..fa1cb06dbfe 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -3,11 +3,13 @@ import { RouterModule } from "@angular/router"; import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../layout"; import { SharedModule } from "../shared/shared.module"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { NavGroupComponent } from "./nav-group.component"; import { NavigationModule } from "./navigation.module"; @@ -42,6 +44,7 @@ export default { toggleSideNavigation: "Toggle side navigation", skipToContent: "Skip to content", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, @@ -58,6 +61,10 @@ export default { { useHash: true }, ), ), + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 131dacc8142..3036ab26348 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -1,12 +1,14 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { StoryObj, Meta, moduleMetadata } from "@storybook/angular"; +import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { IconButtonModule } from "../icon-button"; import { LayoutComponent } from "../layout"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { NavItemComponent } from "./nav-item.component"; import { NavigationModule } from "./navigation.module"; @@ -31,11 +33,20 @@ export default { toggleSideNavigation: "Toggle side navigation", skipToContent: "Skip to content", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], parameters: { design: { diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index c8b20ecba77..84c7e3e7298 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -5,47 +5,64 @@ }; as data ) { - + + } diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index cf3d20762fe..b13920d9749 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,4 +1,5 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; import { CommonModule } from "@angular/common"; import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; @@ -16,16 +17,26 @@ export type SideNavVariant = "primary" | "secondary"; @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", - imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe], + imports: [ + CommonModule, + CdkTrapFocus, + NavDividerComponent, + BitIconButtonComponent, + I18nPipe, + DragDropModule, + ], host: { class: "tw-block tw-h-full", }, }) export class SideNavComponent { + protected sideNavService = inject(SideNavService); + readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); - protected sideNavService = inject(SideNavService); + + private elementRef = inject>(ElementRef); protected handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -36,4 +47,21 @@ export class SideNavComponent { return true; }; + + protected onDragMoved(event: CdkDragMove) { + const rectX = this.elementRef.nativeElement.getBoundingClientRect().x; + const eventXPointer = event.pointerPosition.x; + + this.sideNavService.setWidthFromDrag(eventXPointer, rectX); + + // Fix for CDK applying a transform that can cause visual drifting + const element = event.source.element.nativeElement; + element.style.transform = "none"; + } + + protected onKeydown(event: KeyboardEvent) { + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + this.sideNavService.setWidthFromKeys(event.key); + } + } } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index ce44811c7e0..63e54c81fe5 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,37 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs"; +import { + BehaviorSubject, + Observable, + combineLatest, + fromEvent, + map, + startWith, + debounceTime, + first, +} from "rxjs"; + +import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils"; type CollapsePreference = "open" | "closed" | null; +const BIT_SIDE_NAV_WIDTH_KEY_DEF = new KeyDefinition(BIT_SIDE_NAV_DISK, "side-nav-width", { + deserializer: (s) => s, +}); + @Injectable({ providedIn: "root", }) export class SideNavService { + // Units in rem + readonly DEFAULT_OPEN_WIDTH = 18; + readonly MIN_OPEN_WIDTH = 15; + readonly MAX_OPEN_WIDTH = 24; + + private rootFontSizePx: number; + private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); open$ = this._open$.asObservable(); @@ -21,7 +43,30 @@ export class SideNavService { map(([open, isLargeScreen]) => open && !isLargeScreen), ); + /** + * Local component state width + * + * This observable has immediate pixel-perfect updates for the sidebar display width to use + */ + private readonly _width$ = new BehaviorSubject(this.DEFAULT_OPEN_WIDTH); + readonly width$ = this._width$.asObservable(); + + /** + * State provider width + * + * This observable is used to initialize the component state and will be periodically synced + * to the local _width$ state to avoid excessive writes + */ + private readonly widthState = inject(GlobalStateProvider).get(BIT_SIDE_NAV_WIDTH_KEY_DEF); + readonly widthState$ = this.widthState.state$.pipe( + map((width) => width ?? this.DEFAULT_OPEN_WIDTH), + ); + constructor() { + // Get computed root font size to support user-defined a11y font increases + this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); + + // Handle open/close state combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) .pipe(takeUntilDestroyed()) .subscribe(([isLargeScreen, userCollapsePreference]) => { @@ -32,6 +77,16 @@ export class SideNavService { this.setOpen(); } }); + + // Initialize the resizable width from state provider + this.widthState$.pipe(first()).subscribe((width: number) => { + this._width$.next(width); + }); + + // Periodically sync to state provider when component state changes + this.width$.pipe(debounceTime(200), takeUntilDestroyed()).subscribe((width) => { + void this.widthState.update(() => width); + }); } get open() { @@ -46,6 +101,9 @@ export class SideNavService { this._open$.next(false); } + /** + * Toggle the open/close state of the side nav + */ toggle() { const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO @@ -57,8 +115,51 @@ export class SideNavService { this.setOpen(); } } + + /** + * Set new side nav width from drag event coordinates + * + * @param eventXCoordinate x coordinate of the pointer's bounding client rect + * @param dragElementXCoordinate x coordinate of the drag element's bounding client rect + */ + setWidthFromDrag(eventXPointer: number, dragElementXCoordinate: number) { + const newWidthInPixels = eventXPointer - dragElementXCoordinate; + + const newWidthInRem = newWidthInPixels / this.rootFontSizePx; + + this._setWidthWithinMinMax(newWidthInRem); + } + + /** + * Set new side nav width from arrow key events + * + * @param key event key, must be either ArrowRight or ArrowLeft + */ + setWidthFromKeys(key: "ArrowRight" | "ArrowLeft") { + const currentWidth = this._width$.getValue(); + + const delta = key === "ArrowLeft" ? -1 : 1; + const newWidth = currentWidth + delta; + + this._setWidthWithinMinMax(newWidth); + } + + /** + * Calculate and set the new width, not going out of the min/max bounds + * @param newWidth desired new width: number + */ + private _setWidthWithinMinMax(newWidth: number) { + const width = Math.min(Math.max(newWidth, this.MIN_OPEN_WIDTH), this.MAX_OPEN_WIDTH); + + this._width$.next(width); + } } +/** + * Helper function for subscribing to media query events + * @param query media query to validate against + * @returns Observable + */ export const media = (query: string): Observable => { const mediaQuery = window.matchMedia(query); return fromEvent(mediaQuery, "change").pipe( 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 fc6be00b0e0..08f4d875962 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -13,9 +13,11 @@ import { import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; +import { StorybookGlobalStateProvider } from "../../utils/state-mock"; import { positionFixedWrapperDecorator } from "../storybook-decorators"; import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component"; @@ -65,9 +67,14 @@ export default { yes: "Yes", no: "No", loading: "Loading", + resizeSideNavigation: "Resize side navigation", }); }, }, + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, ], }), ], diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index d696e6077dd..d20b5fd1cda 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,13 +1,14 @@ import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/state"; import { countries } from "../form/countries"; import { LayoutComponent } from "../layout"; import { mockLayoutI18n } from "../layout/mocks"; import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { I18nMockService } from "../utils"; +import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -27,6 +28,14 @@ export default { }, ], }), + applicationConfig({ + providers: [ + { + provide: GlobalStateProvider, + useClass: StorybookGlobalStateProvider, + }, + ], + }), ], argTypes: { alignRowContent: { diff --git a/libs/components/src/utils/index.ts b/libs/components/src/utils/index.ts index 5ac6c6c0da0..c66d7761862 100644 --- a/libs/components/src/utils/index.ts +++ b/libs/components/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./aria-disable-element"; export * from "./function-to-observable"; export * from "./has-scrollable-content"; export * from "./i18n-mock.service"; +export * from "./state-mock"; diff --git a/libs/components/src/utils/state-mock.ts b/libs/components/src/utils/state-mock.ts new file mode 100644 index 00000000000..d82705f4d3b --- /dev/null +++ b/libs/components/src/utils/state-mock.ts @@ -0,0 +1,48 @@ +import { BehaviorSubject, Observable } from "rxjs"; + +import { + GlobalState, + StateUpdateOptions, + GlobalStateProvider, + KeyDefinition, +} from "@bitwarden/state"; + +export class StorybookGlobalState implements GlobalState { + private _state$ = new BehaviorSubject(null); + + constructor(initialValue?: T | null) { + this._state$.next(initialValue ?? null); + } + + async update( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: Partial>, + ): Promise { + const currentState = this._state$.value; + const newState = configureState(currentState, null as TCombine); + this._state$.next(newState); + return newState; + } + + get state$(): Observable { + return this._state$.asObservable(); + } + + setValue(value: T | null): void { + this._state$.next(value); + } +} + +export class StorybookGlobalStateProvider implements GlobalStateProvider { + private states = new Map>(); + + get(keyDefinition: KeyDefinition): GlobalState { + const key = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + + if (!this.states.has(key)) { + this.states.set(key, new StorybookGlobalState()); + } + + return this.states.get(key)!; + } +} diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 156c03620b7..445e5fecde7 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -103,6 +103,7 @@ export const AUTOTYPE_SETTINGS_DISK = new StateDefinition("autotypeSettings", "d export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", { web: "disk-local", }); +export const BIT_SIDE_NAV_DISK = new StateDefinition("bitSideNav", "disk"); // DIRT