Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
7b8e794
Add overflow: hidden logic
TylerJDev Feb 6, 2026
a0e4f42
Add changeset
TylerJDev Feb 6, 2026
604c035
Run format
TylerJDev Feb 6, 2026
7fc6739
Update packages/react/src/internal/components/UnderlineTabbedInterfac…
TylerJDev Feb 6, 2026
6921a86
Update packages/react/src/internal/components/UnderlineTabbedInterfac…
TylerJDev Feb 6, 2026
1bc03b6
Update packages/react/src/UnderlineNav/UnderlineNav.tsx
TylerJDev Feb 6, 2026
cc8b23b
WIP: wrap items out of the way during initial render, using scroll-st…
iansan5653 Feb 6, 2026
be8b640
Migrate as much logic as possible to CSS, allowing elements to regist…
iansan5653 Feb 6, 2026
f5b4a72
Add comments about registry width
iansan5653 Feb 6, 2026
ff30931
Remove opacity from item
iansan5653 Feb 6, 2026
dfda985
Disable menu item anchor when empty and hidden
iansan5653 Feb 6, 2026
ab2806c
Remove unecessary memo
iansan5653 Feb 6, 2026
588223e
Add todo comment
iansan5653 Feb 6, 2026
68f2ef1
Add `overflow: hidden` to parent list
iansan5653 Feb 6, 2026
184ea86
Disable stylelint error for scroll-state rule
iansan5653 Feb 9, 2026
591b5ec
Fix failing unit tests
iansan5653 Feb 9, 2026
b65d397
Truncate last menu item
iansan5653 Feb 9, 2026
e0773f5
Replace overflow menu with `ActionMenu`
iansan5653 Feb 18, 2026
8824b2d
Clean up menu-only edge case (unreachable with truncation)
iansan5653 Feb 18, 2026
399300c
Migrate styles to CSS
iansan5653 Feb 18, 2026
500e706
Merge branch 'main' of https://github.com/primer/react into underline…
iansan5653 Feb 18, 2026
35735d2
Improve CSS comments
iansan5653 Feb 18, 2026
3deb5f9
chore: auto-fix lint and formatting issues
iansan5653 Feb 18, 2026
de7ff00
Replace `ResizeObserver` at container level with `IntersectionObserve…
iansan5653 Feb 18, 2026
e01b457
Merge branch 'underline-nav-full-css-spike' of https://github.com/pri…
iansan5653 Feb 18, 2026
690943a
Fix overflow: hidden
iansan5653 Feb 18, 2026
b06222b
Fix underline tabbed panels and swap scroll-state for animation
iansan5653 Feb 20, 2026
ead0546
Update assertion per updated label text
iansan5653 Feb 20, 2026
82d821e
Add margins to stop overflow clipping underline boundary
iansan5653 Feb 23, 2026
cc1e347
Fix registration ordering
iansan5653 Feb 23, 2026
9d3ba17
Simplify underline positioning per TODO comment
iansan5653 Feb 23, 2026
ec9462f
Update menu item role
iansan5653 Feb 23, 2026
b34a523
Update snapshots
iansan5653 Feb 23, 2026
c1dd8f1
Simplify calculation for underline positioning (per TODO)
iansan5653 Feb 23, 2026
d98c04b
Remove unecessary nbsp
iansan5653 Feb 23, 2026
ec2c47c
Remove spec for preserving current item in top-level menu
iansan5653 Feb 23, 2026
df15a1e
Add decoration to current item in overflow menu
iansan5653 Feb 23, 2026
eef80ba
chore: auto-fix lint and formatting issues
iansan5653 Feb 23, 2026
2aec372
Add "descendant registry" pattern
iansan5653 Feb 23, 2026
f7a0ec9
Return id from register hook
iansan5653 Feb 23, 2026
d55720d
Extract reusable "descendant registry" pattern from `ActionBar`, with…
Copilot Feb 24, 2026
8fd5b4a
Add mechanism for updating value without rebuilding tree, and revert …
iansan5653 Feb 24, 2026
67effbc
Set initial state to `undefined`
iansan5653 Feb 25, 2026
1ba092a
Improve performance when removing items
iansan5653 Feb 25, 2026
247ab9c
Improve doc comment on SSR
iansan5653 Feb 25, 2026
dea0dd0
Refactor ActionBar with descendant registry pattern
iansan5653 Feb 25, 2026
0f8782b
Merge branch 'descendant-registry-pattern' of https://github.com/prim…
iansan5653 Feb 25, 2026
c7abf30
Update to use descendant registry pattern
iansan5653 Feb 25, 2026
9efdf26
Export type to resolve TS 4023 error
iansan5653 Feb 25, 2026
7772123
Refactor so we don't need `useRegisterDescendantCallback`
iansan5653 Feb 25, 2026
f818594
Don't depend on full `props` object per Copilot review
iansan5653 Feb 25, 2026
eacda5b
Merge branch 'descendant-registry-pattern' into underline-nav-full-cs…
iansan5653 Feb 25, 2026
4a90231
Revert focused test
iansan5653 Feb 25, 2026
d57aa24
Fix infinite render loop caused by top-level setState
iansan5653 Feb 26, 2026
231f742
Merge branch 'main' into underline-nav-full-css-spike
iansan5653 Mar 3, 2026
3a4e37c
adjust the viewport to ensure actions link is visible
iansan5653 Mar 3, 2026
52bf25f
test(vrt): update snapshots
iansan5653 Mar 3, 2026
759aafe
Address copilot review feedback
iansan5653 Mar 4, 2026
d3c41c3
Merge branch 'underline-nav-full-css-spike' of https://github.com/pri…
iansan5653 Mar 4, 2026
4d8d5e6
Fix and refactor `UnderlineNav` to resolve CLS issues and improve per…
Copilot Mar 4, 2026
fb400cd
Remove outdated style selector from `UnderlinePanels`
iansan5653 Mar 9, 2026
231a403
Remove unecessary `MORE_BUTTON` constants and unexport `getValidChild…
iansan5653 Mar 9, 2026
59ec720
Add explanatory comment for intersection observer
iansan5653 Mar 9, 2026
96da3e6
Don't render `TrailingVisual` if there's no counters to show
iansan5653 Mar 9, 2026
58be0d0
test(vrt): update snapshots
hectahertz Mar 9, 2026
e177ab4
Fix item height so that underline is visible
iansan5653 Mar 10, 2026
25e3fcd
Fix border radius
iansan5653 Mar 10, 2026
1d21e08
Refactor and improve ref logic
iansan5653 Mar 10, 2026
2a19a68
Reset snapshots
iansan5653 Mar 10, 2026
f5814bd
Merge branch 'main' of https://github.com/primer/react into underline…
iansan5653 Mar 10, 2026
0634340
Update story to demo
iansan5653 Mar 10, 2026
8858e6b
Merge branch 'main' into underline-nav-full-css-spike
iansan5653 Mar 11, 2026
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/underline-nav-css-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Refactors `UnderlineNav` overflow handling to use CSS-based overflow detection instead of JavaScript width measurements, eliminating layout shift (CLS) issues and improving performance. The overflow menu is now implemented with `ActionMenu`, and item registration uses a descendant registry instead of the `React.Children` API. Consumer-facing changes: items can now be wrapped in fragments or wrapper components; the current item may appear in the overflow menu when the viewport is narrow; and the overflow menu button is right-aligned.
66 changes: 9 additions & 57 deletions e2e/components/UnderlineNav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,19 @@ test.describe('UnderlineNav', () => {
})

// Default state
// expect(await page.screenshot()).toMatchSnapshot()
expect(await page.screenshot()).toMatchSnapshot()

await page.setViewportSize({width: viewports['primer.breakpoint.sm'], height: 768})
await page.locator('button', {hasText: 'More Repository Items'}).waitFor()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the label of the overflow button to "More items", aligning with this issue for ActionBar: #7437

await page.locator('button', {hasText: 'More items'}).waitFor()

// Resize
// expect(await page.screenshot()).toMatchSnapshot()
expect(await page.screenshot()).toMatchSnapshot()

await page.getByRole('button', {name: 'More Repository Items'}).click()
// expect(await page.screenshot()).toMatchSnapshot()
await page.getByRole('button', {name: 'More items'}).click()
expect(await page.screenshot()).toMatchSnapshot()

await page.getByRole('link', {name: 'Settings (10)'}).click()
// expect(await page.screenshot()).toMatchSnapshot()
await page.getByRole('menuitem', {name: 'Settings (10)'}).click()
expect(await page.screenshot()).toMatchSnapshot()
})

test('Hide icons when there is not enough space to display all list items @vrt', async ({page}) => {
Expand All @@ -223,61 +223,13 @@ test.describe('UnderlineNav', () => {
})

// Default State
// expect(await page.screenshot()).toMatchSnapshot()
expect(await page.screenshot()).toMatchSnapshot()

// Resize
await page.setViewportSize({width: viewports['primer.breakpoint.md'], height: 768})

// Icons should be hidden
// expect(await page.screenshot()).toMatchSnapshot()
})

test('Keep selected item visible @vrt', async ({page}) => {
await visit(page, {
id: 'components-underlinenav-features--overflow-template',
globals: {
colorScheme: theme,
},
})
await page.setViewportSize({width: viewports['primer.breakpoint.sm'], height: 768})

await page.locator('button', {hasText: 'More Repository Items'}).waitFor()
await page.getByRole('button', {name: 'More Repository Items'}).click()
await page.getByRole('link', {name: 'Settings (10)'}).click()

// State after selecting the second last item
// expect(await page.screenshot()).toMatchSnapshot()

// Resize
await page.setViewportSize({
width: 1100,
height: 480,
})
await page.locator('button', {hasText: 'More Repository Items'}).waitFor({
state: 'hidden',
})

// Current state
// expect(await page.screenshot()).toMatchSnapshot()

// Resize
await page.setViewportSize({
width: 800,
height: 480,
})
await page.locator('button', {hasText: 'More Repository Items'}).waitFor()

// Current state
// expect(await page.screenshot()).toMatchSnapshot()

// Resize
await page.setViewportSize({
width: 600,
height: 480,
})
await page.locator('button', {hasText: 'More Repository Items'}).waitFor()
// Current state
// expect(await page.screenshot()).toMatchSnapshot()
expect(await page.screenshot()).toMatchSnapshot()
})
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,7 @@ const items: {navigation: string; icon: React.ReactElement; counter?: number | s
export const OverflowTemplate = ({initialSelectedIndex = 1}: {initialSelectedIndex?: number}) => {
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(initialSelectedIndex)
return (
<UnderlineNav
aria-label="Repository"
// @ts-ignore UnderlineNav does not take selectionVariant prop, but we need to pass it to the underlying ActionList so it doesn't show Selections.
selectionVariant={undefined}
>
<UnderlineNav aria-label="Repository">
{items.map((item, index) => (
<UnderlineNav.Item
key={item.navigation}
Expand All @@ -106,7 +102,7 @@ export const OverflowTemplate = ({initialSelectedIndex = 1}: {initialSelectedInd
}

export const OverflowOnNarrowScreen = () => {
return <OverflowTemplate initialSelectedIndex={1} />
return <OverflowTemplate initialSelectedIndex={items.length - 1} />
}

OverflowOnNarrowScreen.parameters = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ SelectAMenuItem.play = async ({canvasElement}: {canvasElement: HTMLElement}) =>

await delay(1000)

const moreBtn = canvas.getByRole('button', {name: 'More Repository items'})
const moreBtn = canvas.getByRole('button', {name: 'More items'})
userEvent.hover(moreBtn)

await delay()
Expand All @@ -131,35 +131,4 @@ SelectAMenuItem.play = async ({canvasElement}: {canvasElement: HTMLElement}) =>
expect(lastListItem).toEqual(menuListItem)
}

const KeepSelectedItemVisible = () => {
return <OverflowTemplate initialSelectedIndex={7} />
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
KeepSelectedItemVisible.play = async ({canvasElement}: {canvasElement: HTMLElement}) => {
const canvas = within(canvasElement)
// await delay(2000)
const selectedItem = canvas.getByRole('link', {name: 'Settings (10)'})
expect(selectedItem).toHaveAttribute('aria-current', 'page')
// change viewport
canvasElement.style.width = '900px'
await delay(1000)
expect(selectedItem).toHaveAttribute('aria-current', 'page')
canvasElement.style.width = '800px'
await delay(1000)
expect(selectedItem).toHaveAttribute('aria-current', 'page')
canvasElement.style.width = '700px'
await delay(1000)
expect(selectedItem).toHaveAttribute('aria-current', 'page')
canvasElement.style.width = '600px'
await delay(1000)
expect(selectedItem).toHaveAttribute('aria-current', 'page')
canvasElement.style.width = '500px'
await delay(1000)
const lastListItem = canvas.getByRole('list').children[2].children[0]
const menuListItem = canvas.getByRole('link', {name: 'Settings (10)'})
// expect Settings be the last element on the list.
expect(lastListItem).toEqual(menuListItem)
}

export {KeyboardNavigation, SelectAMenuItem, KeepSelectedItemVisible}
export {KeyboardNavigation, SelectAMenuItem}
61 changes: 58 additions & 3 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,65 @@
.MenuItemContent {
.UnderlineWrapper {
/* Progressive enhancement: Detect overflow using scroll-based animations.
The idiomatic way would be a scroll-state container query but browser support
is slightly better for animations. */
animation: detect-overflow linear;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice progressive enhancement! animation-timeline: scroll() isn't supported in Safari/Firefox yet, so it's good there's a JS fallback via data-has-overflow.

One thought: between first paint and the first IO callback, overflowing items will be clipped but the "More" button won't be visible yet. Is that flash acceptable, or should we show the button by default and hide it once we confirm nothing overflows?

Copy link
Contributor Author

@iansan5653 iansan5653 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great question, I was wondering the same thing. The challenge is that we have to choose - on small screens do we delay showing the button, or on large screens do we show and then hide it when there's no overflow?

I think, given the choice, it's typically preferable to insert new UI after SSR vs hiding UI that you initially showed. Showing and then hiding something feels more flickery than showing it after a short delay. I also think large screens are probably more common.

To be clear, this flash would only happen in browsers that don't support scroll animation.

We could try and be more clever here by showing it by default on small screens based on some arbitrary breakpoint, trying to predict when there's most likely going to be an overflow. But I'm not sure it's worth the complexity and it could never be 100% reliable since we can't know what width would trigger the overflow until after we calculate the overflowed items.

This comment was marked as duplicate.

This comment was marked as duplicate.

animation-timeline: scroll(self block);

--UnderlineNav_moreButton-visibility: hidden;
--UnderlineNav_icons-display: inline;

&[data-hide-icons='true'] {
--UnderlineNav_icons-display: none;
}

&[data-has-overflow='true'] {
--UnderlineNav_moreButton-visibility: visible;
}
}

@keyframes detect-overflow {
0%,
100% {
--UnderlineNav_moreButton-visibility: visible;
--UnderlineNav_icons-display: none;
}
}

.ItemsList [data-component='icon'] {
display: var(--UnderlineNav_icons-display);
}

.MoreButtonContainer {
display: flex;
visibility: var(--UnderlineNav_moreButton-visibility);
align-items: center;
justify-content: space-between;
}

/* More button styles migrated from styles.ts (was moreBtnStyles) */
.OverflowMenuItem [aria-current] {
position: relative;

.OverflowMenuItemLabel {
font-weight: var(--base-text-weight-semibold);
}

&::after {
content: '';
width: var(--base-size-2);
position: absolute;
inset: var(--base-size-2) auto var(--base-size-2) 0;
/* stylelint-disable-next-line primer/colors */
background: var(--underlineNav-borderColor-active);
}
}

.MoreButtonDivider {
display: inline-block;
border-left: var(--borderWidth-default) solid var(--borderColor-muted);
width: 0;
margin-inline: var(--base-size-4);
height: var(--base-size-24);
}

.MoreButton {
margin: 0; /* reset Safari extra margin */
border: 0;
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/UnderlineNav/UnderlineNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, it, vi} from 'vitest'
import type React from 'react'
import {render, screen} from '@testing-library/react'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
CodeIcon,
Expand All @@ -16,6 +16,7 @@ import {UnderlineNav} from '.'
import {implementsClassName} from '../utils/testing'
import classes from '../internal/components/UnderlineTabbedInterface.module.css'
import {clsx} from 'clsx'
import {page} from 'vitest/browser'

const ResponsiveUnderlineNav = ({
selectedItemText = 'Code',
Expand Down Expand Up @@ -78,7 +79,8 @@ describe('UnderlineNav', () => {
it('renders icons correctly', () => {
const {getByRole} = render(<ResponsiveUnderlineNav />)
const nav = getByRole('navigation')
expect(nav.getElementsByTagName('svg').length).toEqual(7)
const list = within(nav).getByRole('list')
expect(list.getElementsByTagName('svg').length).toEqual(7)
})

it('fires onSelect on click', async () => {
Expand Down Expand Up @@ -141,9 +143,10 @@ describe('UnderlineNav', () => {
expect(counter.textContent).toBe('\u00A0(120)')
})

it('respects loadingCounters prop', () => {
it('respects loadingCounters prop', async () => {
await page.viewport(1000, 500)
const {getByRole} = render(<ResponsiveUnderlineNav loadingCounters={true} />)
const item = getByRole('link', {name: 'Actions'})
const item = getByRole('link', {name: 'Actions', hidden: true})
const loadingCounter = item.getElementsByTagName('span')[2]
expect(loadingCounter.className).toContain('LoadingCounter')
expect(loadingCounter.textContent).toBe('')
Expand Down
Loading
Loading