mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +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";
|
import { RouterLink, RouterLinkActive } from "@angular/router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +21,16 @@ export abstract class NavBaseComponent {
|
|||||||
*/
|
*/
|
||||||
readonly icon = input<string>();
|
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.
|
* 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` -->
|
<!-- This a higher order component that composes `NavItemComponent` -->
|
||||||
|
@let variantValue = variant();
|
||||||
|
|
||||||
@if (!hideIfEmpty() || nestedNavComponents().length > 0) {
|
@if (!hideIfEmpty() || nestedNavComponents().length > 0) {
|
||||||
<bit-nav-item
|
<bit-nav-item
|
||||||
[text]="text()"
|
[text]="text()"
|
||||||
[icon]="icon()"
|
[icon]="icon()"
|
||||||
[route]="route()"
|
[route]="route()"
|
||||||
|
[variant]="variantValue"
|
||||||
|
[treeDepth]="treeDepth()"
|
||||||
[relativeTo]="relativeTo()"
|
[relativeTo]="relativeTo()"
|
||||||
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
||||||
(mainContentClicked)="handleMainContentClicked()"
|
(mainContentClicked)="handleMainContentClicked()"
|
||||||
@@ -15,7 +19,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tw-ms-auto"
|
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'"
|
[buttonType]="'nav-contrast'"
|
||||||
(click)="toggle($event)"
|
(click)="toggle($event)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -24,9 +31,16 @@
|
|||||||
[label]="['toggleCollapse' | i18n, text()].join(' ')"
|
[label]="['toggleCollapse' | i18n, text()].join(' ')"
|
||||||
></button>
|
></button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@if (variantValue === "tree") {
|
||||||
|
<ng-container slot="start">
|
||||||
|
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
<ng-container slot="end">
|
<ng-container slot="end">
|
||||||
<ng-content select="[slot=end]"></ng-content>
|
<ng-content select="[slot=end]"></ng-content>
|
||||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
@if (variantValue !== "tree") {
|
||||||
|
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||||
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</bit-nav-item>
|
</bit-nav-item>
|
||||||
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
|
<!-- [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],
|
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
|
||||||
})
|
})
|
||||||
export class NavGroupComponent extends NavBaseComponent {
|
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$);
|
readonly sideNavOpen = toSignal(this.sideNavService.open$);
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ export class NavGroupComponent extends NavBaseComponent {
|
|||||||
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
|
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.
|
* Allow overriding of the RouterLink['ariaCurrentWhenActive'] property.
|
||||||
*
|
*
|
||||||
@@ -89,14 +102,20 @@ export class NavGroupComponent extends NavBaseComponent {
|
|||||||
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
|
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
|
||||||
) {
|
) {
|
||||||
super();
|
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) {
|
setOpen(isOpen: boolean) {
|
||||||
this.open.set(isOpen);
|
this.open.set(isOpen);
|
||||||
this.openChange.emit(this.open());
|
this.openChange.emit(this.open());
|
||||||
// FIXME: Remove when updating file. Eslint update
|
if (this.open()) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
this.parentNavGroup?.setOpen(this.open());
|
||||||
this.open() && this.parentNavGroup?.setOpen(this.open());
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggle(event?: MouseEvent) {
|
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;
|
@let open = sideNavService.open$ | async;
|
||||||
@if (open || icon()) {
|
@if (open || icon()) {
|
||||||
<div
|
<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"
|
class="tw-relative tw-rounded-md tw-h-10"
|
||||||
[ngClass]="[
|
[ngClass]="[
|
||||||
showActiveStyles
|
showActiveStyles
|
||||||
@@ -11,20 +17,33 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
<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>
|
<ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container>
|
||||||
|
|
||||||
<!-- Main content of `NavItem` -->
|
<!-- Main content of `NavItem` -->
|
||||||
<ng-template #anchorAndButtonContent>
|
<ng-template #anchorAndButtonContent>
|
||||||
<div
|
<div
|
||||||
|
[ngClass]="[
|
||||||
|
variant() === 'tree' ? 'tw-py-0' : 'tw-py-2',
|
||||||
|
open ? 'tw-pe-4' : 'tw-text-center',
|
||||||
|
]"
|
||||||
[title]="text()"
|
[title]="text()"
|
||||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||||
[ngClass]="{ 'tw-justify-center': !open }"
|
[ngClass]="{ 'tw-justify-center': !open }"
|
||||||
>
|
>
|
||||||
<i
|
@if (icon()) {
|
||||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
|
<i
|
||||||
[attr.aria-hidden]="open"
|
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
|
||||||
[attr.aria-label]="text()"
|
[attr.aria-hidden]="open"
|
||||||
></i>
|
[attr.aria-label]="text()"
|
||||||
|
></i>
|
||||||
|
}
|
||||||
@if (open) {
|
@if (open) {
|
||||||
<span class="tw-truncate">{{ text() }}</span>
|
<span class="tw-truncate">{{ text() }}</span>
|
||||||
}
|
}
|
||||||
@@ -37,6 +56,7 @@
|
|||||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||||
<a
|
<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]"
|
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
|
data-fvw
|
||||||
[routerLink]="route()"
|
[routerLink]="route()"
|
||||||
[relativeTo]="relativeTo()"
|
[relativeTo]="relativeTo()"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
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 { RouterLinkActive, RouterModule } from "@angular/router";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
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.
|
// Resolves a circular dependency between `NavItemComponent` and `NavItemGroup` when using standalone components.
|
||||||
export abstract class NavGroupAbstraction {
|
export abstract class NavGroupAbstraction {
|
||||||
abstract setOpen(open: boolean): void;
|
abstract setOpen(open: boolean): void;
|
||||||
|
abstract treeDepth: ReturnType<typeof model<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
@@ -22,6 +23,18 @@ export abstract class NavGroupAbstraction {
|
|||||||
imports: [CommonModule, IconButtonModule, RouterModule],
|
imports: [CommonModule, IconButtonModule, RouterModule],
|
||||||
})
|
})
|
||||||
export class NavItemComponent extends NavBaseComponent {
|
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` */
|
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||||
readonly forceActiveStyles = input<boolean>(false);
|
readonly forceActiveStyles = input<boolean>(false);
|
||||||
|
|
||||||
@@ -78,5 +91,10 @@ export class NavItemComponent extends NavBaseComponent {
|
|||||||
@Optional() private parentNavGroup: NavGroupAbstraction,
|
@Optional() private parentNavGroup: NavGroupAbstraction,
|
||||||
) {
|
) {
|
||||||
super();
|
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