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/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css
index d905aff3fcf..50ae35928ee 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.module.css
+++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css
@@ -1,7 +1,24 @@
-.MenuItemContent {
+.MoreButtonContainer {
display: flex;
+ visibility: hidden;
align-items: center;
- justify-content: space-between;
+
+ /* 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) {
+ visibility: visible;
+ }
+}
+
+.OverflowMenuItem [aria-current] .OverflowMenuItemLabel {
+ 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) */
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('')
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx
index 14c52ee9683..df0478d8af6 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.tsx
+++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx
@@ -1,22 +1,14 @@
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 type {ChildWidthArray, ResponsiveProps, ChildSize} from './types'
+import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'
import VisuallyHidden from '../_VisuallyHidden'
-import {dividerStyles, menuItemStyles, baseMenuMinWidth} from './styles'
-import {UnderlineItemList, UnderlineWrapper, LoadingCounter, GAP} 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 {LoadingCounter, UnderlineItemList, UnderlineWrapper} from '../internal/components/UnderlineTabbedInterface'
import {invariant} from '../utils/invariant'
import classes from './UnderlineNav.module.css'
-import {getAnchoredPosition} from '@primer/behaviors'
+import {UnderlineNavContext} from './UnderlineNavContext'
+import type {UnderlineNavItemProps} from './UnderlineNavItem'
export type UnderlineNavProps = {
children: React.ReactNode
@@ -38,111 +30,13 @@ 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 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, overflowMeasured: boolean) => void,
-) => {
- let iconsVisible = true
- if (childWidthArray.length === 0) {
- updateListAndMenu({items: childArray, menuItems: []}, iconsVisible, false)
- return
- }
- 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, true)
-}
+const MORE_BTN_HEIGHT = 32
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',
- 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(
(
{
@@ -158,37 +52,35 @@ 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)
- 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 [isOverflowMeasured, setIsOverflowMeasured] = useState(false)
+ /** Tracks whether any item has ever overflowed for the lifecycle of this component. Used to prevent flickering. */
+ const [isOrEverHasOverflowed, setIsOrEverHasOverflowed] = useState(false)
- const validChildren = getValidChildren(children)
+ const [registeredItems, setRegisteredItems] = useState(() => new Map())
- // 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 registerItem = useCallback((id: string, menuItemProps: UnderlineNavItemProps | null) => {
+ setRegisteredItems(prev => {
+ if (menuItemProps === null && prev.get(id) === null) return prev
- // 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
- })
+ if (menuItemProps !== null) setIsOrEverHasOverflowed(true)
- // 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 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
+ })
+ }, [])
+
+ 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.
@@ -203,140 +95,16 @@ 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, overflowMeasured: 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, true)
- }
- // 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, overflowMeasured: boolean) => {
- setResponsiveProps(props)
- setIconsVisible(displayIcons)
-
- if (overflowMeasured) {
- setIsOverflowMeasured(true)
- }
- },
- [],
+ const menuItems = Array.from(registeredItems.entries()).filter(
+ (entry): entry is [string, UnderlineNavItemProps] => entry[1] !== null,
)
- 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])
-
- 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]})
-
- 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)
-
- // 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,
- }
- }
return (
{ariaLabel && {`${ariaLabel} navigation`}}
@@ -346,84 +114,46 @@ export const UnderlineNav = forwardRef(
className={className}
ref={navRef}
data-variant={variant}
- data-overflow-measured={isOverflowMeasured ? 'true' : 'false'}
+ // 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}
- {menuItems.length > 0 && (
-
- // 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,
- )
- }
+
+
+
+
+
+
+ More items
+
+
+
+
+
+ {menuItems.map(([key, allProps]) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const {children: menuItemChildren, counter, as, onSelect, ...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)
- typeof onSelect === 'function' && onSelect(event)
- }}
+ key={key}
+ className={classes.OverflowMenuItem}
+ onClick={event => onSelect?.(event)}
{...menuItemProps}
>
-
- {menuItemChildren}
+ {menuItemChildren}
+
{loadingCounters ? (
) : (
@@ -433,14 +163,14 @@ export const UnderlineNav = forwardRef(
)
)}
-
+
)
})}
-
- )}
-
+
+
+