1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +00:00

[CL-132] Implement resizable side nav (#16533)

Co-authored-by: Vicki League <vleague@bitwarden.com>
This commit is contained in:
Mark Youssef
2025-12-29 11:08:33 -08:00
committed by jaasen-livefront
parent 82014c9fbe
commit 4a6575d463
24 changed files with 381 additions and 56 deletions

View File

@@ -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,
},
],
}),
],

View File

@@ -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: {

View File

@@ -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>
}

View File

@@ -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);
}
}
}

View File

@@ -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(