From 7b8e79465dc9aa2810cead294bfb562bd689285c Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 15:05:49 +0000 Subject: [PATCH 01/24] Add overflow: hidden logic --- packages/react/src/UnderlineNav/UnderlineNav.tsx | 13 ++++++++++++- .../components/UnderlineTabbedInterface.module.css | 9 ++++++++- .../components/UnderlineTabbedInterface.tsx | 6 +++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index c0a252d3ffe..ed66944dd61 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -166,6 +166,8 @@ export const UnderlineNav = forwardRef( const [iconsVisible, setIconsVisible] = useState(true) const [childWidthArray, setChildWidthArray] = useState([]) const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) + // Track whether the initial overflow calculation is complete to prevent CLS + const [isReady, setIsReady] = useState(false) const validChildren = getValidChildren(children) @@ -248,6 +250,8 @@ export const UnderlineNav = forwardRef( const updateListAndMenu = useCallback((props: ResponsiveProps, displayIcons: boolean) => { setResponsiveProps(props) setIconsVisible(displayIcons) + // Mark as ready after the first overflow calculation completes + setIsReady(true) }, []) const setChildrenWidth = useCallback((size: ChildSize) => { setChildWidthArray(arr => { @@ -330,7 +334,14 @@ export const UnderlineNav = forwardRef( }} > {ariaLabel && {`${ariaLabel} navigation`}} - + {listItems} {menuItems.length > 0 && ( diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 85214c5c317..09a42e42111 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -12,6 +12,13 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: inset 0 -1px var(--borderColor-muted); + /* Hide overflow until calculation is complete to prevent CLS */ + overflow: hidden; + + &[data-ready='true'] { + overflow: visible; + } + &[data-variant='flush'] { /* stylelint-disable-next-line primer/spacing */ padding-inline: unset; @@ -153,4 +160,4 @@ to { opacity: 0.2; } -} +} \ No newline at end of file diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 509459ea47f..63ec7edd99e 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -32,14 +32,18 @@ type UnderlineWrapperProps = { as?: As className?: string ref?: React.Ref + /** Indicates whether the overflow calculation is complete. When false, overflow is hidden to prevent CLS. */ + ready?: boolean } export const UnderlineWrapper = forwardRef((props, ref) => { - const {children, className, as: Component = 'div', ...rest} = props + const {children, className, as: Component = 'div', ready, ...rest} = props + return ( } + data-ready={ready ? 'true' : 'false'} {...rest} > {children} From a0e4f4255ee69ec29857c49c4b97ec866081e87d Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 15:08:19 +0000 Subject: [PATCH 02/24] Add changeset --- .changeset/clever-geese-cover.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-geese-cover.md diff --git a/.changeset/clever-geese-cover.md b/.changeset/clever-geese-cover.md new file mode 100644 index 00000000000..e0b8c9efa55 --- /dev/null +++ b/.changeset/clever-geese-cover.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +UnderlineNav: Adds `overflow: hidden` when calculating items to prevent CLS From 604c035a477cb4a9cb93068b8c9c1ef370af0007 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 15:15:38 +0000 Subject: [PATCH 03/24] Run format --- .../src/internal/components/UnderlineTabbedInterface.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 09a42e42111..ebe310aee9e 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -160,4 +160,4 @@ to { opacity: 0.2; } -} \ No newline at end of file +} From 7fc673975b0d7a90e766c933e3ef5a1abe173812 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 10:41:03 -0500 Subject: [PATCH 04/24] Update packages/react/src/internal/components/UnderlineTabbedInterface.module.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../internal/components/UnderlineTabbedInterface.module.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index ebe310aee9e..67d7533a96f 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -13,8 +13,11 @@ box-shadow: inset 0 -1px var(--borderColor-muted); /* Hide overflow until calculation is complete to prevent CLS */ - overflow: hidden; + overflow: visible; + &[data-ready='false'] { + overflow: hidden; + } &[data-ready='true'] { overflow: visible; } From 6921a860f47625c0da2c25aa120a17a289aefa4c Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 10:41:37 -0500 Subject: [PATCH 05/24] Update packages/react/src/internal/components/UnderlineTabbedInterface.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../react/src/internal/components/UnderlineTabbedInterface.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 63ec7edd99e..107b2b1407f 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -43,7 +43,7 @@ export const UnderlineWrapper = forwardRef((props, ref) => { } - data-ready={ready ? 'true' : 'false'} + data-ready={ready === undefined ? undefined : ready ? 'true' : 'false'} {...rest} > {children} From 1bc03b6d6667d907ddc25d8e23239bcd2cfcf977 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 6 Feb 2026 10:41:58 -0500 Subject: [PATCH 06/24] Update packages/react/src/UnderlineNav/UnderlineNav.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../react/src/UnderlineNav/UnderlineNav.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index ed66944dd61..5adaeb9526b 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -247,12 +247,19 @@ export const UnderlineNav = forwardRef( return breakpoint } - const updateListAndMenu = useCallback((props: ResponsiveProps, displayIcons: boolean) => { - setResponsiveProps(props) - setIconsVisible(displayIcons) - // Mark as ready after the first overflow calculation completes - setIsReady(true) - }, []) + const updateListAndMenu = useCallback( + (props: ResponsiveProps, displayIcons: boolean) => { + setResponsiveProps(props) + setIconsVisible(displayIcons) + + // Only mark as ready once widths have been measured for all valid children + const widths = displayIcons ? childWidthArray : noIconChildWidthArray + if (!isReady && widths.length > 0 && widths.length === validChildren.length) { + setIsReady(true) + } + }, + [childWidthArray, noIconChildWidthArray, isReady, validChildren.length], + ) const setChildrenWidth = useCallback((size: ChildSize) => { setChildWidthArray(arr => { const newArr = [...arr, size] From cc8b23b4749dad5bbda32a21e9f901d2185836d0 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 18:11:32 +0000 Subject: [PATCH 07/24] WIP: wrap items out of the way during initial render, using scroll-state to detect overflow --- .../src/UnderlineNav/UnderlineNav.module.css | 9 + .../react/src/UnderlineNav/UnderlineNav.tsx | 188 +++++++++--------- .../UnderlineTabbedInterface.module.css | 18 +- 3 files changed, 120 insertions(+), 95 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css index d905aff3fcf..14d9ee8f151 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css @@ -4,6 +4,15 @@ justify-content: space-between; } +.MoreButtonContainer { + display: none; + align-items: center; + + @container underline-wrapper scroll-state(scrollable: block) { + display: flex; + } +} + /* More button styles migrated from styles.ts (was moreBtnStyles) */ .MoreButton { margin: 0; /* reset Safari extra margin */ diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 5adaeb9526b..6f4156b5c83 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -38,7 +38,7 @@ export type UnderlineNavProps = { // However, we need to calculate number of possible items when the more button present as well. So using the width of the more button as a constant. export const MORE_BTN_WIDTH = 86 // The height is needed to make sure we don't have a layout shift when the more button is the only item in the nav. -const MORE_BTN_HEIGHT = 45 +const MORE_BTN_HEIGHT = 32 const overflowEffect = ( navWidth: number, @@ -254,7 +254,7 @@ export const UnderlineNav = forwardRef( // Only mark as ready once widths have been measured for all valid children const widths = displayIcons ? childWidthArray : noIconChildWidthArray - if (!isReady && widths.length > 0 && widths.length === validChildren.length) { + if (!isReady && widths.length > 0 && widths.length >= validChildren.length) { setIsReady(true) } }, @@ -351,97 +351,101 @@ export const UnderlineNav = forwardRef( > {listItems} - {menuItems.length > 0 && ( -
  • - {!onlyMenuVisible &&
    } - - = baseMenuMinWidth - ? baseMenuInlineStyles - : menuInlineStyles), - display: isWidgetOpen ? 'block' : 'none', - }} - > - {menuItems.map((menuItem, index) => { - const { - children: menuItemChildren, - counter, - 'aria-current': ariaCurrent, - onSelect, - ...menuItemProps - } = menuItem.props - - // This logic is used to pop the selected item out of the menu and into the list when the navigation is control externally - if (Boolean(ariaCurrent) && ariaCurrent !== 'false') { - const event = new MouseEvent('click') - !onlyMenuVisible && - swapMenuItemWithListItem( - menuItem, - index, - // @ts-ignore - not a big deal because it is internally creating an event but ask help - event as React.MouseEvent, - updateListAndMenu, - ) - } - - return ( - | React.KeyboardEvent, - ) => { - // When there are no items in the list, do not run the swap function as we want to keep everything in the menu. - !onlyMenuVisible && swapMenuItemWithListItem(menuItem, index, event, updateListAndMenu) - closeOverlay() - focusOnMoreMenuBtn() - // fire onSelect event that comes from the UnderlineNav.Item (if it is defined) - typeof onSelect === 'function' && onSelect(event) - }} - {...menuItemProps} - > - - {menuItemChildren} - {loadingCounters ? ( - - ) : ( - counter !== undefined && ( - - {counter} - - ) - )} - - - ) - })} - -
  • - )}
    +
    0 ? 'flex' : undefined, + alignItems: 'center', + height: `${MORE_BTN_HEIGHT}px`, + }} + className={classes.MoreButtonContainer} + > + {!onlyMenuVisible &&
    } + + = baseMenuMinWidth + ? baseMenuInlineStyles + : menuInlineStyles), + display: isWidgetOpen ? 'block' : 'none', + }} + > + {menuItems.map((menuItem, index) => { + const { + children: menuItemChildren, + counter, + 'aria-current': ariaCurrent, + onSelect, + ...menuItemProps + } = menuItem.props + + // This logic is used to pop the selected item out of the menu and into the list when the navigation is control externally + if (Boolean(ariaCurrent) && ariaCurrent !== 'false') { + const event = new MouseEvent('click') + !onlyMenuVisible && + swapMenuItemWithListItem( + menuItem, + index, + // @ts-ignore - not a big deal because it is internally creating an event but ask help + event as React.MouseEvent, + updateListAndMenu, + ) + } + + return ( + | React.KeyboardEvent) => { + // When there are no items in the list, do not run the swap function as we want to keep everything in the menu. + !onlyMenuVisible && swapMenuItemWithListItem(menuItem, index, event, updateListAndMenu) + closeOverlay() + focusOnMoreMenuBtn() + // fire onSelect event that comes from the UnderlineNav.Item (if it is defined) + typeof onSelect === 'function' && onSelect(event) + }} + {...menuItemProps} + > + + {menuItemChildren} + {loadingCounters ? ( + + ) : ( + counter !== undefined && ( + + {counter} + + ) + )} + + + ) + })} + +
    ) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 67d7533a96f..f6536328a5f 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -2,11 +2,12 @@ display: flex; /* stylelint-disable-next-line primer/spacing */ padding-inline: var(--stack-padding-normal); - justify-content: flex-start; - align-items: center; + padding-top: var(--base-size-8); + justify-content: space-between; + align-items: flex-start; /* make space for the underline */ - min-height: var(--control-xlarge-size, 48px); + height: var(--control-xlarge-size, 48px); /* using a box-shadow instead of a border to accommodate 'overflow-y: hidden' on UnderlinePanels */ /* stylelint-disable-next-line primer/box-shadow */ @@ -17,7 +18,13 @@ &[data-ready='false'] { overflow: hidden; + container: underline-wrapper / scroll-state; + + .UnderlineItemList { + flex-wrap: wrap; + } } + &[data-ready='true'] { overflow: visible; } @@ -53,6 +60,7 @@ background-color: transparent; border: 0; border-radius: var(--borderRadius-medium, var(--borderRadius-small)); + opacity: 1; /* button resets */ appearance: none; @@ -101,6 +109,10 @@ color: var(--fgColor-muted); align-items: center; margin-inline-end: var(--base-size-8); + + @container underline-wrapper scroll-state(scrollable: block) { + display: none; + } } .UnderlineItem [data-component='counter'] { From be8b640745ab10a00d55b515a73a8bbe9d36f040 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:12:22 +0000 Subject: [PATCH 08/24] Migrate as much logic as possible to CSS, allowing elements to register themselves for the menu --- .../src/UnderlineNav/UnderlineNav.module.css | 6 +- .../react/src/UnderlineNav/UnderlineNav.tsx | 270 ++++-------------- .../src/UnderlineNav/UnderlineNavContext.tsx | 14 +- .../src/UnderlineNav/UnderlineNavItem.tsx | 53 ++-- .../UnderlineTabbedInterface.module.css | 23 +- .../components/UnderlineTabbedInterface.tsx | 10 +- 6 files changed, 91 insertions(+), 285 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css index 14d9ee8f151..cae955f9a89 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css @@ -5,11 +5,13 @@ } .MoreButtonContainer { - display: none; + display: flex; + visibility: hidden; align-items: center; + /* Visibility is controlled by scroll-state query instead of React state so that it's known during SSR */ @container underline-wrapper scroll-state(scrollable: block) { - display: flex; + visibility: visible; } } diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 6f4156b5c83..ea29e4107a3 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -3,10 +3,9 @@ import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react import {UnderlineNavContext} from './UnderlineNavContext' import type {ResizeObserverEntry} from '../hooks/useResizeObserver' import {useResizeObserver} from '../hooks/useResizeObserver' -import type {ChildWidthArray, ResponsiveProps, ChildSize} from './types' import VisuallyHidden from '../_VisuallyHidden' import {dividerStyles, menuItemStyles, baseMenuMinWidth} from './styles' -import {UnderlineItemList, UnderlineWrapper, LoadingCounter, GAP} from '../internal/components/UnderlineTabbedInterface' +import {UnderlineItemList, UnderlineWrapper, LoadingCounter} from '../internal/components/UnderlineTabbedInterface' import {Button} from '../Button' import {TriangleDownIcon} from '@primer/octicons-react' import {useOnEscapePress} from '../hooks/useOnEscapePress' @@ -17,6 +16,7 @@ import CounterLabel from '../CounterLabel' import {invariant} from '../utils/invariant' import classes from './UnderlineNav.module.css' import {getAnchoredPosition} from '@primer/behaviors' +import type {UnderlineNavItemProps} from './UnderlineNavItem' export type UnderlineNavProps = { children: React.ReactNode @@ -40,94 +40,11 @@ export const MORE_BTN_WIDTH = 86 // The height is needed to make sure we don't have a layout shift when the more button is the only item in the nav. const MORE_BTN_HEIGHT = 32 -const overflowEffect = ( - navWidth: number, - moreMenuWidth: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - childArray: Array>, - childWidthArray: ChildWidthArray, - noIconChildWidthArray: ChildWidthArray, - updateListAndMenu: (props: ResponsiveProps, iconsVisible: boolean) => void, -) => { - let iconsVisible = true - if (childWidthArray.length === 0) { - updateListAndMenu({items: childArray, menuItems: []}, iconsVisible) - } - const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth) - const numberOfItemsWithoutIconPossible = calculatePossibleItems(noIconChildWidthArray, navWidth) - // We need to take more menu width into account when calculating the number of items possible - const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems( - noIconChildWidthArray, - navWidth, - moreMenuWidth || MORE_BTN_WIDTH, - ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: Array> = [] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuItems: Array> = [] - - // First, we check if we can fit all the items with their icons - if (childArray.length <= numberOfItemsPossible) { - items.push(...childArray) - } else if (childArray.length <= numberOfItemsWithoutIconPossible) { - // if we can't fit all the items with their icons, we check if we can fit all the items without their icons - iconsVisible = false - items.push(...childArray) - } else { - // if we can't fit all the items without their icons, we keep the icons hidden and show the ones that doesn't fit into the list in the overflow menu - iconsVisible = false - - /* Below is an accessibility requirement. Never show only one item in the overflow menu. - * If there is only one item left to display in the overflow menu according to the calculation, - * we need to pull another item from the list into the overflow menu. - */ - const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu - const numberOfListItems = - numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu - for (const [index, child] of childArray.entries()) { - if (index < numberOfListItems) { - items.push(child) - } else { - const ariaCurrent = child.props['aria-current'] - const isCurrent = Boolean(ariaCurrent) && ariaCurrent !== 'false' - // We need to make sure to keep the selected item always visible. - // To do that, we swap the selected item with the last item in the list to make it visible. (When there is at least 1 item in the list to swap.) - if (isCurrent && numberOfListItems > 0) { - // If selected item couldn't make in to the list, we swap it with the last item in the list. - const indexToReplaceAt = numberOfListItems - 1 // because we are replacing the last item in the list - // splice method modifies the array by removing 1 item here at the given index and replace it with the "child" element then returns the removed item. - const propsectiveAction = items.splice(indexToReplaceAt, 1, child)[0] - menuItems.push(propsectiveAction) - } else { - menuItems.push(child) - } - } - } - } - updateListAndMenu({items, menuItems}, iconsVisible) -} - export const getValidChildren = (children: React.ReactNode) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] } -const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => { - const widthToFit = navWidth - moreMenuWidth - let breakpoint = childWidthArray.length // assume all items will fit - let sumsOfChildWidth = 0 - for (const [index, childWidth] of childWidthArray.entries()) { - sumsOfChildWidth = sumsOfChildWidth + childWidth.width + GAP - if (sumsOfChildWidth > widthToFit) { - breakpoint = index - break - } else { - continue - } - } - return breakpoint -} - // Inline styles converted from baseMenuStyles for use as CSSProperties const baseMenuInlineStyles: React.CSSProperties = { position: 'absolute', @@ -157,37 +74,41 @@ export const UnderlineNav = forwardRef( const backupRef = useRef(null) const navRef = (forwardedRef ?? backupRef) as RefObject const listRef = useRef(null) - const moreMenuRef = useRef(null) + const moreMenuRef = useRef(null) const moreMenuBtnRef = useRef(null) const containerRef = React.useRef(null) const disclosureWidgetId = useId() const [isWidgetOpen, setIsWidgetOpen] = useState(false) - const [iconsVisible, setIconsVisible] = useState(true) - const [childWidthArray, setChildWidthArray] = useState([]) - const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) - // Track whether the initial overflow calculation is complete to prevent CLS - const [isReady, setIsReady] = useState(false) - const validChildren = getValidChildren(children) + /** Tracks whether any item has ever overflowed for the lifecycle of this component. Used to prevent flickering. */ + const [isOrEverHasOverflowed, setIsOrEverHasOverflowed] = useState(false) - // Responsive props object manages which items are in the list and which items are in the menu. - const [responsiveProps, setResponsiveProps] = useState({ - items: validChildren, - menuItems: [], - }) + const [registeredItems, setRegisteredItems] = useState(() => new Map()) - // Make sure to have the fresh props data for list items when children are changed (keeping aria-current up-to-date) - const listItems = responsiveProps.items.map(item => { - return validChildren.find(child => child.key === item.key) ?? item - }) + const registerItem = useCallback((id: string, menuItemProps: UnderlineNavItemProps | null) => { + setRegisteredItems(prev => { + if (menuItemProps === null && prev.get(id) === null) return prev + + if (menuItemProps !== null) setIsOrEverHasOverflowed(true) + + const copy = new Map(prev) + copy.set(id, menuItemProps) + return copy + }) + }, []) + + const unregisterItem = useCallback((id: string) => { + setRegisteredItems(prev => { + if (!prev.has(id)) return prev + + const copy = new Map(prev) + copy.delete(id) + return copy + }) + }, []) - // Make sure to have the fresh props data for menu items when children are changed (keeping aria-current up-to-date) - const menuItems = responsiveProps.menuItems.map(menuItem => { - return validChildren.find(child => child.key === menuItem.key) ?? menuItem - }) - // This is the case where the viewport is too narrow to show any list item with the more menu. In this case, we only show the dropdown - const onlyMenuVisible = responsiveProps.items.length === 0 + const validChildren = getValidChildren(children) if (__DEV__) { // Practically, this is not a conditional hook, it is just making sure this hook runs only on DEV not PROD. @@ -202,78 +123,6 @@ export const UnderlineNav = forwardRef( }) } - function getItemsWidth(itemText: string): number { - return noIconChildWidthArray.find(item => item.text === itemText)?.width ?? 0 - } - - const swapMenuItemWithListItem = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prospectiveListItem: React.ReactElement, - indexOfProspectiveListItem: number, - event: React.MouseEvent | React.KeyboardEvent, - callback: (props: ResponsiveProps, displayIcons: boolean) => void, - ) => { - // get the selected menu item's width - const widthToFitIntoList = getItemsWidth(prospectiveListItem.props.children) - // Check if there is any empty space on the right side of the list - const availableSpace = - (navRef.current?.getBoundingClientRect().width ?? 0) - (listRef.current?.getBoundingClientRect().width ?? 0) - - // Calculate how many items need to be pulled in to the menu to make room for the selected menu item - // I.e. if we need to pull 2 items in (index 0 and index 1), breakpoint (index) will return 1. - const index = getBreakpointForItemSwapping(widthToFitIntoList, availableSpace) - const indexToSliceAt = responsiveProps.items.length - 1 - index - // Form the new list of items - const itemsLeftInList = [...responsiveProps.items].slice(0, indexToSliceAt) - const updatedItemList = [...itemsLeftInList, prospectiveListItem] - // Form the new menu items - const itemsToAddToMenu = [...responsiveProps.items].slice(indexToSliceAt) - const updatedMenuItems = [...menuItems] - // Add itemsToAddToMenu array's items to the menu at the index of the prospectiveListItem and remove 1 count of items (prospectiveListItem) - updatedMenuItems.splice(indexOfProspectiveListItem, 1, ...itemsToAddToMenu) - callback({items: updatedItemList, menuItems: updatedMenuItems}, false) - } - // How many items do we need to pull in to the menu to make room for the selected menu item. - function getBreakpointForItemSwapping(widthToFitIntoList: number, availableSpace: number) { - let widthToSwap = 0 - let breakpoint = 0 - for (const [index, item] of [...responsiveProps.items].reverse().entries()) { - widthToSwap += getItemsWidth(item.props.children) - if (widthToFitIntoList < widthToSwap + availableSpace) { - breakpoint = index - break - } - } - return breakpoint - } - - const updateListAndMenu = useCallback( - (props: ResponsiveProps, displayIcons: boolean) => { - setResponsiveProps(props) - setIconsVisible(displayIcons) - - // Only mark as ready once widths have been measured for all valid children - const widths = displayIcons ? childWidthArray : noIconChildWidthArray - if (!isReady && widths.length > 0 && widths.length >= validChildren.length) { - setIsReady(true) - } - }, - [childWidthArray, noIconChildWidthArray, isReady, validChildren.length], - ) - const setChildrenWidth = useCallback((size: ChildSize) => { - setChildWidthArray(arr => { - const newArr = [...arr, size] - return newArr - }) - }, []) - - const setNoIconChildrenWidth = useCallback((size: ChildSize) => { - setNoIconChildWidthArray(arr => { - const newArr = [...arr, size] - return newArr - }) - }, []) - const closeOverlay = React.useCallback(() => { setIsWidgetOpen(false) }, [setIsWidgetOpen]) @@ -302,19 +151,10 @@ export const UnderlineNav = forwardRef( useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) + const [containerWidth, setContainerWidth] = useState(-1) useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - const navWidth = resizeObserverEntries[0].contentRect.width - const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 - navWidth !== 0 && - overflowEffect( - navWidth, - moreMenuWidth, - validChildren, - childWidthArray, - noIconChildWidthArray, - updateListAndMenu, - ) - }, navRef as RefObject) + setContainerWidth(resizeObserverEntries[0].contentRect.width) + }, navRef) // Compute menuInlineStyles if needed let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles} @@ -331,13 +171,18 @@ export const UnderlineNav = forwardRef( } } + const menuItems = Array.from(registeredItems.entries()).filter( + (entry): entry is [string, UnderlineNavItemProps] => entry[1] !== null, + ) + const onlyMenuVisible = menuItems.length === registeredItems.size + return ( {ariaLabel && {`${ariaLabel} navigation`}} @@ -347,15 +192,18 @@ export const UnderlineNav = forwardRef( className={className} ref={navRef} data-variant={variant} - ready={isReady} + // When the component is first rendered, we use a scroll-state CSS container query to determine whether to + // hide icons. This works well for SSR where we can't have run any effects yet. But the pure CSS approach + // can cause flickering because hiding the icons makes more space, allowing them to show, which fills that + // space...so after that, if anything has ever wrapped, we just keep the icons hidden to prevent that flickering. + data-hide-icons={isOrEverHasOverflowed ? 'true' : 'false'} > - {listItems} + {children}
    0 ? 'flex' : undefined, alignItems: 'center', height: `${MORE_BTN_HEIGHT}px`, }} @@ -393,35 +241,15 @@ export const UnderlineNav = forwardRef( display: isWidgetOpen ? 'block' : 'none', }} > - {menuItems.map((menuItem, index) => { - const { - children: menuItemChildren, - counter, - 'aria-current': ariaCurrent, - onSelect, - ...menuItemProps - } = menuItem.props - - // This logic is used to pop the selected item out of the menu and into the list when the navigation is control externally - if (Boolean(ariaCurrent) && ariaCurrent !== 'false') { - const event = new MouseEvent('click') - !onlyMenuVisible && - swapMenuItemWithListItem( - menuItem, - index, - // @ts-ignore - not a big deal because it is internally creating an event but ask help - event as React.MouseEvent, - updateListAndMenu, - ) - } + {menuItems.map(([key, allProps]) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {children: menuItemChildren, counter, onSelect, as, ...menuItemProps} = allProps return ( | React.KeyboardEvent) => { - // When there are no items in the list, do not run the swap function as we want to keep everything in the menu. - !onlyMenuVisible && swapMenuItemWithListItem(menuItem, index, event, updateListAndMenu) closeOverlay() focusOnMoreMenuBtn() // fire onSelect event that comes from the UnderlineNav.Item (if it is defined) diff --git a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx index f276771efac..4dd18faa364 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx @@ -1,14 +1,14 @@ -import type React from 'react' import {createContext} from 'react' +import type {UnderlineNavItemProps} from './UnderlineNavItem' export const UnderlineNavContext = createContext<{ - setChildrenWidth: React.Dispatch<{text: string; width: number}> - setNoIconChildrenWidth: React.Dispatch<{text: string; width: number}> loadingCounters: boolean - iconsVisible: boolean + containerWidth: number + registerItem: (id: string, props: UnderlineNavItemProps | null) => void + unregisterItem: (id: string) => void }>({ - setChildrenWidth: () => null, - setNoIconChildrenWidth: () => null, loadingCounters: false, - iconsVisible: true, + containerWidth: -1, + registerItem: () => {}, + unregisterItem: () => {}, }) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3341f677e11..857b0af0aae 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -1,5 +1,5 @@ -import type {MutableRefObject, RefObject} from 'react' -import React, {forwardRef, useRef, useContext} from 'react' +import type {RefObject} from 'react' +import React, {forwardRef, useRef, useContext, memo, useEffect, useId, useState} from 'react' import type {IconProps} from '@primer/octicons-react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {UnderlineNavContext} from './UnderlineNavContext' @@ -59,9 +59,9 @@ export type UnderlineNavItemProps = { counter?: number | string } & LinkProps -export const UnderlineNavItem = forwardRef( - ( - { +export const UnderlineNavItem = memo( + forwardRef((allProps, forwardedRef) => { + const { as: Component = 'a', href = '#', children, @@ -71,36 +71,24 @@ export const UnderlineNavItem = forwardRef( icon: Icon, leadingVisual, ...props - }, - forwardedRef, - ) => { + } = allProps + const backupRef = useRef(null) const ref = (forwardedRef ?? backupRef) as RefObject - const {setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} = useContext(UnderlineNavContext) + const {loadingCounters, containerWidth, registerItem, unregisterItem} = useContext(UnderlineNavContext) + + const id = useId() + const [isOverflowing, setIsOverflowing] = useState(false) useLayoutEffect(() => { if (ref.current) { - const domRect = (ref as MutableRefObject).current.getBoundingClientRect() - - const icon = Array.from((ref as MutableRefObject).current.children).find( - child => child.getAttribute('data-component') === 'icon', - ) - - const content = Array.from((ref as MutableRefObject).current.children).find( - child => child.getAttribute('data-component') === 'text', - ) as HTMLElement - const text = content.textContent as string - - const iconWidthWithMargin = icon - ? icon.getBoundingClientRect().width + - Number(getComputedStyle(icon).marginRight.slice(0, -2)) + - Number(getComputedStyle(icon).marginLeft.slice(0, -2)) - : 0 - - setChildrenWidth({text, width: domRect.width}) - setNoIconChildrenWidth({text, width: domRect.width - iconWidthWithMargin}) + const isOverflowing = ref.current.offsetTop > 0 + setIsOverflowing(isOverflowing) + registerItem(id, isOverflowing ? allProps : null) } - }, [ref, setChildrenWidth, setNoIconChildrenWidth]) + }, [ref, containerWidth, registerItem, id, allProps]) + + useEffect(() => () => unregisterItem(id), [id, unregisterItem]) const keyDownHandler = React.useCallback( (event: React.KeyboardEvent) => { @@ -131,14 +119,15 @@ export const UnderlineNavItem = forwardRef( counter={counter} icon={leadingVisual ?? Icon} loadingCounters={loadingCounters} - iconsVisible={iconsVisible} {...props} + aria-hidden={isOverflowing ? true : allProps['aria-hidden']} + tabIndex={isOverflowing ? -1 : allProps.tabIndex} > {children} ) - }, -) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps> + }) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps>, +) UnderlineNavItem.displayName = 'UnderlineNavItem' diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index f6536328a5f..171ac420038 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -5,6 +5,8 @@ padding-top: var(--base-size-8); justify-content: space-between; align-items: flex-start; + overflow: hidden; + container: underline-wrapper / scroll-state; /* make space for the underline */ height: var(--control-xlarge-size, 48px); @@ -13,26 +15,14 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: inset 0 -1px var(--borderColor-muted); - /* Hide overflow until calculation is complete to prevent CLS */ - overflow: visible; - - &[data-ready='false'] { - overflow: hidden; - container: underline-wrapper / scroll-state; - - .UnderlineItemList { - flex-wrap: wrap; - } - } - - &[data-ready='true'] { - overflow: visible; - } - &[data-variant='flush'] { /* stylelint-disable-next-line primer/spacing */ padding-inline: unset; } + + &[data-hide-icons='true'] [data-component='icon'] { + display: none; + } } .UnderlineItemList { @@ -44,6 +34,7 @@ list-style: none; align-items: center; gap: 8px; + flex-wrap: wrap; } .UnderlineItem { diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 107b2b1407f..102cd141da1 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -32,18 +32,15 @@ type UnderlineWrapperProps = { as?: As className?: string ref?: React.Ref - /** Indicates whether the overflow calculation is complete. When false, overflow is hidden to prevent CLS. */ - ready?: boolean } export const UnderlineWrapper = forwardRef((props, ref) => { - const {children, className, as: Component = 'div', ready, ...rest} = props + const {children, className, as: Component = 'div', ...rest} = props return ( } - data-ready={ready === undefined ? undefined : ready ? 'true' : 'false'} {...rest} > {children} @@ -66,7 +63,6 @@ export const LoadingCounter = () => { export type UnderlineItemProps = { as?: As | 'a' | 'button' className?: string - iconsVisible?: boolean loadingCounters?: boolean counter?: number | string // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -76,11 +72,11 @@ export type UnderlineItemProps = { } & React.ComponentPropsWithoutRef export const UnderlineItem = React.forwardRef((props, ref) => { - const {as: Component = 'a', children, counter, icon: Icon, iconsVisible, loadingCounters, className, ...rest} = props + const {as: Component = 'a', children, counter, icon: Icon, loadingCounters, className, ...rest} = props const textContent = getTextContent(children) return ( - {iconsVisible && Icon && {isElement(Icon) ? Icon : }} + {Icon && {isElement(Icon) ? Icon : }} {children && ( {children} From f5b4a72b67c73ea01da41497d895a8ba04e734bb Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:27:37 +0000 Subject: [PATCH 09/24] Add comments about registry width --- packages/react/src/UnderlineNav/UnderlineNavItem.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 857b0af0aae..f40df3461b5 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -82,12 +82,19 @@ export const UnderlineNavItem = memo( useLayoutEffect(() => { if (ref.current) { + // Overflowing items wrap onto subsequent lines, so their `offsetTop` increases const isOverflowing = ref.current.offsetTop > 0 setIsOverflowing(isOverflowing) + + // Even if an item is not overflowing, it still needs to register itself to claim it's place in the registry. + // This preserves order - otherwise, items that overflow first would appear first in the menu. registerItem(id, isOverflowing ? allProps : null) } + + // To preserve the item's spot in the registry, we don't unregister until we actually dismount the component. }, [ref, containerWidth, registerItem, id, allProps]) + // Unregister only on dismount: useEffect(() => () => unregisterItem(id), [id, unregisterItem]) const keyDownHandler = React.useCallback( From ff309318a61acfb34d06e3bf126d6292edf72636 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:28:52 +0000 Subject: [PATCH 10/24] Remove opacity from item --- .../src/internal/components/UnderlineTabbedInterface.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 171ac420038..ab7f3426e23 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -51,7 +51,6 @@ background-color: transparent; border: 0; border-radius: var(--borderRadius-medium, var(--borderRadius-small)); - opacity: 1; /* button resets */ appearance: none; From dfda9852afc38feeaa08b927b42a44c420661757 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:30:20 +0000 Subject: [PATCH 11/24] Disable menu item anchor when empty and hidden --- packages/react/src/UnderlineNav/UnderlineNav.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index ea29e4107a3..3fe929d7092 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -217,6 +217,7 @@ export const UnderlineNav = forwardRef( aria-expanded={isWidgetOpen} onClick={onAnchorClick} trailingAction={TriangleDownIcon} + disabled={menuItems.length === 0} > {onlyMenuVisible ? ( From ab2806cb93396fc4b774e5fcd582b42b648b0cc2 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:40:24 +0000 Subject: [PATCH 12/24] Remove unecessary memo --- .../src/UnderlineNav/UnderlineNavItem.tsx | 152 +++++++++--------- 1 file changed, 75 insertions(+), 77 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index f40df3461b5..3b2a3cf7c45 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -1,5 +1,5 @@ import type {RefObject} from 'react' -import React, {forwardRef, useRef, useContext, memo, useEffect, useId, useState} from 'react' +import React, {forwardRef, useRef, useContext, useEffect, useId, useState} from 'react' import type {IconProps} from '@primer/octicons-react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {UnderlineNavContext} from './UnderlineNavContext' @@ -59,82 +59,80 @@ export type UnderlineNavItemProps = { counter?: number | string } & LinkProps -export const UnderlineNavItem = memo( - forwardRef((allProps, forwardedRef) => { - const { - as: Component = 'a', - href = '#', - children, - counter, - onSelect, - 'aria-current': ariaCurrent, - icon: Icon, - leadingVisual, - ...props - } = allProps - - const backupRef = useRef(null) - const ref = (forwardedRef ?? backupRef) as RefObject - const {loadingCounters, containerWidth, registerItem, unregisterItem} = useContext(UnderlineNavContext) - - const id = useId() - const [isOverflowing, setIsOverflowing] = useState(false) - - useLayoutEffect(() => { - if (ref.current) { - // Overflowing items wrap onto subsequent lines, so their `offsetTop` increases - const isOverflowing = ref.current.offsetTop > 0 - setIsOverflowing(isOverflowing) - - // Even if an item is not overflowing, it still needs to register itself to claim it's place in the registry. - // This preserves order - otherwise, items that overflow first would appear first in the menu. - registerItem(id, isOverflowing ? allProps : null) +export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { + const { + as: Component = 'a', + href = '#', + children, + counter, + onSelect, + 'aria-current': ariaCurrent, + icon: Icon, + leadingVisual, + ...props + } = allProps + + const backupRef = useRef(null) + const ref = (forwardedRef ?? backupRef) as RefObject + const {loadingCounters, containerWidth, registerItem, unregisterItem} = useContext(UnderlineNavContext) + + const id = useId() + const [isOverflowing, setIsOverflowing] = useState(false) + + useLayoutEffect(() => { + if (ref.current) { + // Overflowing items wrap onto subsequent lines, so their `offsetTop` increases + const isOverflowing = ref.current.offsetTop > 0 + setIsOverflowing(isOverflowing) + + // Even if an item is not overflowing, it still needs to register itself to claim it's place in the registry. + // This preserves order - otherwise, items that overflow first would appear first in the menu. + registerItem(id, isOverflowing ? allProps : null) + } + + // To preserve the item's spot in the registry, we don't unregister until we actually dismount the component. + }, [ref, containerWidth, registerItem, id, allProps]) + + // Unregister only on dismount: + useEffect(() => () => unregisterItem(id), [id, unregisterItem]) + + const keyDownHandler = React.useCallback( + (event: React.KeyboardEvent) => { + if ((event.key === ' ' || event.key === 'Enter') && !event.defaultPrevented && typeof onSelect === 'function') { + onSelect(event) } - - // To preserve the item's spot in the registry, we don't unregister until we actually dismount the component. - }, [ref, containerWidth, registerItem, id, allProps]) - - // Unregister only on dismount: - useEffect(() => () => unregisterItem(id), [id, unregisterItem]) - - const keyDownHandler = React.useCallback( - (event: React.KeyboardEvent) => { - if ((event.key === ' ' || event.key === 'Enter') && !event.defaultPrevented && typeof onSelect === 'function') { - onSelect(event) - } - }, - [onSelect], - ) - const clickHandler = React.useCallback( - (event: React.MouseEvent) => { - if (!event.defaultPrevented && typeof onSelect === 'function') { - onSelect(event) - } - }, - [onSelect], - ) - - return ( -
  • - - {children} - -
  • - ) - }) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps>, -) + }, + [onSelect], + ) + const clickHandler = React.useCallback( + (event: React.MouseEvent) => { + if (!event.defaultPrevented && typeof onSelect === 'function') { + onSelect(event) + } + }, + [onSelect], + ) + + return ( +
  • + + {children} + +
  • + ) +}) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps> UnderlineNavItem.displayName = 'UnderlineNavItem' From 588223e21069b442272a75e5551f6b9db209a2fc Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:43:53 +0000 Subject: [PATCH 13/24] Add todo comment --- packages/react/src/UnderlineNav/UnderlineNavItem.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3b2a3cf7c45..66717660733 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -91,6 +91,10 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { } // To preserve the item's spot in the registry, we don't unregister until we actually dismount the component. + + // TODO: We should try to trim the registry down to the bare minimum needed to render a menu item so that we don't + // have to re-register every time `allProps` changes. + // See /workspaces/react/packages/react/src/ActionBar/ActionBar.tsx#531 for example. }, [ref, containerWidth, registerItem, id, allProps]) // Unregister only on dismount: From 68f2ef1db10b9675b57dad1f18f092e3d87e6049 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Fri, 6 Feb 2026 20:52:00 +0000 Subject: [PATCH 14/24] Add `overflow: hidden` to parent list --- .../src/internal/components/UnderlineTabbedInterface.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index ab7f3426e23..b9f2f439826 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -35,6 +35,8 @@ align-items: center; gap: 8px; flex-wrap: wrap; + /* Allows the menu to clip wrapped items to keep on shrinking even if a wrapped item was very wide */ + overflow: hidden; } .UnderlineItem { From 184ea86a6739d45619635c0af98092b8ca032130 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 9 Feb 2026 15:08:38 +0000 Subject: [PATCH 15/24] Disable stylelint error for scroll-state rule --- .../src/internal/components/UnderlineTabbedInterface.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index b9f2f439826..7310bf09eb6 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -6,6 +6,7 @@ justify-content: space-between; align-items: flex-start; overflow: hidden; + /* stylelint-disable-next-line declaration-property-value-no-unknown */ container: underline-wrapper / scroll-state; /* make space for the underline */ From 591b5ecc2a4b3d8d385b4ab929d1b3e4a3f656a9 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 9 Feb 2026 15:42:05 +0000 Subject: [PATCH 16/24] Fix failing unit tests --- packages/react/src/UnderlineNav/UnderlineNav.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx index 6039ec2ad8a..b91ef0b93c8 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx @@ -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, @@ -78,7 +78,8 @@ describe('UnderlineNav', () => { it('renders icons correctly', () => { const {getByRole} = render() 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 () => { @@ -143,7 +144,7 @@ describe('UnderlineNav', () => { it('respects loadingCounters prop', () => { const {getByRole} = render() - 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('') From b65d3978d9339b913e736e8466d3ebb824bb4334 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 9 Feb 2026 16:23:38 +0000 Subject: [PATCH 17/24] Truncate last menu item --- .../src/UnderlineNav/UnderlineNav.examples.stories.tsx | 2 +- .../react/src/UnderlineNav/UnderlineNavItem.module.css | 1 + .../components/UnderlineTabbedInterface.module.css | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.examples.stories.tsx b/packages/react/src/UnderlineNav/UnderlineNav.examples.stories.tsx index 180c7730e30..13eb842f5b9 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.examples.stories.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.examples.stories.tsx @@ -65,7 +65,7 @@ export const PullRequestPage = () => { } const items: {navigation: string; icon: React.ReactElement; counter?: number | string; href?: string}[] = [ - {navigation: 'Code', icon: , href: '#code'}, + {navigation: 'Code (really really long first item title)', icon: , href: '#code'}, {navigation: 'Issues', icon: , counter: '12K', href: '#issues'}, {navigation: 'Pull Requests', icon: , counter: 13, href: '#pull-requests'}, {navigation: 'Discussions', icon: , counter: 5, href: '#discussions'}, diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.module.css b/packages/react/src/UnderlineNav/UnderlineNavItem.module.css index c1e75b06204..3cc20b69036 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.module.css @@ -2,4 +2,5 @@ display: flex; flex-direction: column; align-items: center; + overflow: hidden; } diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 7310bf09eb6..1ac1e8d631b 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -54,6 +54,7 @@ background-color: transparent; border: 0; border-radius: var(--borderRadius-medium, var(--borderRadius-small)); + max-width: 100%; /* button resets */ appearance: none; @@ -108,6 +109,12 @@ } } +.UnderlineItem [data-component='text'] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .UnderlineItem [data-component='counter'] { margin-inline-start: var(--base-size-8); display: flex; From e0773f5aa9bc0be0105309d824fceaf766a8a6cc Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 20:34:02 +0000 Subject: [PATCH 18/24] Replace overflow menu with `ActionMenu` --- .../src/UnderlineNav/UnderlineNav.module.css | 10 +- .../react/src/UnderlineNav/UnderlineNav.tsx | 197 +++++------------- packages/react/src/UnderlineNav/styles.ts | 11 - 3 files changed, 60 insertions(+), 158 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css index cae955f9a89..64fe0c8e19b 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css @@ -1,9 +1,3 @@ -.MenuItemContent { - display: flex; - align-items: center; - justify-content: space-between; -} - .MoreButtonContainer { display: flex; visibility: hidden; @@ -15,6 +9,10 @@ } } +.OverflowMenuItem [aria-current] .OverflowMenuItemLabel { + font-weight: var(--base-text-weight-semibold); +} + /* More button styles migrated from styles.ts (was moreBtnStyles) */ .MoreButton { margin: 0; /* reset Safari extra margin */ diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index 3fe929d7092..e22bf2ccb74 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -1,21 +1,16 @@ import type {RefObject} from 'react' -import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react' -import {UnderlineNavContext} from './UnderlineNavContext' -import type {ResizeObserverEntry} from '../hooks/useResizeObserver' -import {useResizeObserver} from '../hooks/useResizeObserver' +import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react' import VisuallyHidden from '../_VisuallyHidden' -import {dividerStyles, menuItemStyles, baseMenuMinWidth} from './styles' -import {UnderlineItemList, UnderlineWrapper, LoadingCounter} from '../internal/components/UnderlineTabbedInterface' -import {Button} from '../Button' -import {TriangleDownIcon} from '@primer/octicons-react' -import {useOnEscapePress} from '../hooks/useOnEscapePress' -import {useOnOutsideClick} from '../hooks/useOnOutsideClick' -import {useId} from '../hooks/useId' import {ActionList} from '../ActionList' +import {ActionMenu} from '../ActionMenu' import CounterLabel from '../CounterLabel' +import type {ResizeObserverEntry} from '../hooks/useResizeObserver' +import {useResizeObserver} from '../hooks/useResizeObserver' +import {LoadingCounter, UnderlineItemList, UnderlineWrapper} from '../internal/components/UnderlineTabbedInterface' import {invariant} from '../utils/invariant' +import {dividerStyles} from './styles' import classes from './UnderlineNav.module.css' -import {getAnchoredPosition} from '@primer/behaviors' +import {UnderlineNavContext} from './UnderlineNavContext' import type {UnderlineNavItemProps} from './UnderlineNavItem' export type UnderlineNavProps = { @@ -45,20 +40,6 @@ export const getValidChildren = (children: React.ReactNode) => { return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] } -// Inline styles converted from baseMenuStyles for use as CSSProperties -const baseMenuInlineStyles: React.CSSProperties = { - position: 'absolute', - zIndex: 1, - top: '90%', - boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)', - borderRadius: 12, - background: 'var(--overlay-bgColor)', - listStyle: 'none', - minWidth: `${baseMenuMinWidth}px`, - maxWidth: '640px', - right: 0, -} - export const UnderlineNav = forwardRef( ( { @@ -74,12 +55,6 @@ export const UnderlineNav = forwardRef( const backupRef = useRef(null) const navRef = (forwardedRef ?? backupRef) as RefObject const listRef = useRef(null) - const moreMenuRef = useRef(null) - const moreMenuBtnRef = useRef(null) - const containerRef = React.useRef(null) - const disclosureWidgetId = useId() - - const [isWidgetOpen, setIsWidgetOpen] = useState(false) /** Tracks whether any item has ever overflowed for the lifecycle of this component. Used to prevent flickering. */ const [isOrEverHasOverflowed, setIsOrEverHasOverflowed] = useState(false) @@ -123,54 +98,11 @@ export const UnderlineNav = forwardRef( }) } - const closeOverlay = React.useCallback(() => { - setIsWidgetOpen(false) - }, [setIsWidgetOpen]) - - const focusOnMoreMenuBtn = React.useCallback(() => { - moreMenuBtnRef.current?.focus() - }, []) - - const onAnchorClick = useCallback((event: React.MouseEvent) => { - if (event.defaultPrevented || event.button !== 0) { - return - } - setIsWidgetOpen(isWidgetOpen => !isWidgetOpen) - }, []) - - useOnEscapePress( - (event: KeyboardEvent) => { - if (isWidgetOpen) { - event.preventDefault() - closeOverlay() - focusOnMoreMenuBtn() - } - }, - [isWidgetOpen], - ) - - useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) - const [containerWidth, setContainerWidth] = useState(-1) useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { setContainerWidth(resizeObserverEntries[0].contentRect.width) }, navRef) - // Compute menuInlineStyles if needed - let menuInlineStyles: React.CSSProperties = {...baseMenuInlineStyles} - if (containerRef.current && listRef.current) { - const {left} = getAnchoredPosition(containerRef.current, listRef.current, { - align: 'start', - side: 'outside-bottom', - }) - - menuInlineStyles = { - ...baseMenuInlineStyles, - right: undefined, - left, - } - } - const menuItems = Array.from(registeredItems.entries()).filter( (entry): entry is [string, UnderlineNavItemProps] => entry[1] !== null, ) @@ -201,79 +133,62 @@ export const UnderlineNav = forwardRef( {children} +
    - {!onlyMenuVisible &&
    } - - = baseMenuMinWidth - ? baseMenuInlineStyles - : menuInlineStyles), - display: isWidgetOpen ? 'block' : 'none', - }} - > - {menuItems.map(([key, allProps]) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {children: menuItemChildren, counter, onSelect, as, ...menuItemProps} = allProps - - return ( - | React.KeyboardEvent) => { - closeOverlay() - focusOnMoreMenuBtn() - // fire onSelect event that comes from the UnderlineNav.Item (if it is defined) - typeof onSelect === 'function' && onSelect(event) - }} - {...menuItemProps} - > - - {menuItemChildren} - {loadingCounters ? ( - - ) : ( - counter !== undefined && ( - - {counter} - - ) - )} - - - ) - })} - + {!onlyMenuVisible &&
    } + + + + + {onlyMenuVisible ? ( + <> + {`${ariaLabel}`} Menu + + ) : ( + <> + More {`${ariaLabel} items`} + + )} + + + + + + {menuItems.map(([key, allProps]) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {children: menuItemChildren, counter, as, onSelect, ...menuItemProps} = allProps + + return ( + onSelect?.(event)} + {...menuItemProps} + > + {menuItemChildren} + + {loadingCounters ? ( + + ) : ( + counter !== undefined && ( + + {counter} + + ) + )} + + + ) + })} + + +
    diff --git a/packages/react/src/UnderlineNav/styles.ts b/packages/react/src/UnderlineNav/styles.ts index 71ad7910619..6d3d7ae037b 100644 --- a/packages/react/src/UnderlineNav/styles.ts +++ b/packages/react/src/UnderlineNav/styles.ts @@ -6,14 +6,3 @@ export const dividerStyles = { marginRight: 'var(--base-size-4)', height: '24px', // The height of the divider - reference from Figma } - -export const menuItemStyles = { - // This is needed to hide the selected check icon on the menu item. https://github.com/primer/react/tree/main/packages/react/src/ActionList/Selection.tsx#L32 - '& > span': { - display: 'none', - }, - // To reset the style when the menu items are rendered as react router links - textDecoration: 'none', -} - -export const baseMenuMinWidth = 192 From 8824b2d7b5e8d42b92411bbeb76238c35e2a73c8 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 20:37:40 +0000 Subject: [PATCH 19/24] Clean up menu-only edge case (unreachable with truncation) --- packages/react/src/UnderlineNav/UnderlineNav.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index e22bf2ccb74..e3da0c6deac 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -106,7 +106,6 @@ export const UnderlineNav = forwardRef( const menuItems = Array.from(registeredItems.entries()).filter( (entry): entry is [string, UnderlineNavItemProps] => entry[1] !== null, ) - const onlyMenuVisible = menuItems.length === registeredItems.size return ( - {!onlyMenuVisible &&
    } +
    - {onlyMenuVisible ? ( - <> - {`${ariaLabel}`} Menu - - ) : ( - <> - More {`${ariaLabel} items`} - - )} + More  items From 399300c4436d4d6a198fc22f51b0f1b9496114d6 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 20:39:22 +0000 Subject: [PATCH 20/24] Migrate styles to CSS --- packages/react/src/UnderlineNav/UnderlineNav.module.css | 8 ++++++++ packages/react/src/UnderlineNav/styles.ts | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 packages/react/src/UnderlineNav/styles.ts diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css index 64fe0c8e19b..50ae35928ee 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css @@ -13,6 +13,14 @@ font-weight: var(--base-text-weight-semibold); } +.MoreButtonDivider { + display: inline-block; + border-left: var(--borderWidth-default) solid var(--borderColor-muted); + width: 0; + margin-right: var(--base-size-4); + height: var(--base-size-24); +} + /* More button styles migrated from styles.ts (was moreBtnStyles) */ .MoreButton { margin: 0; /* reset Safari extra margin */ diff --git a/packages/react/src/UnderlineNav/styles.ts b/packages/react/src/UnderlineNav/styles.ts deleted file mode 100644 index 6d3d7ae037b..00000000000 --- a/packages/react/src/UnderlineNav/styles.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const dividerStyles = { - display: 'inline-block', - borderLeft: '1px solid', - width: '1px', - borderLeftColor: 'var(--borderColor-muted)', - marginRight: 'var(--base-size-4)', - height: '24px', // The height of the divider - reference from Figma -} From 35735d26e78b5d8f81e6ff4e89614aec6c002111 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 20:42:44 +0000 Subject: [PATCH 21/24] Improve CSS comments --- .../internal/components/UnderlineTabbedInterface.module.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 5a6ae743188..c3c7ca37b3f 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -47,7 +47,7 @@ align-items: center; gap: 8px; flex-wrap: wrap; - /* Allows the menu to clip wrapped items to keep on shrinking even if a wrapped item was very wide */ + /* Allows the menu to clip wrapped items to keep on shrinking */ overflow: hidden; } @@ -115,11 +115,13 @@ align-items: center; margin-inline-end: var(--base-size-8); + /* Hide via CSS when there's an overflow (for SSR; after initial render will control with React state) */ @container underline-wrapper scroll-state(scrollable: block) { display: none; } } +/* Only the first item will ever truncate, after all other items wrap */ .UnderlineItem [data-component='text'] { overflow: hidden; text-overflow: ellipsis; From 3deb5f915b377c5b8b5ac800f7bf12d3c02a5fe8 Mon Sep 17 00:00:00 2001 From: iansan5653 <2294248+iansan5653@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:49:09 +0000 Subject: [PATCH 22/24] chore: auto-fix lint and formatting issues --- .../src/internal/components/UnderlineTabbedInterface.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index c3c7ca37b3f..87b9632e989 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -5,7 +5,6 @@ padding-top: var(--base-size-8); justify-content: space-between; align-items: flex-start; - overflow: hidden; /* stylelint-disable-next-line declaration-property-value-no-unknown */ container: underline-wrapper / scroll-state; From de7ff00cc5e66a474d6471d00f22f18309f1af0e Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 21:00:23 +0000 Subject: [PATCH 23/24] Replace `ResizeObserver` at container level with `IntersectionObserver` at item level --- .../react/src/UnderlineNav/UnderlineNav.tsx | 13 ++--------- .../src/UnderlineNav/UnderlineNavContext.tsx | 2 -- .../src/UnderlineNav/UnderlineNavItem.tsx | 22 +++++++++---------- .../UnderlineTabbedInterface.module.css | 3 --- 4 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index e3da0c6deac..df0478d8af6 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -4,11 +4,8 @@ import VisuallyHidden from '../_VisuallyHidden' import {ActionList} from '../ActionList' import {ActionMenu} from '../ActionMenu' import CounterLabel from '../CounterLabel' -import type {ResizeObserverEntry} from '../hooks/useResizeObserver' -import {useResizeObserver} from '../hooks/useResizeObserver' import {LoadingCounter, UnderlineItemList, UnderlineWrapper} from '../internal/components/UnderlineTabbedInterface' import {invariant} from '../utils/invariant' -import {dividerStyles} from './styles' import classes from './UnderlineNav.module.css' import {UnderlineNavContext} from './UnderlineNavContext' import type {UnderlineNavItemProps} from './UnderlineNavItem' @@ -98,11 +95,6 @@ export const UnderlineNav = forwardRef( }) } - const [containerWidth, setContainerWidth] = useState(-1) - useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { - setContainerWidth(resizeObserverEntries[0].contentRect.width) - }, navRef) - const menuItems = Array.from(registeredItems.entries()).filter( (entry): entry is [string, UnderlineNavItemProps] => entry[1] !== null, ) @@ -112,7 +104,6 @@ export const UnderlineNav = forwardRef( value={{ loadingCounters, registerItem, - containerWidth, unregisterItem, }} > @@ -139,10 +130,10 @@ export const UnderlineNav = forwardRef( }} className={classes.MoreButtonContainer} > -
    +
    - + More  items diff --git a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx index 4dd18faa364..d27a7af2551 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx @@ -3,12 +3,10 @@ import type {UnderlineNavItemProps} from './UnderlineNavItem' export const UnderlineNavContext = createContext<{ loadingCounters: boolean - containerWidth: number registerItem: (id: string, props: UnderlineNavItemProps | null) => void unregisterItem: (id: string) => void }>({ loadingCounters: false, - containerWidth: -1, registerItem: () => {}, unregisterItem: () => {}, }) diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 66717660733..6d1f3e6d2ee 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -3,7 +3,6 @@ import React, {forwardRef, useRef, useContext, useEffect, useId, useState} from import type {IconProps} from '@primer/octicons-react' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {UnderlineNavContext} from './UnderlineNavContext' -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' import {UnderlineItem} from '../internal/components/UnderlineTabbedInterface' import classes from './UnderlineNavItem.module.css' @@ -74,28 +73,29 @@ export const UnderlineNavItem = forwardRef((allProps, forwardedRef) => { const backupRef = useRef(null) const ref = (forwardedRef ?? backupRef) as RefObject - const {loadingCounters, containerWidth, registerItem, unregisterItem} = useContext(UnderlineNavContext) + const {loadingCounters, registerItem, unregisterItem} = useContext(UnderlineNavContext) const id = useId() const [isOverflowing, setIsOverflowing] = useState(false) - useLayoutEffect(() => { - if (ref.current) { + useEffect(() => { + const element = ref.current + if (!element) return + + const observer = new IntersectionObserver(() => { // Overflowing items wrap onto subsequent lines, so their `offsetTop` increases - const isOverflowing = ref.current.offsetTop > 0 + const isOverflowing = element.offsetTop > 0 setIsOverflowing(isOverflowing) // Even if an item is not overflowing, it still needs to register itself to claim it's place in the registry. // This preserves order - otherwise, items that overflow first would appear first in the menu. registerItem(id, isOverflowing ? allProps : null) - } + }) - // To preserve the item's spot in the registry, we don't unregister until we actually dismount the component. + observer.observe(element) - // TODO: We should try to trim the registry down to the bare minimum needed to render a menu item so that we don't - // have to re-register every time `allProps` changes. - // See /workspaces/react/packages/react/src/ActionBar/ActionBar.tsx#531 for example. - }, [ref, containerWidth, registerItem, id, allProps]) + return () => observer.disconnect() + }, [ref, registerItem, id, allProps]) // Unregister only on dismount: useEffect(() => () => unregisterItem(id), [id, unregisterItem]) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index c3c7ca37b3f..3976e310a4a 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -16,9 +16,6 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: inset 0 -1px var(--borderColor-muted); - /* Hide overflow until calculation is complete to prevent CLS */ - overflow: visible; - &[data-overflow-measured='false'] { overflow: hidden; } From 690943ae9db9629487d1367b1a535afd7a0adaaf Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 18 Feb 2026 21:14:22 +0000 Subject: [PATCH 24/24] Fix overflow: hidden --- .../components/UnderlineTabbedInterface.module.css | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 0be02ad49ce..926582094a0 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -5,6 +5,7 @@ padding-top: var(--base-size-8); justify-content: space-between; align-items: flex-start; + overflow: hidden; /* stylelint-disable-next-line declaration-property-value-no-unknown */ container: underline-wrapper / scroll-state; @@ -15,14 +16,6 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: inset 0 -1px var(--borderColor-muted); - &[data-overflow-measured='false'] { - overflow: hidden; - } - - &[data-overflow-measured='true'] { - overflow: visible; - } - &[data-variant='flush'] { /* stylelint-disable-next-line primer/spacing */ padding-inline: unset;