From a3d58ebf31ce2c36c3ae5d16899bea6da2d7bbcc Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:28:00 -0500 Subject: [PATCH 1/2] fix(Breadcrumbs): measure every element a crumb renders A crumb that renders a link plus a sibling button (e.g. github-ui's repository picker) was measured by its first element only, so the fit under-counted it and the crumbs never collapsed, spilling out of end-aligned containers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/breadcrumbs-measure-whole-crumb.md | 5 ++ .../react/src/Breadcrumbs/Breadcrumbs.tsx | 20 ++++++-- .../__tests__/BreadcrumbsOverflow.test.tsx | 46 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/breadcrumbs-measure-whole-crumb.md diff --git a/.changeset/breadcrumbs-measure-whole-crumb.md b/.changeset/breadcrumbs-measure-whole-crumb.md new file mode 100644 index 00000000000..a51df97d18a --- /dev/null +++ b/.changeset/breadcrumbs-measure-whole-crumb.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Breadcrumbs: Collapse crumbs that render more than one element (such as a link with a sibling button) when they no longer fit, instead of overflowing the container. diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index 05b26021a4b..aae6256885b 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -101,6 +101,21 @@ function getContentWidth(nav: HTMLElement) { return nav.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight) } +// A crumb can render several elements (e.g. a link and a sibling button), so its width spans +// all of the item's children except the separator. +function getCrumbWidth(li: HTMLElement) { + let left = Infinity + let right = -Infinity + for (const child of li.children) { + if (child.classList.contains(classes.ItemSeparator)) continue + const rect = child.getBoundingClientRect() + if (rect.width === 0) continue + left = Math.min(left, rect.left) + right = Math.max(right, rect.right) + } + return right > left ? right - left : 0 +} + const sameFit = (a: Fit, b: Fit) => a.extra === b.extra && a.rootCollapsed === b.rootCollapsed && a.narrow === b.narrow // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -219,9 +234,8 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = ' } for (const li of nav.querySelectorAll(':scope > ol > li[data-index]')) { const key = items[Number(li.getAttribute('data-index'))]?.key - const crumb = li.firstElementChild - if (key == null || !crumb) continue - const crumbWidth = crumb.getBoundingClientRect().width + if (key == null || !li.firstElementChild) continue + const crumbWidth = getCrumbWidth(li) widths.set(key, crumbWidth) if (li.nextElementSibling && !li.hasAttribute('data-collapsed')) { const separator = li.getBoundingClientRect().width - crumbWidth diff --git a/packages/react/src/Breadcrumbs/__tests__/BreadcrumbsOverflow.test.tsx b/packages/react/src/Breadcrumbs/__tests__/BreadcrumbsOverflow.test.tsx index a5e1c49e229..a8abd563646 100644 --- a/packages/react/src/Breadcrumbs/__tests__/BreadcrumbsOverflow.test.tsx +++ b/packages/react/src/Breadcrumbs/__tests__/BreadcrumbsOverflow.test.tsx @@ -395,6 +395,52 @@ describe('Breadcrumbs overflow regressions (real layout)', () => { expect(linksWithinNav()).toBe(true) }) + it('measures every element a crumb renders, so it collapses instead of spilling out', async () => { + // Mirrors a header crumb that renders a link and a sibling button (a repository picker) in a + // region that aligns to the end, so an under-measured crumb would spill out of its start edge. + function CrumbWithPicker() { + return ( + <> + + repository + + + + ) + } + const ui = (width: number) => ( +
+ + + organization-with-a-long-name + + + +
+ ) + const {rerender} = render(ui(800)) + await nextFrame() + expect(menuButton()).toBeNull() + + // Narrower than the crumbs, but wider than they measure without the picker button. + rerender(ui(screen.getByRole('list').getBoundingClientRect().width - 8)) + await nextFrame() + expect(visibleLinks()).toEqual(['repository']) + const region = screen.getByTestId('region').getBoundingClientRect() + for (const element of [ + menuButton()!, + ...screen.getAllByRole('link'), + screen.getByRole('button', {name: 'Switch'}), + ]) { + const rect = element.getBoundingClientRect() + if (rect.width === 0) continue + expect(rect.left).toBeGreaterThanOrEqual(region.left - 1) + expect(rect.right).toBeLessThanOrEqual(region.right + 1) + } + }) + it('starts with the overflow menu closed after switching to wrap and back', async () => { const user = userEvent.setup() const ui = (overflow: 'wrap' | 'menu') =>
{crumbs(overflow, labels)}
From a4e90ccb7bddea9bb7796844403eb931e5dcb675 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:26:02 -0500 Subject: [PATCH 2/2] refactor(Breadcrumbs): measure a crumb's extent with a Range Counts text nodes as well as elements, and reads one rect per crumb. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../react/src/Breadcrumbs/Breadcrumbs.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index aae6256885b..7f04137af4c 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -101,19 +101,13 @@ function getContentWidth(nav: HTMLElement) { return nav.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight) } -// A crumb can render several elements (e.g. a link and a sibling button), so its width spans -// all of the item's children except the separator. -function getCrumbWidth(li: HTMLElement) { - let left = Infinity - let right = -Infinity - for (const child of li.children) { - if (child.classList.contains(classes.ItemSeparator)) continue - const rect = child.getBoundingClientRect() - if (rect.width === 0) continue - left = Math.min(left, rect.left) - right = Math.max(right, rect.right) - } - return right > left ? right - left : 0 +// A crumb can render several nodes (e.g. a link and a sibling button, or text), so its width is +// the extent of everything in the item except the separator. +function getCrumbWidth(li: HTMLElement, range: Range) { + range.selectNodeContents(li) + const last = li.lastElementChild + if (last?.classList.contains(classes.ItemSeparator)) range.setEndBefore(last) + return range.getBoundingClientRect().width } const sameFit = (a: Fit, b: Fit) => a.extra === b.extra && a.rootCollapsed === b.rootCollapsed && a.narrow === b.narrow @@ -228,14 +222,15 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = ' const previous = widthsRef.current const widths = new Map() let separators = separatorsRef.current + const range = nav.ownerDocument.createRange() for (const item of items) { const width = previous.get(item.key ?? '') if (width !== undefined) widths.set(item.key ?? '', width) } for (const li of nav.querySelectorAll(':scope > ol > li[data-index]')) { const key = items[Number(li.getAttribute('data-index'))]?.key - if (key == null || !li.firstElementChild) continue - const crumbWidth = getCrumbWidth(li) + if (key == null) continue + const crumbWidth = getCrumbWidth(li, range) widths.set(key, crumbWidth) if (li.nextElementSibling && !li.hasAttribute('data-collapsed')) { const separator = li.getBoundingClientRect().width - crumbWidth