mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 18:33:50 +00:00
[CL-132] Implement resizable side nav (#16533)
Co-authored-by: Vicki League <vleague@bitwarden.com>
This commit is contained in:
committed by
jaasen-livefront
parent
82014c9fbe
commit
4a6575d463
@@ -6039,5 +6039,8 @@
|
||||
},
|
||||
"whyAmISeeingThis": {
|
||||
"message": "Why am I seeing this?"
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DesktopLayoutComponent>;
|
||||
|
||||
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopLayoutComponent, RouterModule.forRoot([]), NavigationModule],
|
||||
@@ -44,6 +48,10 @@ describe("DesktopLayoutComponent", () => {
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(DesktopLayoutComponent, {
|
||||
|
||||
@@ -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<DesktopSideNavComponent>;
|
||||
|
||||
const fakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DesktopSideNavComponent, NavigationModule],
|
||||
@@ -32,6 +36,10 @@ describe("DesktopSideNavComponent", () => {
|
||||
provide: I18nService,
|
||||
useValue: mock<I18nService>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -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<SendListFiltersService>;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -4388,6 +4388,9 @@
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
},
|
||||
"sessionTimeoutSettingsManagedByOrganization": {
|
||||
"message": "This setting is managed by your organization."
|
||||
},
|
||||
|
||||
@@ -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<ActivatedRoute>(),
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useValue: fakeGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -12308,6 +12308,9 @@
|
||||
"userVerificationFailed": {
|
||||
"message": "User verification failed."
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
},
|
||||
"recoveryDeleteCiphersTitle": {
|
||||
"message": "Delete unrecoverable vault items"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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<DrawerComponent>;
|
||||
|
||||
|
||||
@@ -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) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -5,4 +5,5 @@ export const mockLayoutI18n = {
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
loading: "Loading",
|
||||
resizeSideNavigation: "Resize side navigation",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -5,47 +5,64 @@
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full 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)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
|
||||
[ngStyle]="
|
||||
variant() === 'secondary' && {
|
||||
'--color-text-alt2': 'var(--color-text-main)',
|
||||
'--color-background-alt3': 'var(--color-secondary-100)',
|
||||
'--color-background-alt4': 'var(--color-secondary-300)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<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="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]: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>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<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="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-200 hover:tw-ease-in-out hover:tw-delay-500 hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!data.open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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<SideNavVariant>("primary");
|
||||
|
||||
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
|
||||
protected sideNavService = inject(SideNavService);
|
||||
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number>(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<boolean>(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<number>(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<boolean>
|
||||
*/
|
||||
export const media = (query: string): Observable<boolean> => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
return fromEvent<MediaQueryList>(mediaQuery, "change").pipe(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
48
libs/components/src/utils/state-mock.ts
Normal file
48
libs/components/src/utils/state-mock.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
StateUpdateOptions,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
} from "@bitwarden/state";
|
||||
|
||||
export class StorybookGlobalState<T> implements GlobalState<T> {
|
||||
private _state$ = new BehaviorSubject<T | null>(null);
|
||||
|
||||
constructor(initialValue?: T | null) {
|
||||
this._state$.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: Partial<StateUpdateOptions<T, TCombine>>,
|
||||
): Promise<T | null> {
|
||||
const currentState = this._state$.value;
|
||||
const newState = configureState(currentState, null as TCombine);
|
||||
this._state$.next(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
get state$(): Observable<T | null> {
|
||||
return this._state$.asObservable();
|
||||
}
|
||||
|
||||
setValue(value: T | null): void {
|
||||
this._state$.next(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class StorybookGlobalStateProvider implements GlobalStateProvider {
|
||||
private states = new Map<string, StorybookGlobalState<any>>();
|
||||
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
const key = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
|
||||
if (!this.states.has(key)) {
|
||||
this.states.set(key, new StorybookGlobalState<T>());
|
||||
}
|
||||
|
||||
return this.states.get(key)!;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user