From 9e35e89ac43fc8973fb20cffaadcb564cef1d433 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 19 Jan 2026 13:25:28 +0100 Subject: [PATCH 1/8] add playwright mcp server --- .vscode/settings.json | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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"], From 61ad00af311a063ab8a1e9580d2997e9ce710846 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 21 Jan 2026 10:39:47 +0100 Subject: [PATCH 2/8] fixture --- examples/nextjs/next-env.d.ts | 2 +- examples/nextjs/src/app/underlinenav/page.tsx | 42 +++++++++++++++++++ package-lock.json | 8 ++-- packages/react/package.json | 1 + 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 examples/nextjs/src/app/underlinenav/page.tsx diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts index c4e7c0ebef4..c4b7818fbb2 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/package-lock.json b/package-lock.json index c08231f808a..0978b7100e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.7.0", + "@primer/react": "38.7.1", "@primer/styled-react": "1.0.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -95,7 +95,7 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.7.0", + "@primer/react": "38.7.1", "@primer/styled-react": "1.0.2", "next": "^16.0.10", "react": "^19.2.0", @@ -138,7 +138,7 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.7.0", + "@primer/react": "38.7.1", "@primer/styled-react": "1.0.2", "clsx": "^2.1.1", "next": "^16.0.10", @@ -26428,7 +26428,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.7.0", + "version": "38.7.1", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", diff --git a/packages/react/package.json b/packages/react/package.json index 3c5ca6990b2..2a0cac7455b 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", From be5ec9e7c3d0fccc92ab10cee275fa1c30a06f59 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 21 Jan 2026 14:23:05 +0100 Subject: [PATCH 3/8] add data-ssr-hidden attribute --- .../components/UnderlineTabbedInterface.module.css | 7 +++++++ .../internal/components/UnderlineTabbedInterface.tsx | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 85214c5c317..5ef756cd49b 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 during SSR to prevent horizontal scrollbar before JS hydration calculates which items fit */ + overflow: hidden; + + &[data-ssr-hidden='false'] { + 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 a6fa4bee785..b758d6cabff 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -1,11 +1,12 @@ // Used for UnderlineNav and UnderlinePanels components -import React from 'react' +import React, {useState} from 'react' import {type ForwardedRef, forwardRef, type FC, type PropsWithChildren, type ElementType} from 'react' import {isElement} from 'react-is' import type {IconProps} from '@primer/octicons-react' import CounterLabel from '../../CounterLabel' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../../utils/polymorphic' +import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import classes from './UnderlineTabbedInterface.module.css' import {clsx} from 'clsx' @@ -22,10 +23,19 @@ type UnderlineWrapperProps = { export const UnderlineWrapper = forwardRef((props, ref) => { const {children, className, as: Component = 'div', ...rest} = props + // Track hydration state: true on server and initial client render, false after hydration + const [isSSR, setIsSSR] = useState(true) + + useIsomorphicLayoutEffect(() => { + // After hydration, allow overflow to be visible + setIsSSR(false) + }, []) + return ( } + data-ssr-hidden={isSSR ? 'true' : 'false'} {...rest} > {children} From 4676eea57da518ce370c134ba7a7c942bc8865fe Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Mon, 26 Jan 2026 23:19:07 -0500 Subject: [PATCH 4/8] format --- examples/nextjs/next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts index c4b7818fbb2..a3e4680c77e 100644 --- a/examples/nextjs/next-env.d.ts +++ b/examples/nextjs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/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. From dd213675c660d928200dbe942ca857ede82c153f Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 29 Jan 2026 15:20:08 -0500 Subject: [PATCH 5/8] Adds `data-ready` to determine if container should have overflow hidden or not --- .../react/src/UnderlineNav/UnderlineNav.tsx | 13 ++++++++++++- .../UnderlineTabbedInterface.module.css | 4 ++-- .../components/UnderlineTabbedInterface.tsx | 17 ++++++----------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index c0a252d3ffe..ed66944dd61 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -166,6 +166,8 @@ export const UnderlineNav = forwardRef( const [iconsVisible, setIconsVisible] = useState(true) const [childWidthArray, setChildWidthArray] = useState([]) const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) + // Track whether the initial overflow calculation is complete to prevent CLS + const [isReady, setIsReady] = useState(false) const validChildren = getValidChildren(children) @@ -248,6 +250,8 @@ export const UnderlineNav = forwardRef( const updateListAndMenu = useCallback((props: ResponsiveProps, displayIcons: boolean) => { setResponsiveProps(props) setIconsVisible(displayIcons) + // Mark as ready after the first overflow calculation completes + setIsReady(true) }, []) const setChildrenWidth = useCallback((size: ChildSize) => { setChildWidthArray(arr => { @@ -330,7 +334,14 @@ export const UnderlineNav = forwardRef( }} > {ariaLabel && {`${ariaLabel} navigation`}} - + {listItems} {menuItems.length > 0 && ( diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css index 5ef756cd49b..ebe310aee9e 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.module.css +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.module.css @@ -12,10 +12,10 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: inset 0 -1px var(--borderColor-muted); - /* Hide overflow during SSR to prevent horizontal scrollbar before JS hydration calculates which items fit */ + /* Hide overflow until calculation is complete to prevent CLS */ overflow: hidden; - &[data-ssr-hidden='false'] { + &[data-ready='true'] { overflow: visible; } diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index 9cece00cabc..d6653b26dc3 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -1,12 +1,11 @@ // Used for UnderlineNav and UnderlinePanels components -import React, {useState} from 'react' +import React from 'react' import {type ForwardedRef, forwardRef, type FC, type PropsWithChildren, type ElementType} from 'react' import {isElement} from 'react-is' import type {IconProps} from '@primer/octicons-react' import CounterLabel from '../../CounterLabel' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../../utils/polymorphic' -import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' import classes from './UnderlineTabbedInterface.module.css' import {clsx} from 'clsx' @@ -33,23 +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 - // Track hydration state: true on server and initial client render, false after hydration - const [isSSR, setIsSSR] = useState(true) - - useIsomorphicLayoutEffect(() => { - // After hydration, allow overflow to be visible - setIsSSR(false) - }, []) + const {children, className, as: Component = 'div', ready, ...rest} = props return ( } - data-ssr-hidden={isSSR ? 'true' : 'false'} + data-ready={ready ? 'true' : 'false'} {...rest} > {children} @@ -84,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 : }} From abb909127882f65f45f2b52c07e961e2ff2fb48b Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Thu, 29 Jan 2026 17:27:38 -0500 Subject: [PATCH 6/8] Use `undefined` instead of `false` for conditional --- .../react/src/internal/components/UnderlineTabbedInterface.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx index d6653b26dc3..ffadb25adc2 100644 --- a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx +++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx @@ -43,7 +43,7 @@ export const UnderlineWrapper = forwardRef((props, ref) => { } - data-ready={ready ? 'true' : 'false'} + data-ready={ready ? 'true' : undefined} {...rest} > {children} From 586b2c9d1c6c7a8cb68b3ae706ee3911bbb8d47b Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 3 Feb 2026 10:08:53 -0500 Subject: [PATCH 7/8] Only render icons if they fit --- packages/react/src/UnderlineNav/UnderlineNav.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index ed66944dd61..937b5a09f65 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -49,7 +49,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 +69,8 @@ const overflowEffect = ( // First, we check if we can fit all the items with their icons if (childArray.length <= numberOfItemsPossible) { items.push(...childArray) + console.log('All items fit with icons') + 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 @@ -163,7 +165,7 @@ export const UnderlineNav = forwardRef( const disclosureWidgetId = useId() 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 From 7033319ffac6204da2374b7269ced47119f6b440 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Tue, 3 Feb 2026 14:13:56 -0500 Subject: [PATCH 8/8] Try CSS based approach --- .../UnderlineNav.features.stories.tsx | 123 +++++++++ .../src/UnderlineNav/UnderlineNav.module.css | 85 ++++++ .../react/src/UnderlineNav/UnderlineNav.tsx | 246 +++++++++++++++++- .../UnderlineNav/UnderlineNavItem.module.css | 48 ++++ .../src/UnderlineNav/UnderlineNavItem.tsx | 41 ++- packages/react/src/UnderlineNav/index.ts | 3 +- packages/react/src/UnderlineNav/types.ts | 29 +++ 7 files changed, 570 insertions(+), 5 deletions(-) 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 937b5a09f65..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. @@ -69,7 +84,6 @@ const overflowEffect = ( // First, we check if we can fit all the items with their icons if (childArray.length <= numberOfItemsPossible) { items.push(...childArray) - console.log('All items fit with icons') 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 @@ -144,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( ( { @@ -153,6 +275,7 @@ export const UnderlineNav = forwardRef( variant = 'inset', className, children, + responsiveOverflow, }: UnderlineNavProps, forwardedRef, ) => { @@ -164,12 +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(false) const [childWidthArray, setChildWidthArray] = useState([]) const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) // Track whether the initial overflow calculation is complete to prevent CLS - const [isReady, setIsReady] = useState(false) + // In responsive mode, we're ready immediately since CSS handles visibility + const [isReady, setIsReady] = useState(useResponsiveMode) const validChildren = getValidChildren(children) @@ -326,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 ( +
  • > } + +/** + * + * 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' +}