1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-27 14:53:44 +00:00

[CL-904] Migrate CL/Navigation to use OnPush (#16958)

* Migrate CL/Navigation to use OnPush

* Modernize the code

* Swap to signals and class

* Further tweaks

* Remove this.

* Replace setOpen and setClose with a public signal

* fix merge issues and signal-ifying service

* fix class and style bindings

* fix accidental behavior change from merge conflicts

* fix redundant check

* fix missed ngClass

* fix comment

* Re-add share ng-template

---------

Co-authored-by: Vicki League <vleague@bitwarden.com>
Co-authored-by: Will Martin <contact@willmartian.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Oscar Hinton
2026-01-26 17:44:16 +01:00
committed by GitHub
parent 178fd9a577
commit d64db8fbf5
14 changed files with 240 additions and 265 deletions

View File

@@ -30,21 +30,14 @@
<ng-content></ng-content>
</main>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
>
@if (sideNavService.open()) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>

View File

@@ -1,8 +1,11 @@
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
import { Directive, output, input, model } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
/**
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
* Base class for navigation components in the side navigation.
*
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties
* that are passed down to `NavItemComponent`.
*/
@Directive()
export abstract class NavBaseComponent {
@@ -38,23 +41,26 @@ export abstract class NavBaseComponent {
*
* ---
*
* @remarks
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
*
* See: {@link https://github.com/angular/angular/issues/24482}
* @see {@link RouterLink.routerLink}
* @see {@link https://github.com/angular/angular/issues/24482}
*/
readonly route = input<RouterLink["routerLink"]>();
/**
* Passed to internal `routerLink`
*
* See {@link RouterLink.relativeTo}
* @see {@link RouterLink.relativeTo}
*/
readonly relativeTo = input<RouterLink["relativeTo"]>();
/**
* Passed to internal `routerLink`
*
* See {@link RouterLinkActive.routerLinkActiveOptions}
* @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" }
* @see {@link RouterLinkActive.routerLinkActiveOptions}
*/
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
paths: "subset",
@@ -71,7 +77,5 @@ export abstract class NavBaseComponent {
/**
* Fires when main content is clicked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
readonly mainContentClicked = output<void>();
}

View File

@@ -1,3 +1,3 @@
@if (sideNavService.open$ | async) {
@if (sideNavService.open()) {
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
}

View File

@@ -1,15 +1,15 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { SideNavService } from "./side-nav.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
/**
* A visual divider for separating navigation items in the side navigation.
*/
@Component({
selector: "bit-nav-divider",
templateUrl: "./nav-divider.component.html",
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavDividerComponent {
constructor(protected sideNavService: SideNavService) {}
protected readonly sideNavService = inject(SideNavService);
}

View File

@@ -20,9 +20,7 @@
<button
type="button"
class="tw-ms-auto tw-text-fg-sidenav-text"
[ngClass]="{
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
}"
[class]="variantValue === 'tree' && !open() ? 'tw-transform tw-rotate-[90deg]' : ''"
[bitIconButton]="toggleButtonIcon()"
buttonType="nav-contrast"
(click)="toggle($event)"

View File

@@ -1,17 +1,14 @@
import { CommonModule } from "@angular/common";
import { NgTemplateOutlet } from "@angular/common";
import {
booleanAttribute,
Component,
EventEmitter,
Optional,
Output,
SkipSelf,
inject,
input,
model,
contentChildren,
ChangeDetectionStrategy,
computed,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { RouterLinkActive } from "@angular/router";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -22,8 +19,6 @@ import { NavBaseComponent } from "./nav-base.component";
import { NavGroupAbstraction, NavItemComponent } from "./nav-item.component";
import { SideNavService } from "./side-nav.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-nav-group",
templateUrl: "./nav-group.component.html",
@@ -31,20 +26,24 @@ import { SideNavService } from "./side-nav.service";
{ provide: NavBaseComponent, useExisting: NavGroupComponent },
{ provide: NavGroupAbstraction, useExisting: NavGroupComponent },
],
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
imports: [NgTemplateOutlet, NavItemComponent, IconButtonModule, I18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavGroupComponent extends NavBaseComponent {
protected readonly sideNavService = inject(SideNavService);
private readonly parentNavGroup = inject(NavGroupComponent, { optional: true, skipSelf: true });
// Query direct children for hideIfEmpty functionality
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false });
readonly sideNavOpen = toSignal(this.sideNavService.open$);
protected readonly sideNavOpen = this.sideNavService.open;
readonly sideNavAndGroupOpen = computed(() => {
return this.open() && this.sideNavOpen();
});
/** When the side nav is open, the parent nav item should not show active styles when open. */
readonly parentHideActiveStyles = computed(() => {
protected readonly parentHideActiveStyles = computed(() => {
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
});
@@ -80,7 +79,7 @@ export class NavGroupComponent extends NavBaseComponent {
/**
* UID for `[attr.aria-controls]`
*/
protected contentId = Math.random().toString(36).substring(2);
protected readonly contentId = Math.random().toString(36).substring(2);
/**
* Is `true` if the expanded content is visible
@@ -98,15 +97,7 @@ export class NavGroupComponent extends NavBaseComponent {
/** Does not toggle the expanded state on click */
readonly disableToggleOnClick = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output()
openChange = new EventEmitter<boolean>();
constructor(
protected sideNavService: SideNavService,
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
) {
constructor() {
super();
// Set tree depth based on parent's depth
@@ -118,9 +109,8 @@ export class NavGroupComponent extends NavBaseComponent {
setOpen(isOpen: boolean) {
this.open.set(isOpen);
this.openChange.emit(this.open());
if (this.open()) {
this.parentNavGroup?.setOpen(this.open());
if (this.open() && this.parentNavGroup) {
this.parentNavGroup.setOpen(this.open());
}
}
@@ -130,9 +120,9 @@ export class NavGroupComponent extends NavBaseComponent {
}
protected handleMainContentClicked() {
if (!this.sideNavService.open) {
if (!this.sideNavService.open()) {
if (!this.route()) {
this.sideNavService.setOpen();
this.sideNavService.open.set(true);
}
this.open.set(true);
} else if (!this.disableToggleOnClick()) {

View File

@@ -1,4 +1,4 @@
import { Component, importProvidersFrom } from "@angular/core";
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
@@ -14,10 +14,9 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class DummyContentComponent {}

View File

@@ -1,13 +1,13 @@
<div class="tw-ps-2 tw-pe-2">
@let open = sideNavService.open$ | async;
@let open = sideNavService.open();
@if (open || icon()) {
<div
[style.padding-inline-start]="navItemIndentationPadding()"
class="tw-relative tw-rounded-md tw-h-10"
[class]="fvwStyles()"
[class.tw-bg-bg-sidenav-active-item]="showActiveStyles"
[class.tw-bg-bg-sidenav-background]="!showActiveStyles"
[class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles"
[class]="fvwStyles$ | async"
>
<div class="tw-relative tw-flex tw-items-center tw-h-full">
@if (open) {
@@ -17,35 +17,9 @@
<ng-content select="[slot=start]"></ng-content>
</div>
}
<ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container>
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[title]="text()"
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
[class.tw-text-center]="!open"
[class.tw-justify-center]="!open"
>
@if (icon()) {
<i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text {{ icon() }}"
[attr.aria-hidden]="open"
[attr.aria-label]="text()"
></i>
}
@if (open) {
<span class="tw-truncate">{{ text() }}</span>
}
</div>
</ng-template>
<!-- Show if a value was passed to `this.route` -->
<ng-template #isAnchor>
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
@if (route()) {
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin` -->
<!-- The following `class` field should match the button class field below -->
<a
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
@@ -61,11 +35,8 @@
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>
</ng-template>
<!-- Show if `this.route` is falsy -->
<ng-template #isButton>
<!-- Class field should match `#isAnchor` class field above -->
} @else {
<!-- Class field should match anchor class field above -->
<button
type="button"
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
@@ -75,7 +46,7 @@
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</button>
</ng-template>
}
@if (open) {
<div
@@ -88,3 +59,27 @@
</div>
}
</div>
<!-- Main content of `NavItem` -->
<ng-template #anchorAndButtonContent>
<div
[title]="text()"
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
[class.tw-text-center]="!open"
[class.tw-justify-center]="!open"
>
@if (icon()) {
<i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text"
[class]="icon()"
[attr.aria-hidden]="open"
[attr.aria-label]="text()"
></i>
}
@if (open) {
<span class="tw-truncate">{{ text() }}</span>
}
</div>
</ng-template>

View File

@@ -1,7 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component, HostListener, Optional, computed, input, model } from "@angular/core";
import { RouterLinkActive, RouterModule } from "@angular/router";
import { BehaviorSubject, map } from "rxjs";
import { NgTemplateOutlet } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
input,
inject,
signal,
computed,
model,
} from "@angular/core";
import { RouterModule, RouterLinkActive } from "@angular/router";
import { IconButtonModule } from "../icon-button";
@@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction {
abstract treeDepth: ReturnType<typeof model<number>>;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-nav-item",
templateUrl: "./nav-item.component.html",
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
imports: [CommonModule, IconButtonModule, RouterModule],
imports: [NgTemplateOutlet, IconButtonModule, RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
"(focusin)": "onFocusIn($event.target)",
"(focusout)": "onFocusOut()",
},
})
export class NavItemComponent extends NavBaseComponent {
/**
@@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent {
*/
protected readonly TREE_DEPTH_PADDING = 1.75;
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
/**
* Forces active styles to be shown, regardless of the `routerLinkActiveOptions`
*/
readonly forceActiveStyles = input<boolean>(false);
protected readonly sideNavService = inject(SideNavService);
private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true });
/**
* Is `true` if `to` matches the current route
*/
@@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent {
* adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels
*/
protected readonly navItemIndentationPadding = computed(() => {
const open = this.sideNavService.open;
const open = this.sideNavService.open();
const depth = this.treeDepth() ?? 0;
if (open && this.variant() === "tree") {
@@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent {
* (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some
* styles, so the entire component can have an outline.
*/
protected focusVisibleWithin$ = new BehaviorSubject(false);
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
map((value) =>
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "",
),
protected readonly focusVisibleWithin = signal(false);
protected readonly fvwStyles = computed(() =>
this.focusVisibleWithin()
? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus"
: "",
);
@HostListener("focusin", ["$event.target"])
onFocusIn(target: HTMLElement) {
this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible"));
}
@HostListener("focusout")
onFocusOut() {
this.focusVisibleWithin$.next(false);
protected onFocusIn(target: HTMLElement) {
this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible"));
}
constructor(
protected sideNavService: SideNavService,
@Optional() private parentNavGroup: NavGroupAbstraction,
) {
protected onFocusOut() {
this.focusVisibleWithin.set(false);
}
constructor() {
super();
// Set tree depth based on parent's depth

View File

@@ -1,22 +1,21 @@
<div
[ngClass]="{
'tw-sticky tw-top-0 tw-z-50 tw-pb-4': sideNavService.open,
'tw-pb-[calc(theme(spacing.8)_+_2px)]': !sideNavService.open,
}"
class="tw-px-2 tw-pt-2"
[class]="
sideNavService.open()
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'
: 'tw-pb-[calc(theme(spacing.8)_+_2px)]'
"
>
<!-- absolutely position the link svg to avoid shifting layout when sidenav is closed -->
<a
[routerLink]="route()"
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-bg-sidenav tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-border-focus hover:tw-bg-bg-sidenav-item-hover tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
[ngClass]="{
'!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open,
}"
[class]="!sideNavService.open() ? '!tw-h-[55px] [&_svg]:!tw-w-[26px]' : ''"
[attr.aria-label]="label()"
[title]="label()"
routerLinkActive
ariaCurrentWhenActive="page"
>
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
<bit-icon [icon]="sideNavService.open() ? openIcon() : closedIcon()"></bit-icon>
</a>
</div>

View File

@@ -1,5 +1,4 @@
import { CommonModule } from "@angular/common";
import { Component, input } from "@angular/core";
import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core";
import { RouterLinkActive, RouterLink } from "@angular/router";
import { BitwardenShield, Icon } from "@bitwarden/assets/svg";
@@ -8,18 +7,25 @@ import { BitIconComponent } from "../icon/icon.component";
import { SideNavService } from "./side-nav.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-nav-logo",
templateUrl: "./nav-logo.component.html",
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
imports: [RouterLinkActive, RouterLink, BitIconComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavLogoComponent {
/** Icon that is displayed when the side nav is closed */
protected readonly sideNavService = inject(SideNavService);
/**
* Icon that is displayed when the side nav is closed
*
* @default BitwardenShield
*/
readonly closedIcon = input(BitwardenShield);
/** Icon that is displayed when the side nav is open */
/**
* Icon that is displayed when the side nav is open
*/
readonly openIcon = input.required<Icon>();
/**
@@ -27,8 +33,8 @@ export class NavLogoComponent {
*/
readonly route = input.required<string | any[]>();
/** Passed to `attr.aria-label` and `attr.title` */
/**
* Passed to `attr.aria-label` and `attr.title`
*/
readonly label = input.required<string>();
constructor(protected sideNavService: SideNavService) {}
}

View File

@@ -1,68 +1,60 @@
@if (
{
open: sideNavService.open$ | async,
isOverlay: sideNavService.isOverlay$ | async,
};
as data
) {
<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-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
[ngStyle]="
variant() === 'secondary' && {
'--color-sidenav-text': 'var(--color-admin-sidenav-text)',
'--color-sidenav-background': 'var(--color-admin-sidenav-background)',
'--color-sidenav-active-item': 'var(--color-admin-sidenav-active-item)',
'--color-sidenav-item-hover': 'var(--color-admin-sidenav-item-hover)',
}
"
[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-bg-sidenav"
>
<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>
</nav>
@let open = sideNavService.open();
@let isOverlay = sideNavService.isOverlay();
<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-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
[style]="
variant() === 'secondary'
? '--color-sidenav-text: var(--color-admin-sidenav-text); --color-sidenav-background: var(--color-admin-sidenav-background); --color-sidenav-active-item: var(--color-admin-sidenav-active-item); --color-sidenav-item-hover: var(--color-admin-sidenav-item-hover);'
: ''
"
[cdkTrapFocus]="isOverlay"
[attr.role]="isOverlay ? 'dialog' : null"
[attr.aria-modal]="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
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-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] 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>
}
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
>
<bit-nav-divider></bit-nav-divider>
@if (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]="open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="nav-contrast"
size="small"
(click)="sideNavService.toggle()"
[label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="open"
aria-controls="bit-side-nav"
></button>
</div>
</div>
</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-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] 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]="!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,7 +1,14 @@
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";
import { AsyncPipe } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
viewChild,
inject,
} from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service";
export type SideNavVariant = "primary" | "secondary";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
/**
* Side navigation component that provides a collapsible navigation menu.
*/
@Component({
selector: "bit-side-nav",
templateUrl: "side-nav.component.html",
imports: [
CommonModule,
CdkTrapFocus,
NavDividerComponent,
BitIconButtonComponent,
I18nPipe,
DragDropModule,
AsyncPipe,
],
host: {
class: "tw-block tw-h-full",
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SideNavComponent {
protected sideNavService = inject(SideNavService);
protected readonly sideNavService = inject(SideNavService);
/**
* Visual variant of the side navigation
*
* @default "primary"
*/
readonly variant = input<SideNavVariant>("primary");
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
protected handleKeyDown = (event: KeyboardEvent) => {
protected readonly handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.sideNavService.setClose();
this.sideNavService.open.set(false);
this.toggleButton()?.nativeElement.focus();
return false;
}

View File

@@ -1,15 +1,6 @@
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
BehaviorSubject,
Observable,
combineLatest,
fromEvent,
map,
startWith,
debounceTime,
first,
} from "rxjs";
import { computed, effect, inject, Injectable, signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
@@ -32,16 +23,17 @@ export class SideNavService {
private rootFontSizePx: number;
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
open$ = this._open$.asObservable();
/**
* Whether the side navigation is open or closed.
*/
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe(
map(([open, isLargeScreen]) => open && !isLargeScreen),
);
readonly userCollapsePreference = signal<CollapsePreference>(null);
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
/**
* Local component state width
@@ -67,16 +59,14 @@ export class SideNavService {
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
// Handle open/close state
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
.pipe(takeUntilDestroyed())
.subscribe(([isLargeScreen, userCollapsePreference]) => {
if (!isLargeScreen) {
this.setClose();
} else if (userCollapsePreference !== "closed") {
// Auto-open when user hasn't set preference (null) or prefers open
this.setOpen();
}
});
effect(() => {
if (!this.isLargeScreen()) {
this.open.set(false);
} else if (this.userCollapsePreference() !== "closed") {
// Auto-open when user hasn't set preference (null) or prefers open
this.open.set(true);
}
});
// Initialize the resizable width from state provider
this.widthState$.pipe(first()).subscribe((width: number) => {
@@ -89,31 +79,14 @@ export class SideNavService {
});
}
get open() {
return this._open$.getValue();
}
setOpen() {
this._open$.next(true);
}
setClose() {
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
this._userCollapsePreference$.next(curr ? "closed" : "open");
this.userCollapsePreference.set(this.open() ? "closed" : "open");
if (curr) {
this.setClose();
} else {
this.setOpen();
}
this.open.set(!this.open());
}
/**