Skip to content
Open
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/underlinenav-intersection-observer-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Improve UnderlineNav overflow performance by replacing synchronous DOM measurements with CSS `overflow: hidden` + `IntersectionObserver`
76 changes: 72 additions & 4 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,88 @@
/* Fixed height on the wrapper prevents vertical CLS on first paint.
Horizontal clipping is handled by overflow: hidden on the <ul>.
The More button is always rendered (visibility: hidden when no overflow)
to prevent horizontal CLS. We intentionally omit overflow: hidden here
so the underline ::after pseudo-element (which extends ~1px below) is not clipped. */
.NavWrapper {
height: var(--control-xlarge-size, 48px);
}

.MenuItemContent {
display: flex;
align-items: center;
justify-content: space-between;
}

/* Container for the "More" button and overflow menu, positioned outside the list
so it is not clipped by overflow: hidden on the list. */
.MoreMenuContainer {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}

/* Before IO has fired, the More button is rendered with visibility: hidden
to reserve space and prevent horizontal CLS when it appears. */
.MoreMenuHidden {
visibility: hidden;
}

/* Overflow list: clips items that don't fit; IO detects which are clipped.
Padding/margin accommodate the underline ::after pseudo-element below items. */
.OverflowList {
overflow: hidden;
flex: 1;
min-width: 0;
padding-bottom: var(--base-size-12);
margin-bottom: calc(var(--base-size-12) * -1);
}

/* Divider line before the More button */
.Divider {
display: inline-block;
border-left: var(--borderWidth-thin) solid var(--borderColor-muted);
width: 1px;
margin-right: var(--base-size-4);
height: var(--base-size-24);
}

/* Dropdown overlay for the overflow menu */
.OverflowMenu {
position: absolute;
z-index: 1;
top: 90%;
box-shadow: var(--shadow-resting-medium);
border-radius: var(--borderRadius-medium);
background: var(--overlay-bgColor);
list-style: none;
min-width: 192px;
max-width: 640px;
right: 0;
display: none;
}

.OverflowMenuOpen {
display: block;
}

/* Hide the selected check icon on menu items and reset link decoration */
.MenuItem {
text-decoration: none;

& > span {
display: none;
}
}

/* More button styles migrated from styles.ts (was moreBtnStyles) */
.MoreButton {
margin: 0; /* reset Safari extra margin */
border: 0;
background: transparent;
font-weight: var(--base-text-weight-normal);
box-shadow: none;
padding-top: var(--base-size-4);
padding-bottom: var(--base-size-4);
padding-left: var(--base-size-8);
padding-right: var(--base-size-8);
padding: var(--base-size-4) var(--base-size-8);

& > [data-component='trailingVisual'] {
margin-left: 0;
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/UnderlineNav/UnderlineNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ describe('UnderlineNav', () => {
it('renders icons correctly', () => {
const {getByRole} = render(<ResponsiveUnderlineNav />)
const nav = getByRole('navigation')
expect(nav.getElementsByTagName('svg').length).toEqual(7)
const list = nav.querySelector('[role="list"], ul, ol')!
expect(list.getElementsByTagName('svg').length).toEqual(7)
})

it('fires onSelect on click', async () => {
Expand Down
Loading
Loading