diff --git a/.changeset/underlinenav-intersection-observer-overflow.md b/.changeset/underlinenav-intersection-observer-overflow.md
new file mode 100644
index 00000000000..66e0ef6c1da
--- /dev/null
+++ b/.changeset/underlinenav-intersection-observer-overflow.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': patch
+---
+
+Improve UnderlineNav overflow performance by replacing synchronous DOM measurements with CSS `overflow: hidden` + `IntersectionObserver`
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css
index d905aff3fcf..7fb57876911 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.module.css
+++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css
@@ -1,9 +1,80 @@
+/* Fixed height on the wrapper prevents vertical CLS on first paint.
+ Horizontal clipping is handled by overflow: hidden on the
.
+ The More button is always rendered (visibility: hidden when no overflow)
+ to prevent horizontal CLS. We intentionally omit overflow: hidden here
+ so the underline ::after pseudo-element (which extends ~1px below) is not clipped. */
+.NavWrapper {
+ height: var(--control-xlarge-size, 48px);
+}
+
.MenuItemContent {
display: flex;
align-items: center;
justify-content: space-between;
}
+/* Container for the "More" button and overflow menu, positioned outside the list
+ so it is not clipped by overflow: hidden on the list. */
+.MoreMenuContainer {
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+}
+
+/* Before IO has fired, the More button is rendered with visibility: hidden
+ to reserve space and prevent horizontal CLS when it appears. */
+.MoreMenuHidden {
+ visibility: hidden;
+}
+
+/* Overflow list: clips items that don't fit; IO detects which are clipped.
+ Padding/margin accommodate the underline ::after pseudo-element below items. */
+.OverflowList {
+ overflow: hidden;
+ flex: 1;
+ min-width: 0;
+ padding-bottom: var(--base-size-12);
+ margin-bottom: calc(var(--base-size-12) * -1);
+}
+
+/* Divider line before the More button */
+.Divider {
+ display: inline-block;
+ border-left: var(--borderWidth-thin) solid var(--borderColor-muted);
+ width: 1px;
+ margin-right: var(--base-size-4);
+ height: var(--base-size-24);
+}
+
+/* Dropdown overlay for the overflow menu */
+.OverflowMenu {
+ position: absolute;
+ z-index: 1;
+ top: 90%;
+ box-shadow: var(--shadow-resting-medium);
+ border-radius: var(--borderRadius-medium);
+ background: var(--overlay-bgColor);
+ list-style: none;
+ min-width: 192px;
+ max-width: 640px;
+ right: 0;
+ display: none;
+}
+
+.OverflowMenuOpen {
+ display: block;
+}
+
+/* Hide the selected check icon on menu items and reset link decoration */
+.MenuItem {
+ text-decoration: none;
+
+ & > span {
+ display: none;
+ }
+}
+
/* More button styles migrated from styles.ts (was moreBtnStyles) */
.MoreButton {
margin: 0; /* reset Safari extra margin */
@@ -11,10 +82,7 @@
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);
+ padding: var(--base-size-4) var(--base-size-8);
& > [data-component='trailingVisual'] {
margin-left: 0;
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx
index 6039ec2ad8a..74ba7215c76 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.test.tsx
+++ b/packages/react/src/UnderlineNav/UnderlineNav.test.tsx
@@ -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 = nav.querySelector('[role="list"], ul, ol')!
+ expect(list.getElementsByTagName('svg').length).toEqual(7)
})
it('fires onSelect on click', async () => {
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx
index c0a252d3ffe..70ecd3f08a9 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.tsx
+++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx
@@ -1,12 +1,8 @@
import type {RefObject} from 'react'
-import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react'
+import React, {useRef, forwardRef, useCallback, useState, useEffect, useMemo} 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'
@@ -15,8 +11,8 @@ import {useId} from '../hooks/useId'
import {ActionList} from '../ActionList'
import CounterLabel from '../CounterLabel'
import {invariant} from '../utils/invariant'
+import {clsx} from 'clsx'
import classes from './UnderlineNav.module.css'
-import {getAnchoredPosition} from '@primer/behaviors'
export type UnderlineNavProps = {
children: React.ReactNode
@@ -34,114 +30,15 @@ export type UnderlineNavProps = {
*/
variant?: 'inset' | 'flush'
}
-// When page is loaded, we don't have ref for the more button as it is not on the DOM yet.
-// 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) => 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) => {
+// Threshold for considering an item "fully visible" in IntersectionObserver
+const VISIBILITY_THRESHOLD = 0.95
+
+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(
(
{
@@ -157,35 +54,39 @@ 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 menuListRef = useRef(null)
const disclosureWidgetId = useId()
const [isWidgetOpen, setIsWidgetOpen] = useState(false)
- const [iconsVisible, setIconsVisible] = useState(true)
- const [childWidthArray, setChildWidthArray] = useState([])
- const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([])
-
- const validChildren = getValidChildren(children)
-
- // Responsive props object manages which items are in the list and which items are in the menu.
- const [responsiveProps, setResponsiveProps] = useState({
- items: validChildren,
- menuItems: [],
- })
-
- // 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
- })
-
- // 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
+ // Track icon visibility in a ref (not state) — toggling is done via CSS data attribute
+ // on the wrapper, avoiding React re-render cycles.
+ const iconsVisibleRef = useRef(true)
+ // Index from which items overflow (-1 = no overflow).
+ // All items from this index onward are clipped by CSS and duplicated in the overflow menu.
+ const [overflowStartIndex, setOverflowStartIndex] = useState(-1)
+ // Tracks whether IO has completed its first determination.
+ // Before this, the More button is rendered with visibility: hidden to prevent CLS.
+ const ioReadyRef = useRef(false)
+
+ const validChildren = useMemo(() => getValidChildren(children), [children])
+
+ const hasOverflow = overflowStartIndex >= 0 && overflowStartIndex < validChildren.length
+ // When the viewport is too narrow to show any list item. Only the dropdown is shown.
+ const onlyMenuVisible = overflowStartIndex === 0
+
+ // Phase tracking for icon toggle to prevent infinite loops:
+ // 'normal' - stable state
+ // 'trying-without-icons' - just hid icons, waiting for IO to re-fire
+ // 'trying-with-icons' - trying to re-enable icons (container grew), waiting for IO
+ const iconPhaseRef = useRef<'normal' | 'trying-without-icons' | 'trying-with-icons'>('normal')
+
+ // Tracks the list width when icons were last disabled due to overflow.
+ // Icons will only be retried when the list grows beyond this width.
+ const widthWhenIconsDisabledRef = useRef(0)
+
+ // Visibility map for IntersectionObserver — tracks each element's intersection state
+ const visibilityMapRef = useRef(new Map())
if (__DEV__) {
// Practically, this is not a conditional hook, it is just making sure this hook runs only on DEV not PROD.
@@ -200,74 +101,173 @@ export const UnderlineNav = forwardRef(
})
}
- function getItemsWidth(itemText: string): number {
- return noIconChildWidthArray.find(item => item.text === itemText)?.width ?? 0
- }
+ // Compute display items with aria-current swap.
+ // All items are always rendered in the list (CSS clips overflow).
+ // If the aria-current item is in the overflow range, swap it with the last visible item.
+ const [displayItems, menuItems] = useMemo(() => {
+ if (!hasOverflow) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return [validChildren, [] as React.ReactElement[]]
+ }
+
+ const items = [...validChildren]
- const swapMenuItemWithListItem = (
+ // Collect overflow items
// 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
- }
+ const overflow: React.ReactElement[] = []
+ for (let i = overflowStartIndex; i < items.length; i++) {
+ overflow.push(items[i])
}
- return breakpoint
- }
- const updateListAndMenu = useCallback((props: ResponsiveProps, displayIcons: boolean) => {
- setResponsiveProps(props)
- setIconsVisible(displayIcons)
- }, [])
- const setChildrenWidth = useCallback((size: ChildSize) => {
- setChildWidthArray(arr => {
- const newArr = [...arr, size]
- return newArr
+ // Check if aria-current item is in the overflow range
+ const currentOverflowIdx = overflow.findIndex(child => {
+ const ac = child.props['aria-current']
+ return Boolean(ac) && ac !== 'false'
})
- }, [])
- const setNoIconChildrenWidth = useCallback((size: ChildSize) => {
- setNoIconChildWidthArray(arr => {
- const newArr = [...arr, size]
- return newArr
+ if (currentOverflowIdx !== -1 && overflowStartIndex > 0) {
+ // Swap current item with the last visible item to keep it in view
+ const itemIdxInFull = overflowStartIndex + currentOverflowIdx
+ const lastVisibleIdx = overflowStartIndex - 1
+ ;[items[itemIdxInFull], items[lastVisibleIdx]] = [items[lastVisibleIdx], items[itemIdxInFull]]
+ // Update overflow array to reflect the swap
+ overflow[currentOverflowIdx] = items[itemIdxInFull]
+ }
+
+ return [items, overflow]
+ }, [validChildren, overflowStartIndex, hasOverflow])
+
+ // IntersectionObserver-based overflow detection.
+ // Uses CSS overflow: hidden on the list + IO to detect which items are clipped.
+ // No forced reflows: IO fires asynchronously after layout.
+ // Icon toggling is done via CSS data attribute (no React re-render needed).
+ useEffect(() => {
+ const list = listRef.current
+ const wrapper = navRef.current
+ if (!list || typeof IntersectionObserver === 'undefined' || typeof ResizeObserver === 'undefined') {
+ return
+ }
+
+ // Helper to toggle icon visibility via CSS data attribute on the wrapper.
+ // This avoids a React re-render cycle — the browser relayouts and IO re-fires.
+ const setIconsVisible = (visible: boolean) => {
+ iconsVisibleRef.current = visible
+ wrapper?.setAttribute('data-icons-visible', String(visible))
+ }
+
+ // Ensure the initial attribute matches the ref
+ wrapper?.setAttribute('data-icons-visible', String(iconsVisibleRef.current))
+
+ // Clear visibility map on each observer setup
+ visibilityMapRef.current.clear()
+
+ const observer = new IntersectionObserver(
+ entries => {
+ // Update visibility map with new entries
+ for (const entry of entries) {
+ visibilityMapRef.current.set(entry.target, entry.intersectionRatio >= VISIBILITY_THRESHOLD)
+ }
+
+ // Find the first non-visible item (scanning from the start)
+ const listChildren = Array.from(list.children)
+ let firstOverflow = -1
+ for (let i = 0; i < listChildren.length; i++) {
+ if (visibilityMapRef.current.get(listChildren[i]) === false) {
+ firstOverflow = i
+ break
+ }
+ }
+
+ const hasOverflowNow = firstOverflow !== -1
+ ioReadyRef.current = true
+
+ if (hasOverflowNow) {
+ if (iconPhaseRef.current === 'trying-with-icons') {
+ // We tried re-enabling icons but they don't fit. Revert.
+ setIconsVisible(false)
+ iconPhaseRef.current = 'normal'
+ // Remember this width so we don't retry until the list grows
+ widthWhenIconsDisabledRef.current = list.clientWidth
+ return
+ }
+
+ if (iconsVisibleRef.current) {
+ // First overflow with icons — try hiding icons to see if all items fit
+ setIconsVisible(false)
+ iconPhaseRef.current = 'trying-without-icons'
+ // Remember the width for retry prevention
+ widthWhenIconsDisabledRef.current = list.clientWidth
+ return
+ }
+
+ if (iconPhaseRef.current === 'trying-without-icons') {
+ iconPhaseRef.current = 'normal'
+ }
+
+ // Accessibility: never show only 1 item in the overflow menu.
+ let adjustedFirstOverflow = firstOverflow
+ const overflowCount = listChildren.length - firstOverflow
+ if (overflowCount === 1 && listChildren.length > 1) {
+ adjustedFirstOverflow = firstOverflow - 1
+ }
+
+ setOverflowStartIndex(adjustedFirstOverflow)
+ } else {
+ // All items are visible
+ setOverflowStartIndex(-1)
+
+ if (iconPhaseRef.current === 'trying-without-icons') {
+ // Items fit without icons — stay without icons
+ iconPhaseRef.current = 'normal'
+ } else if (iconPhaseRef.current === 'trying-with-icons') {
+ // Icons fit — keep them visible
+ iconPhaseRef.current = 'normal'
+ } else if (!iconsVisibleRef.current) {
+ // Only retry icons if the list has actually grown since we disabled them
+ const currentWidth = list.clientWidth
+ if (currentWidth > widthWhenIconsDisabledRef.current + 20) {
+ setIconsVisible(true)
+ iconPhaseRef.current = 'trying-with-icons'
+ }
+ }
+ }
+ },
+ {
+ root: list,
+ threshold: [0, VISIBILITY_THRESHOLD, 1],
+ },
+ )
+
+ // Observe all list items
+ for (let i = 0; i < list.children.length; i++) {
+ observer.observe(list.children[i])
+ }
+
+ // ResizeObserver to detect when the list grows while icons are hidden.
+ // IO won't re-fire when all items are already fully visible and the root
+ // just gets wider, so we need RO to trigger icon retry in that scenario.
+ const resizeObserver = new ResizeObserver(() => {
+ if (!iconsVisibleRef.current && iconPhaseRef.current === 'normal') {
+ const currentWidth = list.clientWidth
+ if (currentWidth > widthWhenIconsDisabledRef.current + 20) {
+ setIconsVisible(true)
+ iconPhaseRef.current = 'trying-with-icons'
+ }
+ }
})
- }, [])
+ resizeObserver.observe(list)
+
+ return () => {
+ observer.disconnect()
+ resizeObserver.disconnect()
+ }
+ }, [validChildren, navRef])
- const closeOverlay = React.useCallback(() => {
+ const closeOverlay = useCallback(() => {
setIsWidgetOpen(false)
- }, [setIsWidgetOpen])
+ }, [])
- const focusOnMoreMenuBtn = React.useCallback(() => {
+ const focusOnMoreMenuBtn = useCallback(() => {
moreMenuBtnRef.current?.focus()
}, [])
@@ -289,141 +289,99 @@ export const UnderlineNav = forwardRef(
[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,
- }
- }
+ useOnOutsideClick({onClickOutside: closeOverlay, containerRef: menuListRef, ignoreClickRefs: [moreMenuBtnRef]})
return (
{ariaLabel && {`${ariaLabel} navigation`}}
-
-
- {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}
-
- )
- )}
-
-
- )
- })}
-
-