([]);
+
/**
* 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
`,