diff --git a/.vscode/settings.json b/.vscode/settings.json index c5f11daa02b..7629b1e8e4d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,12 @@ { + "mcp": { + "servers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } + }, "typescript.tsdk": "node_modules/typescript/lib", "editor.formatOnSave": true, "[javascript]": { @@ -17,10 +25,7 @@ "source.fixAll.stylelint": "explicit" } }, - "stylelint.validate": [ - "css", - "postcss" - ], + "stylelint.validate": ["css", "postcss"], "json.schemas": [ { "fileMatch": ["*.docs.json"], diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts index c4e7c0ebef4..a3e4680c77e 100644 --- a/examples/nextjs/next-env.d.ts +++ b/examples/nextjs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/types/routes.d.ts' +import './.next/dev/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/nextjs/src/app/underlinenav/page.tsx b/examples/nextjs/src/app/underlinenav/page.tsx new file mode 100644 index 00000000000..b4d4f7cb1ef --- /dev/null +++ b/examples/nextjs/src/app/underlinenav/page.tsx @@ -0,0 +1,42 @@ +'use client' + +import React from 'react' +import {UnderlineNav} from '@primer/react' + +export default function UnderlineNavPage() { + const [selectedIndex, setSelectedIndex] = React.useState(1) + + const items: {navigation: string; counter?: number | string; href?: string}[] = [ + {navigation: 'Code', href: '#code'}, + {navigation: 'Issues', counter: '12K', href: '#issues'}, + {navigation: 'Pull Requests', counter: 13, href: '#pull-requests'}, + {navigation: 'Discussions', counter: 5, href: '#discussions'}, + {navigation: 'Actions', counter: 4, href: '#actions'}, + {navigation: 'Projects', counter: 9, href: '#projects'}, + {navigation: 'Insights', counter: '0', href: '#insights'}, + {navigation: 'Settings', counter: 10, href: '#settings'}, + {navigation: 'Security', href: '#security'}, + ] + + return ( +
+

UnderlineNav - Overflow on Narrow Screen

+ + {items.map((item, index) => ( + { + event.preventDefault() + setSelectedIndex(index) + }} + counter={item.counter} + href={item.href} + > + {item.navigation} + + ))} + +
+ ) +} diff --git a/packages/react/package.json b/packages/react/package.json index eb1d60b4ec1..7e02218e57b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -37,6 +37,7 @@ ], "scripts": { "build": "./script/build", + "build:components": "npx rollup -c", "clean": "rimraf dist generated", "start": "concurrently npm:start:*", "start:storybook": "STORYBOOK=true storybook dev -p 6006", diff --git a/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx b/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx index 6b35a2c739b..31dd299acc6 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx @@ -154,3 +154,126 @@ export const VariantFlush = () => { ) } + +/** + * - At extra-narrow viewport (< 544px): Shows first 2 items inline; rest in menu + * - At narrow viewport (544px - 768px): Shows first 3 items inline; rest in menu + * - At regular viewport (768px - 1024px): Shows first 5 items inline; rest in menu + * - At medium viewport (1024px - 1280px): Shows first 6 items inline; rest in menu + * - At large viewport (1280px - 1400px): Shows first 7 items inline; rest in menu + * - At wide viewport (> 1400px): Shows all items inline; menu is hidden + */ +export const ResponsiveOverflow = () => { + const [selectedIndex, setSelectedIndex] = React.useState(0) + + return ( + 1400px, hide menu) + }} + > + {items.map((item, index) => ( + { + event.preventDefault() + setSelectedIndex(index) + }} + counter={item.counter} + href={item.href} + > + {item.navigation} + + ))} + + ) +} + +export const ResponsiveOverflowXNarrow = () => { + return +} + +ResponsiveOverflowXNarrow.parameters = { + viewport: { + viewports: { + ...INITIAL_VIEWPORTS, + xnarrowScreen: { + name: 'Extra Narrow Screen', + styles: { + width: '400px', + height: '100%', + }, + }, + }, + defaultViewport: 'xnarrowScreen', + }, +} + +export const ResponsiveOverflowNarrow = () => { + return +} + +ResponsiveOverflowNarrow.parameters = { + viewport: { + viewports: { + ...INITIAL_VIEWPORTS, + narrowScreen: { + name: 'Narrow Screen', + styles: { + width: '600px', + height: '100%', + }, + }, + }, + defaultViewport: 'narrowScreen', + }, +} + +export const ResponsiveOverflowRegular = () => { + return +} + +ResponsiveOverflowRegular.parameters = { + viewport: { + viewports: { + ...INITIAL_VIEWPORTS, + regularScreen: { + name: 'Regular Screen', + styles: { + width: '900px', + height: '100%', + }, + }, + }, + defaultViewport: 'regularScreen', + }, +} + +export const ResponsiveOverflowWide = () => { + return +} + +ResponsiveOverflowWide.parameters = { + viewport: { + viewports: { + ...INITIAL_VIEWPORTS, + wideScreen: { + name: 'Wide Screen', + styles: { + width: '1500px', + height: '100%', + }, + }, + }, + defaultViewport: 'wideScreen', + }, +} diff --git a/packages/react/src/UnderlineNav/UnderlineNav.module.css b/packages/react/src/UnderlineNav/UnderlineNav.module.css index d905aff3fcf..36905b5927f 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNav.module.css @@ -20,3 +20,88 @@ margin-left: 0; } } + +/* Container for overflow menu when using responsive mode */ +.ResponsiveOverflowContainer { + display: flex; + align-items: center; +} + +/* Responsive menu visibility - extra-narrow viewport (max-width: 544px) */ +@media (max-width: 544px) { + .ResponsiveOverflowContainer[data-hide-menu-xnarrow='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + /* Use :has() to target menu item li elements, excluding the container */ + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-xnarrow='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive menu visibility - narrow viewport (544px - 768px) */ +@media (min-width: 544px) and (max-width: 768px) { + .ResponsiveOverflowContainer[data-hide-menu-narrow='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-narrow='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive menu visibility - regular viewport (768px - 1024px) */ +@media (min-width: 768px) and (max-width: 1024px) { + .ResponsiveOverflowContainer[data-hide-menu-regular='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-regular='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive menu visibility - medium viewport (1024px - 1280px) */ +@media (min-width: 1024px) and (max-width: 1280px) { + .ResponsiveOverflowContainer[data-hide-menu-medium='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-medium='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive menu visibility - large viewport (1280px - 1400px) */ +@media (min-width: 1280px) and (max-width: 1400px) { + .ResponsiveOverflowContainer[data-hide-menu-large='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-large='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive menu visibility - wide viewport (min-width: 1400px) */ +@media (min-width: 1400px) { + .ResponsiveOverflowContainer[data-hide-menu-wide='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } + + li:not(.ResponsiveOverflowContainer):has([data-hide-in-menu-wide='true']) { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index c0a252d3ffe..750ae119b7d 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -3,7 +3,7 @@ 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 type {ChildWidthArray, ResponsiveProps, ChildSize, ResponsiveOverflowConfig} from './types' import VisuallyHidden from '../_VisuallyHidden' import {dividerStyles, menuItemStyles, baseMenuMinWidth} from './styles' import {UnderlineItemList, UnderlineWrapper, LoadingCounter, GAP} from '../internal/components/UnderlineTabbedInterface' @@ -33,6 +33,21 @@ export type UnderlineNavProps = { * Setting this to `flush` will remove the horizontal padding on the items. */ variant?: 'inset' | 'flush' + /** + * When provided, items are rendered with data attributes that CSS media queries use + * to control visibility. The overflow menu is always rendered but uses CSS to hide + * items that are visible inline at the current viewport. + * + * Example: + * ``` + * { + * narrow: [0, 1], // Show first 2 items inline at narrow viewport + * regular: [0, 1, 2, 3], // Show first 4 items inline at regular viewport + * wide: 'all' // Show all items inline at wide viewport + * } + * ``` + */ + responsiveOverflow?: ResponsiveOverflowConfig } // 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. @@ -49,7 +64,7 @@ const overflowEffect = ( noIconChildWidthArray: ChildWidthArray, updateListAndMenu: (props: ResponsiveProps, iconsVisible: boolean) => void, ) => { - let iconsVisible = true + let iconsVisible = false if (childWidthArray.length === 0) { updateListAndMenu({items: childArray, menuItems: []}, iconsVisible) } @@ -69,6 +84,7 @@ const overflowEffect = ( // First, we check if we can fit all the items with their icons if (childArray.length <= numberOfItemsPossible) { items.push(...childArray) + iconsVisible = true } 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 @@ -142,6 +158,114 @@ const baseMenuInlineStyles: React.CSSProperties = { right: 0, } +/** + * Check if an item index should be hidden at a given breakpoint + */ +function isItemHiddenAtBreakpoint( + config: ResponsiveOverflowConfig, + breakpoint: 'xnarrow' | 'narrow' | 'regular' | 'medium' | 'large' | 'wide', + index: number, +): boolean { + const visibleItems = config[breakpoint] + if (visibleItems === undefined || visibleItems === 'all') { + return false + } + return !visibleItems.includes(index) +} + +/** + * Get data attributes for an inline item based on responsive config + */ +function getResponsiveItemAttributes( + config: ResponsiveOverflowConfig, + index: number, +): { + 'data-hide-xnarrow'?: boolean + 'data-hide-narrow'?: boolean + 'data-hide-regular'?: boolean + 'data-hide-medium'?: boolean + 'data-hide-large'?: boolean + 'data-hide-wide'?: boolean +} { + return { + 'data-hide-xnarrow': isItemHiddenAtBreakpoint(config, 'xnarrow', index) || undefined, + 'data-hide-narrow': isItemHiddenAtBreakpoint(config, 'narrow', index) || undefined, + 'data-hide-regular': isItemHiddenAtBreakpoint(config, 'regular', index) || undefined, + 'data-hide-medium': isItemHiddenAtBreakpoint(config, 'medium', index) || undefined, + 'data-hide-large': isItemHiddenAtBreakpoint(config, 'large', index) || undefined, + 'data-hide-wide': isItemHiddenAtBreakpoint(config, 'wide', index) || undefined, + } +} + +/** + * Get data attributes for a menu item based on responsive config. + * Menu items should be hidden when they are visible inline. + */ +function getResponsiveMenuItemAttributes( + config: ResponsiveOverflowConfig, + index: number, +): { + 'data-hide-in-menu-xnarrow'?: boolean + 'data-hide-in-menu-narrow'?: boolean + 'data-hide-in-menu-regular'?: boolean + 'data-hide-in-menu-medium'?: boolean + 'data-hide-in-menu-large'?: boolean + 'data-hide-in-menu-wide'?: boolean +} { + // Hide in menu when NOT hidden inline (i.e., when visible inline) + return { + 'data-hide-in-menu-xnarrow': !isItemHiddenAtBreakpoint(config, 'xnarrow', index) || undefined, + 'data-hide-in-menu-narrow': !isItemHiddenAtBreakpoint(config, 'narrow', index) || undefined, + 'data-hide-in-menu-regular': !isItemHiddenAtBreakpoint(config, 'regular', index) || undefined, + 'data-hide-in-menu-medium': !isItemHiddenAtBreakpoint(config, 'medium', index) || undefined, + 'data-hide-in-menu-large': !isItemHiddenAtBreakpoint(config, 'large', index) || undefined, + 'data-hide-in-menu-wide': !isItemHiddenAtBreakpoint(config, 'wide', index) || undefined, + } +} + +/** + * Get data attributes for the menu container based on responsive config. + * Menu should be hidden when all items are visible inline. + */ +function getResponsiveMenuContainerAttributes(config: ResponsiveOverflowConfig): { + 'data-hide-menu-xnarrow'?: boolean + 'data-hide-menu-narrow'?: boolean + 'data-hide-menu-regular'?: boolean + 'data-hide-menu-medium'?: boolean + 'data-hide-menu-large'?: boolean + 'data-hide-menu-wide'?: boolean +} { + return { + 'data-hide-menu-xnarrow': config.xnarrow === 'all' || undefined, + 'data-hide-menu-narrow': config.narrow === 'all' || undefined, + 'data-hide-menu-regular': config.regular === 'all' || undefined, + 'data-hide-menu-medium': config.medium === 'all' || undefined, + 'data-hide-menu-large': config.large === 'all' || undefined, + 'data-hide-menu-wide': config.wide === 'all' || undefined, + } +} + +/** + * Get all item indices that could be hidden at any breakpoint. + * These items need to be included in the overflow menu. + */ +function getResponsiveMenuItemIndices(config: ResponsiveOverflowConfig, totalItems: number): number[] { + const hiddenIndices = new Set() + + for (const breakpoint of ['xnarrow', 'narrow', 'regular', 'medium', 'large', 'wide'] as const) { + const visibleItems = config[breakpoint] + if (visibleItems !== undefined && visibleItems !== 'all') { + for (let i = 0; i < totalItems; i++) { + if (!visibleItems.includes(i)) { + hiddenIndices.add(i) + } + } + } + } + + return Array.from(hiddenIndices).sort((a, b) => a - b) +} + export const UnderlineNav = forwardRef( ( { @@ -151,6 +275,7 @@ export const UnderlineNav = forwardRef( variant = 'inset', className, children, + responsiveOverflow, }: UnderlineNavProps, forwardedRef, ) => { @@ -162,10 +287,16 @@ export const UnderlineNav = forwardRef( const containerRef = React.useRef(null) const disclosureWidgetId = useId() + // Determine if we're using CSS-based responsive mode + const useResponsiveMode = Boolean(responsiveOverflow) + const [isWidgetOpen, setIsWidgetOpen] = useState(false) - const [iconsVisible, setIconsVisible] = useState(true) + const [iconsVisible, setIconsVisible] = useState(false) const [childWidthArray, setChildWidthArray] = useState([]) const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) + // Track whether the initial overflow calculation is complete to prevent CLS + // In responsive mode, we're ready immediately since CSS handles visibility + const [isReady, setIsReady] = useState(useResponsiveMode) const validChildren = getValidChildren(children) @@ -248,6 +379,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 => { @@ -320,6 +453,119 @@ export const UnderlineNav = forwardRef( } } + if (useResponsiveMode && responsiveOverflow) { + // Get indices of items that need to be in the menu + const responsiveMenuItemIndices = getResponsiveMenuItemIndices(responsiveOverflow, validChildren.length) + const responsiveMenuItems = responsiveMenuItemIndices.map(index => ({ + index, + child: validChildren[index], + })) + + // Clone children with responsive data attributes + const responsiveListItems = validChildren.map((child, index) => { + const attrs = getResponsiveItemAttributes(responsiveOverflow, index) + return React.cloneElement(child, {...attrs, key: child.key ?? index}) + }) + + const menuContainerAttrs = getResponsiveMenuContainerAttributes(responsiveOverflow) + const hasMenuItems = responsiveMenuItems.length > 0 + + return ( + + {ariaLabel && {`${ariaLabel} navigation`}} + + + {responsiveListItems} + {hasMenuItems && ( +
  • +
    + + = baseMenuMinWidth + ? baseMenuInlineStyles + : menuInlineStyles), + display: isWidgetOpen ? 'block' : 'none', + }} + > + {responsiveMenuItems.map(({index, child}) => { + const {children: menuItemChildren, counter, onSelect, ...menuItemProps} = child.props + const menuItemAttrs = getResponsiveMenuItemAttributes(responsiveOverflow, index) + + return ( + | React.KeyboardEvent, + ) => { + closeOverlay() + focusOnMoreMenuBtn() + typeof onSelect === 'function' && onSelect(event) + }} + {...menuItemProps} + {...menuItemAttrs} + > + + {menuItemChildren} + {loadingCounters ? ( + + ) : ( + counter !== undefined && ( + + {counter} + + ) + )} + + + ) + })} + +
  • + )} +
    +
    +
    + ) + } + + // ============================================ + // Default JS-based overflow rendering + // ============================================ return ( {ariaLabel && {`${ariaLabel} navigation`}} - + {listItems} {menuItems.length > 0 && ( diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.module.css b/packages/react/src/UnderlineNav/UnderlineNavItem.module.css index c1e75b06204..320317c4e76 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.module.css +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.module.css @@ -3,3 +3,51 @@ flex-direction: column; align-items: center; } + +/* Responsive item visibility - extra small viewport (max-width: 544px) */ +@media (max-width: 544px) { + .UnderlineNavItem[data-hide-xnarrow='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive item visibility - narrow viewport (544px - 768px) */ +@media (min-width: 544px) and (max-width: 768px) { + .UnderlineNavItem[data-hide-narrow='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive item visibility - regular viewport (768px - 1024px) */ +@media (min-width: 768px) and (max-width: 1024px) { + .UnderlineNavItem[data-hide-regular='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive item visibility - medium viewport (1024px - 1280px) */ +@media (min-width: 1024px) and (max-width: 1280px) { + .UnderlineNavItem[data-hide-medium='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive item visibility - large viewport (1280px - 1400px) */ +@media (min-width: 1280px) and (max-width: 1400px) { + .UnderlineNavItem[data-hide-large='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +/* Responsive item visibility - wide viewport (min-width: 1400px) */ +@media (min-width: 1400px) { + .UnderlineNavItem[data-hide-wide='true'] { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx index 3341f677e11..39275455090 100644 --- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx @@ -57,6 +57,31 @@ export type UnderlineNavItemProps = { * Counter */ counter?: number | string + + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-xnarrow'?: string + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-narrow'?: string + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-regular'?: string + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-medium'?: string + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-large'?: string + /** + * @internal Used by UnderlineNav for CSS based responsive overflow + */ + 'data-hide-wide'?: string } & LinkProps export const UnderlineNavItem = forwardRef( @@ -70,6 +95,12 @@ export const UnderlineNavItem = forwardRef( 'aria-current': ariaCurrent, icon: Icon, leadingVisual, + 'data-hide-xnarrow': dataHideXnarrow, + 'data-hide-narrow': dataHideNarrow, + 'data-hide-regular': dataHideRegular, + 'data-hide-medium': dataHideMedium, + 'data-hide-large': dataHideLarge, + 'data-hide-wide': dataHideWide, ...props }, forwardedRef, @@ -120,7 +151,15 @@ export const UnderlineNavItem = forwardRef( ) return ( -
  • +
  • > } + +/** + * + * Each breakpoint key defines which item indices should be visible at that viewport size. + * Items not in the visible array will be hidden in the inline list but shown in the overflow menu. + * + * Example: + * ``` + * { + * narrow: [0, 1], // Show first 2 items inline at narrow viewport + * regular: [0, 1, 2, 3], // Show first 4 items inline at regular viewport + * wide: 'all' // Show all items inline at wide viewport (hide menu) + * } + * ``` + */ +export type ResponsiveOverflowConfig = { + /** Items visible at extra-narrow viewport (max-width: 544px) */ + xnarrow?: number[] | 'all' + /** Items visible at narrow viewport (544px - 768px) */ + narrow?: number[] | 'all' + /** Items visible at regular viewport (768px - 1024px) */ + regular?: number[] | 'all' + /** Items visible at medium viewport (1024px - 1280px) */ + medium?: number[] | 'all' + /** Items visible at large viewport (1280px - 1400px) */ + large?: number[] | 'all' + /** Items visible at wide viewport (min-width: 1400px) */ + wide?: number[] | 'all' +} diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 85214c5c317..ebe310aee9e 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; diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 509459ea47f..ffadb25adc2 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' : undefined} {...rest} > {children} @@ -74,6 +78,7 @@ export type UnderlineItemProps = { export const UnderlineItem = React.forwardRef((props, ref) => { const {as: Component = 'a', children, counter, icon: Icon, iconsVisible, loadingCounters, className, ...rest} = props const textContent = getTextContent(children) + return ( {iconsVisible && Icon && {isElement(Icon) ? Icon : }}