Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/breadcrumbs-measure-whole-crumb.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 12 additions & 3 deletions packages/react/src/Breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -213,15 +222,15 @@ function Breadcrumbs({className, children, style, overflow = 'wrap', variant = '
const previous = widthsRef.current
const widths = new Map<React.Key, number>()
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<HTMLElement>(':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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<Breadcrumbs.Item href="#repository" selected>
repository
</Breadcrumbs.Item>
<button type="button" style={{width: 24}}>
Switch
</button>
</>
)
}
const ui = (width: number) => (
<div data-testid="region" style={{display: 'flex', justifyContent: 'flex-end', width}}>
<Breadcrumbs overflow="menu">
<Breadcrumbs.Item key="owner" href="#owner">
organization-with-a-long-name
</Breadcrumbs.Item>
<CrumbWithPicker key="repository" />
</Breadcrumbs>
</div>
)
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') => <div style={{width: 300}}>{crumbs(overflow, labels)}</div>
Expand Down
Loading