Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 67 additions & 44 deletions libs/components/src/breadcrumbs/breadcrumbs.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ng-template #breadcrumbLink let-breadcrumb="breadcrumb">
<a
#breadcrumbEl
[class]="breadcrumbStyles"
[routerLink]="breadcrumb.route()"
[queryParams]="breadcrumb.queryParams()"
Expand All @@ -20,6 +21,7 @@

<ng-template #breadcrumbButton let-breadcrumb="breadcrumb">
<button
#breadcrumbEl
type="button"
[class]="breadcrumbStyles"
(click)="breadcrumb.onClick($event)"
Expand All @@ -29,71 +31,92 @@
</button>
</ng-template>

<ng-template #activeBreadcrumb let-breadcrumb="breadcrumb">
<ng-template #activeBreadcrumb let-breadcrumb="breadcrumb" let-truncate="truncate">
<span
#breadcrumbEl
class="!tw-text-fg-heading"
[class]="activeBreadcrumbStyles"
[bitTypography]="size() === 'small' ? 'h6' : 'h3'"
attr.aria-current="page"
tabindex="0"
[class.tw-truncate]="truncate"
>

Check warning on line 43 in libs/components/src/breadcrumbs/breadcrumbs.component.html

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"tabIndex" should only be declared on interactive elements.

See more on https://sonarcloud.io/project/issues?id=bitwarden_clients&issues=AZ2XP5w3qGs17uuvHOKy&open=AZ2XP5w3qGs17uuvHOKy&pullRequest=20207
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</span>
</ng-template>

@for (breadcrumb of beforeOverflow(); track breadcrumb; let last = $last) {
@if (last && !hasOverflow()) {
@if (sortedBreadcrumbs().displayed.length > 0) {
@let firstBreadcrumb = breadcrumbs()[sortedBreadcrumbs().displayed[0]];
@if (firstBreadcrumb.route(); as route) {
<ng-container
[ngTemplateOutlet]="activeBreadcrumb"
[ngTemplateOutletContext]="{ breadcrumb }"
[ngTemplateOutlet]="breadcrumbLink"
[ngTemplateOutletContext]="{ breadcrumb: firstBreadcrumb }"
/>
} @else if (breadcrumb.route(); as route) {
<ng-container [ngTemplateOutlet]="breadcrumbLink" [ngTemplateOutletContext]="{ breadcrumb }" />
} @else {
<ng-container
[ngTemplateOutlet]="breadcrumbButton"
[ngTemplateOutletContext]="{ breadcrumb }"
[ngTemplateOutletContext]="{ breadcrumb: firstBreadcrumb }"
/>
}
@if (!last) {
<ng-container [ngTemplateOutlet]="arrow" />
}
<ng-container [ngTemplateOutlet]="arrow" />
}

@if (hasOverflow()) {
@if (beforeOverflow().length > 0) {
<ng-container [ngTemplateOutlet]="arrow" />
}
<button
type="button"
bitIconButton="bwi-ellipsis-h"
[bitMenuTriggerFor]="overflowMenu"
size="small"
[label]="'moreBreadcrumbs' | i18n"
></button>
<bit-menu #overflowMenu>
@for (breadcrumb of overflow(); track breadcrumb) {
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
<button
#moreButton
type="button"
bitIconButton="bwi-ellipsis-h"
[bitMenuTriggerFor]="overflowMenu"
size="small"
[label]="'moreBreadcrumbs' | i18n"
[hidden]="sortedBreadcrumbs().overflow.length === 0"
[attr.aria-hidden]="sortedBreadcrumbs().overflow.length === 0 ? true : null"
></button>

<bit-menu #overflowMenu>
@for (i of sortedBreadcrumbs().overflow; track i) {
@let breadcrumb = breadcrumbs()[i];
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
</bit-menu>
}
</bit-menu>

@if (sortedBreadcrumbs().overflow.length > 0) {
<ng-container [ngTemplateOutlet]="arrow" />
@if (afterOverflow(); as breadcrumb) {
<ng-container
[ngTemplateOutlet]="activeBreadcrumb"
[ngTemplateOutletContext]="{ breadcrumb }"
/>
}

@for (i of sortedBreadcrumbs().displayed; track i; let first = $first; let last = $last) {
@let breadcrumb = breadcrumbs()[i];
@if (!first) {
@if (breadcrumb.route(); as route) {
<ng-container
[ngTemplateOutlet]="breadcrumbLink"
[ngTemplateOutletContext]="{ breadcrumb }"
/>
} @else {
<ng-container
[ngTemplateOutlet]="breadcrumbButton"
[ngTemplateOutletContext]="{ breadcrumb }"
/>
}
<ng-container [ngTemplateOutlet]="arrow" />
}
}

@if (lastBreadcrumb(); as breadcrumb) {
<ng-container
[ngTemplateOutlet]="activeBreadcrumb"
[ngTemplateOutletContext]="{ breadcrumb, truncate: sortedBreadcrumbs().truncateBreadcrumb }"
/>
}
177 changes: 162 additions & 15 deletions libs/components/src/breadcrumbs/breadcrumbs.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
contentChildren,
inject,
input,
signal,
afterNextRender,
viewChild,
viewChildren,
ElementRef,
DestroyRef,
} from "@angular/core";
import { RouterModule } from "@angular/router";

Expand All @@ -20,6 +26,8 @@ import { TypographyModule } from "../typography";

import { BreadcrumbComponent } from "./breadcrumb.component";

const ARROW_SPACER = 44; // The horizontal space taken up by the arrow between breadcrumbs, in pixels

/**
* Breadcrumbs are used to help users understand where they are in a products navigation. Typically
* Bitwarden uses this component to indicate the user's current location in a set of data organized in
Expand Down Expand Up @@ -48,6 +56,20 @@ import { BreadcrumbComponent } from "./breadcrumb.component";
export class BreadcrumbsComponent {
private readonly i18nService = inject(I18nService);
protected readonly ariaLabel = this.i18nService.t("breadcrumbs");
private readonly resizeObserver: ResizeObserver;
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef);
private readonly hostElementWidth = signal(0);
private readonly moreButton = viewChild.required("moreButton", { read: ElementRef });
private readonly breadcrumbItems = viewChildren<ElementRef>("breadcrumbEl");
private readonly moreButtonWidth = signal(0);

/** Whether the breadcrumbs have been rendered. Used to hide or display breadcrumbs container, preventing layout shifts. */
protected readonly breadcrumbsRendered = signal(false);

/** Cached breadcrumb widths measured before any hiding, keyed by index. */
private readonly breadcrumbWidths = signal<number[]>([]);

/**
* The maximum number of breadcrumbs to show before overflow.
*/
Expand All @@ -60,30 +82,155 @@ export class BreadcrumbsComponent {

protected readonly breadcrumbs = contentChildren(BreadcrumbComponent);

/** Whether the breadcrumbs exceed the show limit and require an overflow menu */
protected readonly hasOverflow = computed(() => this.breadcrumbs().length > this.show());
/** The last and active breadcrumb, shown after the overflow menu */
protected readonly lastBreadcrumb = computed(() => this.breadcrumbs().at(-1));

/** Breadcrumbs shown before the overflow menu */
protected readonly beforeOverflow = computed(() => {
const items = this.breadcrumbs();
const showCount = this.show();
/** Determines which breadcrumbs are displayed and which overflow into the "More" menu.
* Excludes the last/active breadcrumb since that is always shown.
* */
protected readonly sortedBreadcrumbs = computed(() => {
const allBreadcrumbs = this.breadcrumbs().map((_, i) => i);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand the need for this map. What is it we are trying to do here? Just getting a count?


if (items.length > showCount) {
return items.slice(0, showCount - 1);
if (!this.breadcrumbsRendered()) {
return { displayed: allBreadcrumbs.slice(0, -1), overflow: [] as number[] };
}

const breadcrumbWidths = this.breadcrumbWidths();
const containerWidth = this.hostElementWidth();

// Total width of all breadcrumbs including gaps
const totalBreadcrumbWidth = breadcrumbWidths.reduce(
(sum, w, i) => sum + w + (i > 0 ? ARROW_SPACER : 0),
0,
);

// If all breadcrumbs fit without the more button, no overflow needed
if (totalBreadcrumbWidth <= containerWidth) {
return { displayed: allBreadcrumbs.slice(0, -1), overflow: [] as number[] };
}

const displayed: number[] = []; // Store indexes of breadcrumbs that are displayed
const overflow: number[] = []; // Store indexes of breadcrumbs that are in the "More" overflow menu

// Reserve space for the more button and the last/active breadcrumb.
const moreButtonWidth = this.moreButtonWidth();
const activeBreadcrumbWidth = breadcrumbWidths.at(-1) ?? 0;
const firstBreadcrumbWidth = this.breadcrumbWidths()[0]
? this.breadcrumbWidths()[0] + ARROW_SPACER
: 0;

const availableWidth =
containerWidth - moreButtonWidth - activeBreadcrumbWidth - firstBreadcrumbWidth;

let totalWidth = 0;
for (let i = allBreadcrumbs.length - 2; i > 0; i--) {
const breadcrumbWidth = (breadcrumbWidths[i] ?? 0) + ARROW_SPACER;
if (totalWidth + breadcrumbWidth > availableWidth) {
overflow.push(...allBreadcrumbs.slice(1, i + 1)); // Move remaining breadcrumbs (except the first) to overflow
displayed.unshift(0); // Add the first breadcrumb to displayed
break;
}
totalWidth += breadcrumbWidth;
displayed.unshift(i);
}

if (
overflow.length === allBreadcrumbs.length - 2 &&
totalWidth + firstBreadcrumbWidth > availableWidth
) {
// If the first breadcrumb alone exceeds available space, move it to overflow as well
displayed.pop();
overflow.unshift(0);
}

if (displayed.length + 1 > this.show()) {
// If there are more breadcrumbs than the "show" limit, move extras to overflow starting from the left (after the first)
const overflowCount = displayed.length + 1 - this.show();
const overflowItems = displayed.splice(1, overflowCount); // Always keep the first breadcrumb visible if possible
overflow.push(...overflowItems);
}
return items;
});

/** Breadcrumbs hidden in the overflow menu */
protected readonly overflow = computed(() => {
return this.breadcrumbs().slice(this.show() - 1, -1);
// Truncate the last breadcrumb if there are overflowed breadcrumbs
const truncateBreadcrumb = displayed.length === 0 && overflow.length > 0 && availableWidth < 0;

return { displayed, overflow, truncateBreadcrumb };
});

/** The last breadcrumb, shown after the overflow menu */
protected readonly afterOverflow = computed(() => this.breadcrumbs().at(-1));
constructor() {
this.resizeObserver = new ResizeObserver(this.measureHostElementWidth);

afterNextRender(() => {
// Measure breadcrumb widths and more button width after fonts have loaded to ensure accurate measurements
void document.fonts.ready.then(() => {
this.measureHostElementWidth();
this.measureMoreButtonWidth();
this.measureBreadcrumbWidths();
this.breadcrumbsRendered.set(true);
});
this.resizeObserver.observe(this.hostElement.nativeElement);
this.destroyRef.onDestroy(() => this.resizeObserver.disconnect());
});
}

/** Calculates and sets the width of the host element */
private readonly measureHostElementWidth = (entries?: ResizeObserverEntry[]) => {
const headerEl = this.hostElement.nativeElement;
const contentWidth = entries
? entries[0].contentBoxSize[0].inlineSize
: headerEl.getBoundingClientRect().width;

if (contentWidth !== this.hostElementWidth()) {
this.hostElementWidth.set(contentWidth);
}
};

/**
* Measures the widths of all breadcrumbs and stores them in the `breadcrumbWidths` signal.
* This is used to determine how many breadcrumbs can fit in the available space before
* overflowing into the "More" menu.
*/
private readonly measureBreadcrumbWidths = () => {
this.breadcrumbWidths.set(
this.breadcrumbItems()
// Round up to prevent edge case overflow when breadcrumb width is close to available space
.map((item) => Math.ceil(item.nativeElement.getBoundingClientRect().width)),
);
};

/**
* Measures the width of the "More" button and stores it in the `moreButtonWidth` signal.
* This is used to determine how many breadcrumbs can fit in the available space before
* overflowing into the "More" menu.
*/
private readonly measureMoreButtonWidth = (entries?: ResizeObserverEntry[]): void => {
const moreButtonEl = this.moreButton().nativeElement;

if (entries != null) {
// Called by ResizeObserver β€” button is visible, read directly from entries
this.moreButtonWidth.set(entries[0].contentBoxSize[0].inlineSize + ARROW_SPACER);
return;
}

// Called manually (init / fonts loaded) β€” button may be hidden, temporarily show it
const wasHidden = moreButtonEl.hidden;
if (wasHidden) {
moreButtonEl.hidden = false;
// Force style recalculation before measuring, as getBoundingClientRect()
// may return stale dimensions if styles haven't been flushed yet.
void window.getComputedStyle(moreButtonEl).width;
}

this.moreButtonWidth.set(moreButtonEl.getBoundingClientRect().width + ARROW_SPACER);

// Hide the more button again if it was originally hidden
if (wasHidden) {
moreButtonEl.hidden = true;
}
};

protected readonly baseStyles = [
"tw-inline-block",
"tw-whitespace-nowrap",
"!tw-m-0",
"focus-visible:!tw-text-fg-brand",
"focus-visible:!tw-rounded",
Expand Down
9 changes: 4 additions & 5 deletions libs/components/src/breadcrumbs/breadcrumbs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ When a user is 2 or more levels deep into a tree, the top level is displayed fol

### Overflow

When a user is several levels deep into a tree, the top level or 2 are displayed followed by an
When a user is more than 4 levels deep into a tree or if there is not enough available space, the
top level is displayed followed by an

<i class="bwi bwi-ellipsis-h"> </i> icon button, and then the page directly above the current page.

When the user selects the <i class="bwi bwi-ellipsis-h"></i> icon button, a menu opens displaying
the pages between the top level and the previous page.
<i class="bwi bwi-ellipsis-h"> </i> icon button that contains an overflow menu of breadcrumbs
exceeding the display limit/available space.

<Canvas of={stories.Overflow} />

Expand Down
Loading
Loading