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 && ( -
  • - {!onlyMenuVisible &&
    } - - = baseMenuMinWidth - ? baseMenuInlineStyles - : menuInlineStyles), - display: isWidgetOpen ? 'block' : 'none', - }} - > - {menuItems.map((menuItem, index) => { - const { - children: menuItemChildren, - counter, - 'aria-current': ariaCurrent, - onSelect, - ...menuItemProps - } = menuItem.props + {children} + - // 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( ) )} - + ) })} -
  • - )} -
    + + +
    ) diff --git a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx index f276771efac..d27a7af2551 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavContext.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavContext.tsx @@ -1,14 +1,12 @@ -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 + registerItem: (id: string, props: UnderlineNavItemProps | null) => void + unregisterItem: (id: string) => void }>({ - setChildrenWidth: () => null, - setNoIconChildrenWidth: () => null, loadingCounters: false, - iconsVisible: true, + registerItem: () => {}, + unregisterItem: () => {}, }) 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/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3341f677e11..6d1f3e6d2ee 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -1,9 +1,8 @@ -import type {MutableRefObject, RefObject} from 'react' -import React, {forwardRef, useRef, useContext} from 'react' +import type {RefObject} 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' -import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' import {UnderlineItem} from '../internal/components/UnderlineTabbedInterface' import classes from './UnderlineNavItem.module.css' @@ -59,86 +58,85 @@ export type UnderlineNavItemProps = { counter?: number | string } & LinkProps -export const UnderlineNavItem = forwardRef( - ( - { - as: Component = 'a', - href = '#', - children, - counter, - onSelect, - 'aria-current': ariaCurrent, - icon: Icon, - leadingVisual, - ...props +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, registerItem, unregisterItem} = useContext(UnderlineNavContext) + + const id = useId() + const [isOverflowing, setIsOverflowing] = useState(false) + + useEffect(() => { + const element = ref.current + if (!element) return + + const observer = new IntersectionObserver(() => { + // Overflowing items wrap onto subsequent lines, so their `offsetTop` increases + 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) + }) + + observer.observe(element) + + return () => observer.disconnect() + }, [ref, 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) + } }, - forwardedRef, - ) => { - const backupRef = useRef(null) - const ref = (forwardedRef ?? backupRef) as RefObject - const {setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} = useContext(UnderlineNavContext) - - 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}) + [onSelect], + ) + const clickHandler = React.useCallback( + (event: React.MouseEvent) => { + if (!event.defaultPrevented && typeof onSelect === 'function') { + onSelect(event) } - }, [ref, setChildrenWidth, setNoIconChildrenWidth]) - - 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], + ) + + return ( +
  • + + {children} + +
  • + ) +}) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps> UnderlineNavItem.displayName = 'UnderlineNavItem' diff --git a/packages/react/src/UnderlineNav/styles.ts b/packages/react/src/UnderlineNav/styles.ts deleted file mode 100644 index 71ad7910619..00000000000 --- a/packages/react/src/UnderlineNav/styles.ts +++ /dev/null @@ -1,19 +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 -} - -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 diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 83cdd50d81f..926582094a0 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -2,31 +2,28 @@ 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; + overflow: hidden; + /* stylelint-disable-next-line declaration-property-value-no-unknown */ + container: underline-wrapper / scroll-state; /* 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 */ 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; - } - - &[data-overflow-measured='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 { @@ -38,6 +35,9 @@ list-style: none; align-items: center; gap: 8px; + flex-wrap: wrap; + /* Allows the menu to clip wrapped items to keep on shrinking */ + overflow: hidden; } .UnderlineItem { @@ -54,6 +54,7 @@ background-color: transparent; border: 0; border-radius: var(--borderRadius-medium, var(--borderRadius-small)); + max-width: 100%; /* button resets */ appearance: none; @@ -102,6 +103,18 @@ color: var(--fgColor-muted); 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; + white-space: nowrap; } .UnderlineItem [data-component='counter'] { diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 509459ea47f..102cd141da1 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -36,6 +36,7 @@ type UnderlineWrapperProps = { export const UnderlineWrapper = forwardRef((props, ref) => { const {children, className, as: Component = 'div', ...rest} = props + return ( { 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 @@ -72,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}