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:
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user