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