Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 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
7cd9844
Remove overflow logic entirely and just use scrolling
iansan5653 Mar 10, 2026
640bc86
Reset snapshots
iansan5653 Mar 10, 2026
d829b34
Merge branch 'main' of https://github.com/primer/react into underline…
iansan5653 Mar 10, 2026
7c87609
Update changeset
iansan5653 Mar 10, 2026
cc3d338
Prevent scrolling ancestors
iansan5653 Mar 11, 2026
2a4484d
Remove unused registry
iansan5653 Mar 13, 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
---

Removes `UnderlineNav` dynamic overflow functionality, replacing with simple scroll container to simplify logic, improve performance, and remove layout shifts, especially when using server-side rendering.
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()
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()
Comment on lines 204 to +214
})

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)
Comment on lines +113 to 114

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}
51 changes: 33 additions & 18 deletions packages/react/src/UnderlineNav/UnderlineNav.module.css
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
.MenuItemContent {
display: flex;
align-items: center;
justify-content: space-between;
}
.ScrollContainer {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: auto;
scrollbar-width: thin;
scrollbar-color: var(--borderColor-muted) transparent;
scrollbar-gutter: stable;

/* Progressive enhancement: Detect overflow using scroll-based animations.
This lets us calculate the icon visibility during SSR. */
animation: detect-overflow linear;
animation-timeline: scroll(self inline);

Comment on lines +9 to +13
--UnderlineNav_icons-display: inline;

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

/* Even on browsers that support scroll-driven animations, we still need to
force icons to remain hidden with JS to avoid flickering as they cause/remove
overflow. Once they are hidden once, they remain hidden for the life of the
component. */
&[data-hide-icons='true'] {
animation: none;
animation-timeline: 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);
--UnderlineNav_icons-display: none;
}
}

& > [data-component='trailingVisual'] {
margin-left: 0;
@keyframes detect-overflow {
0%,
100% {
--UnderlineNav_icons-display: none;
}
}
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