1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[CL-881] restore nav tree view (#17210)

* restore tree nav view

* address Claude feedback

* address feedback and fix depth calculation

* address feedback from Claude

* do not reserve space for icon
This commit is contained in:
Bryan Cunningham
2025-11-06 11:10:17 -05:00
committed by GitHub
parent 29e4085986
commit f865139d16
6 changed files with 115 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { Directive, EventEmitter, Output, input } from "@angular/core";
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
/**
@@ -21,6 +21,16 @@ export abstract class NavBaseComponent {
*/
readonly icon = input<string>();
/**
* If this item is used within a tree, set `variant` to `"tree"`
*/
readonly variant = input<"default" | "tree">("default");
/**
* Depth level when nested inside of a `'tree'` variant
*/
readonly treeDepth = model(0);
/**
* Optional route to be passed to internal `routerLink`. If not provided, the nav component will render as a button.
*

View File

@@ -1,9 +1,13 @@
<!-- This a higher order component that composes `NavItemComponent` -->
@let variantValue = variant();
@if (!hideIfEmpty() || nestedNavComponents().length > 0) {
<bit-nav-item
[text]="text()"
[icon]="icon()"
[route]="route()"
[variant]="variantValue"
[treeDepth]="treeDepth()"
[relativeTo]="relativeTo()"
[routerLinkActiveOptions]="routerLinkActiveOptions()"
(mainContentClicked)="handleMainContentClicked()"
@@ -15,7 +19,10 @@
<button
type="button"
class="tw-ms-auto"
[bitIconButton]="open() ? 'bwi-angle-up' : 'bwi-angle-down'"
[ngClass]="{
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
}"
[bitIconButton]="toggleButtonIcon()"
[buttonType]="'nav-contrast'"
(click)="toggle($event)"
size="small"
@@ -24,9 +31,16 @@
[label]="['toggleCollapse' | i18n, text()].join(' ')"
></button>
</ng-template>
@if (variantValue === "tree") {
<ng-container slot="start">
<ng-container *ngTemplateOutlet="button"></ng-container>
</ng-container>
}
<ng-container slot="end">
<ng-content select="[slot=end]"></ng-content>
<ng-container *ngTemplateOutlet="button"></ng-container>
@if (variantValue !== "tree") {
<ng-container *ngTemplateOutlet="button"></ng-container>
}
</ng-container>
</bit-nav-item>
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->

View File

@@ -34,7 +34,8 @@ import { SideNavService } from "./side-nav.service";
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
})
export class NavGroupComponent extends NavBaseComponent {
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true });
// Query direct children for hideIfEmpty functionality
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false });
readonly sideNavOpen = toSignal(this.sideNavService.open$);
@@ -47,6 +48,18 @@ export class NavGroupComponent extends NavBaseComponent {
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
});
/**
* Determines the appropriate icon for the toggle button based on variant and open state.
* - Tree variant: Always uses 'bwi-up-solid'
* - Default variant: Uses 'bwi-angle-up' when open, 'bwi-angle-down' when closed
*/
readonly toggleButtonIcon = computed(() => {
if (this.variant() === "tree") {
return "bwi-up-solid";
}
return this.open() ? "bwi-angle-up" : "bwi-angle-down";
});
/**
* Allow overriding of the RouterLink['ariaCurrentWhenActive'] property.
*
@@ -89,14 +102,20 @@ export class NavGroupComponent extends NavBaseComponent {
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
) {
super();
// Set tree depth based on parent's depth
// Both NavGroups and NavItems use constructor-based depth initialization
if (this.parentNavGroup) {
this.treeDepth.set(this.parentNavGroup.treeDepth() + 1);
}
}
setOpen(isOpen: boolean) {
this.open.set(isOpen);
this.openChange.emit(this.open());
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.open() && this.parentNavGroup?.setOpen(this.open());
if (this.open()) {
this.parentNavGroup?.setOpen(this.open());
}
}
protected toggle(event?: MouseEvent) {

View File

@@ -132,3 +132,24 @@ export const Secondary: StoryObj<NavGroupComponent> = {
`,
}),
};
export const Tree: StoryObj<NavGroupComponent> = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-side-nav>
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection-shared" [variant]="'tree'"></bit-nav-item>
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection-shared" [variant]="'tree'" [open]="true">
<bit-nav-group text="Level 2 - with children" route="t4" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-item text="Level 3 - no children, no icon" route="t5" variant="tree"></bit-nav-item>
<bit-nav-group text="Level 3 - with children" route="t6" icon="bwi-collection-shared" variant="tree" [open]="true">
<bit-nav-item text="Level 4 - no children, no icon" route="t7" variant="tree"></bit-nav-item>
</bit-nav-group>
</bit-nav-group>
</bit-nav-group>
</bit-nav-group>
</bit-side-nav>
`,
}),
};

View File

@@ -2,6 +2,12 @@
@let open = sideNavService.open$ | async;
@if (open || icon()) {
<div
[ngStyle]="{
'padding-inline-start':
open && variant() === 'tree'
? TREE_BASE_PADDING + (treeDepth() ?? 0) * TREE_DEPTH_PADDING + 'rem'
: '0',
}"
class="tw-relative tw-rounded-md tw-h-10"
[ngClass]="[
showActiveStyles
@@ -11,20 +17,33 @@
]"
>
<div class="tw-relative tw-flex tw-items-center tw-h-full">
@if (open) {
<div
class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_-_4px)] tw-pe-2 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
>
<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
[ngClass]="[
variant() === 'tree' ? 'tw-py-0' : 'tw-py-2',
open ? 'tw-pe-4' : 'tw-text-center',
]"
[title]="text()"
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
[ngClass]="{ 'tw-justify-center': !open }"
>
<i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
[attr.aria-hidden]="open"
[attr.aria-label]="text()"
></i>
@if (icon()) {
<i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
[attr.aria-hidden]="open"
[attr.aria-label]="text()"
></i>
}
@if (open) {
<span class="tw-truncate">{{ text() }}</span>
}
@@ -37,6 +56,7 @@
<!-- The following `class` field should match the `#isButton` 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-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[ngClass]="{ 'tw-ps-0': variant() === 'tree' }"
data-fvw
[routerLink]="route()"
[relativeTo]="relativeTo()"

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, HostListener, Optional, input } from "@angular/core";
import { Component, HostListener, Optional, input, model } from "@angular/core";
import { RouterLinkActive, RouterModule } from "@angular/router";
import { BehaviorSubject, map } from "rxjs";
@@ -11,6 +11,7 @@ import { SideNavService } from "./side-nav.service";
// Resolves a circular dependency between `NavItemComponent` and `NavItemGroup` when using standalone components.
export abstract class NavGroupAbstraction {
abstract setOpen(open: boolean): void;
abstract treeDepth: ReturnType<typeof model<number>>;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -22,6 +23,18 @@ export abstract class NavGroupAbstraction {
imports: [CommonModule, IconButtonModule, RouterModule],
})
export class NavItemComponent extends NavBaseComponent {
/**
* Base padding for tree variant items (in rem)
* This provides the initial indentation for tree items before depth-based padding
*/
protected readonly TREE_BASE_PADDING = 1.25;
/**
* Padding increment per tree depth level (in rem)
* Each nested level adds this amount of padding to visually indicate hierarchy
*/
protected readonly TREE_DEPTH_PADDING = 1.25;
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
readonly forceActiveStyles = input<boolean>(false);
@@ -78,5 +91,10 @@ export class NavItemComponent extends NavBaseComponent {
@Optional() private parentNavGroup: NavGroupAbstraction,
) {
super();
// Set tree depth based on parent's depth
if (this.parentNavGroup) {
this.treeDepth.set(this.parentNavGroup.treeDepth() + 1);
}
}
}