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..7f04137af4c 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -101,6 +101,15 @@ function getContentWidth(nav: HTMLElement) { return nav.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight) } +// 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -213,15 +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 - const crumb = li.firstElementChild - if (key == null || !crumb) continue - const crumbWidth = crumb.getBoundingClientRect().width + 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 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)}