diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index 63af03b6a7e4..1cc5a6c31645 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -8,6 +8,7 @@ - - @for (breadcrumb of overflow(); track breadcrumb) { - @if (breadcrumb.route(); as route) { - - - - } @else { - - } + + + + @for (i of sortedBreadcrumbs().overflow; track i) { + @let breadcrumb = breadcrumbs()[i]; + @if (breadcrumb.route(); as route) { + + + + } @else { + } - + } + + +@if (sortedBreadcrumbs().overflow.length > 0) { - @if (afterOverflow(); as 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) { + + } @else { + + } + } } + +@if (lastBreadcrumb(); as breadcrumb) { + +} diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.ts b/libs/components/src/breadcrumbs/breadcrumbs.component.ts index 7d0bb624a59f..2791071f53ce 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.ts @@ -6,6 +6,12 @@ import { contentChildren, inject, input, + signal, + afterNextRender, + viewChild, + viewChildren, + ElementRef, + DestroyRef, } from "@angular/core"; import { RouterModule } from "@angular/router"; @@ -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 @@ -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("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([]); + /** * The maximum number of breadcrumbs to show before overflow. */ @@ -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); - 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", diff --git a/libs/components/src/breadcrumbs/breadcrumbs.mdx b/libs/components/src/breadcrumbs/breadcrumbs.mdx index 513aec295c7f..cf1d1fc60873 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.mdx +++ b/libs/components/src/breadcrumbs/breadcrumbs.mdx @@ -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 - icon button, and then the page directly above the current page. - -When the user selects the icon button, a menu opens displaying -the pages between the top level and the previous page. + icon button that contains an overflow menu of breadcrumbs +exceeding the display limit/available space. diff --git a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts index 6b7e0625e5fe..f5df5388a0f6 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.stories.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.stories.ts @@ -157,29 +157,16 @@ export const Overflow: Story = { render: (args) => ({ props: args, template: ` -

Router links

-

- - Acme Vault - Collection - Middle-Collection 1 - Middle-Collection 2 - Middle-Collection 3 - Middle-Collection 4 - End Collection - -

-

Click emit

- - Acme Vault - Collection + + First Collection Middle-Collection 1 Middle-Collection 2 Middle-Collection 3 Middle-Collection 4 - End Collection + Middle-Collection 5 + Active Collection

`,