Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 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
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 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}
58 changes: 55 additions & 3 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,62 @@
.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

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

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?

animation-timeline: scroll(self block);

&[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, inline);
}

.MoreButtonContainer {
display: flex;
visibility: var(--UnderlineNav_moreButton-visibility, hidden);
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