Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilly-lemons-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": major
---

Remove sx from UnderlinePanels
17 changes: 4 additions & 13 deletions packages/react/src/UnderlineNav/UnderlineNav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {MutableRefObject, RefObject} from 'react'
import type {RefObject} from 'react'
import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react'
import Box from '../Box'
import type {SxProp} from '../sx'
Expand All @@ -18,7 +18,6 @@ import {useOnEscapePress} from '../hooks/useOnEscapePress'
import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
import {useId} from '../hooks/useId'
import {ActionList} from '../ActionList'
import {defaultSxProp} from '../utils/defaultSxProp'
import CounterLabel from '../CounterLabel'
import {invariant} from '../utils/invariant'

Expand Down Expand Up @@ -145,7 +144,6 @@ export const UnderlineNav = forwardRef(
{
as = 'nav',
'aria-label': ariaLabel,
sx: sxProp = defaultSxProp,
loadingCounters = false,
variant = 'inset',
className,
Expand All @@ -154,7 +152,7 @@ export const UnderlineNav = forwardRef(
forwardedRef,
) => {
const backupRef = useRef<HTMLElement>(null)
const navRef = (forwardedRef ?? backupRef) as MutableRefObject<HTMLElement>
const navRef = (forwardedRef ?? backupRef) as RefObject<HTMLElement>
const listRef = useRef<HTMLUListElement>(null)
const moreMenuRef = useRef<HTMLLIElement>(null)
const moreMenuBtnRef = useRef<HTMLButtonElement>(null)
Expand Down Expand Up @@ -215,7 +213,7 @@ export const UnderlineNav = forwardRef(
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 - (listRef.current?.getBoundingClientRect().width ?? 0)
(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.
Expand Down Expand Up @@ -316,14 +314,7 @@ export const UnderlineNav = forwardRef(
}}
>
{ariaLabel && <VisuallyHidden as="h2">{`${ariaLabel} navigation`}</VisuallyHidden>}
<UnderlineWrapper
as={as}
aria-label={ariaLabel}
className={className}
ref={navRef}
sx={sxProp}
data-variant={variant}
>
<UnderlineWrapper as={as} aria-label={ariaLabel} className={className} ref={navRef} data-variant={variant}>
<UnderlineItemList ref={listRef} role="list">
{listItems}
{menuItems.length > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,14 @@ import React, {
import {TabContainerElement} from '@github/tab-container-element'
import type {IconProps} from '@primer/octicons-react'
import {createComponent} from '../../utils/create-component'
import {
UnderlineItemList,
UnderlineWrapper,
UnderlineItem,
type UnderlineItemProps,
} from '../../internal/components/UnderlineTabbedInterface'
import {type BoxProps} from '../../Box'
import {UnderlineItemList, UnderlineWrapper, UnderlineItem} from '../../internal/components/UnderlineTabbedInterface'
import {useId} from '../../hooks'
import {invariant} from '../../utils/invariant'
import {type SxProp} from '../../sx'
import {useResizeObserver, type ResizeObserverEntry} from '../../hooks/useResizeObserver'
import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
import classes from './UnderlinePanels.module.css'
import {clsx} from 'clsx'
import {BoxWithFallback} from '../../internal/components/BoxWithFallback'

export type UnderlinePanelsProps = {
/**
Expand Down Expand Up @@ -74,7 +67,7 @@ export type TabProps = PropsWithChildren<{
}> &
SxProp

export type PanelProps = Omit<BoxProps, 'as'>
export type PanelProps = React.HTMLAttributes<HTMLDivElement>

const TabContainerComponent = createComponent(TabContainerElement, 'tab-container')

Expand Down Expand Up @@ -104,7 +97,7 @@ const UnderlinePanels: FC<UnderlinePanelsProps> = ({
let panelIndex = 0

const childrenWithProps = Children.map(children, child => {
if (isValidElement<UnderlineItemProps>(child) && child.type === Tab) {
if (isValidElement(child) && child.type === Tab) {
return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`, loadingCounters, iconsVisible})
}

Expand Down Expand Up @@ -221,8 +214,12 @@ const Tab: FC<TabProps> = ({'aria-selected': ariaSelected, onSelect, ...props})

Tab.displayName = 'UnderlinePanels.Tab'

const Panel: FC<PanelProps> = props => {
return <BoxWithFallback as="div" role="tabpanel" {...props} />
const Panel: FC<PanelProps> = ({children, ...rest}) => {
return (
<div role="tabpanel" {...rest}>
{children}
</div>
)
}

Panel.displayName = 'UnderlinePanels.Panel'
Expand Down
100 changes: 44 additions & 56 deletions packages/react/src/internal/components/UnderlineTabbedInterface.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
// Used for UnderlineNav and UnderlinePanels components

import type React from 'react'
import {forwardRef, type FC, type PropsWithChildren} 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 SxProp} from '../../sx'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../../utils/polymorphic'

import classes from './UnderlineTabbedInterface.module.css'
import {clsx} from 'clsx'
import {BoxWithFallback} from './BoxWithFallback'

// The gap between the list items. It is a constant because the gap is used to calculate the possible number of items that can fit in the container.
export const GAP = 8

type UnderlineWrapperProps = {
type UnderlineWrapperProps<As extends React.ElementType> = {
slot?: string
as?: React.ElementType
as?: As
className?: string
ref?: React.Ref<unknown>
} & SxProp
ref?: React.Ref<HTMLElement>
}

export const UnderlineWrapper = forwardRef(
({children, className, ...rest}: PropsWithChildren<UnderlineWrapperProps>, forwardedRef) => {
return (
<BoxWithFallback className={clsx(classes.UnderlineWrapper, className)} ref={forwardedRef} {...rest}>
{children}
</BoxWithFallback>
)
},
)
export const UnderlineWrapper = forwardRef((props, ref) => {
const {children, className, as: Component = 'nav', ...rest} = props
return (
<Component
className={clsx(classes.UnderlineWrapper, className)}
ref={ref as ForwardedRef<HTMLDivElement>}
{...rest}
>
{children}
</Component>
)
}) as PolymorphicForwardRefComponent<ElementType, UnderlineWrapperProps<ElementType>>

export const UnderlineItemList = forwardRef(({children, ...rest}: PropsWithChildren, forwardedRef) => {
return (
Expand All @@ -44,51 +45,38 @@ export const LoadingCounter = () => {
return <span className={classes.LoadingCounter} />
}

export type UnderlineItemProps = {
as?: React.ElementType | 'a' | 'button'
export type UnderlineItemProps<As extends React.ElementType> = {
as?: As | 'a' | 'button'
className?: string
iconsVisible?: boolean
loadingCounters?: boolean
counter?: number | string
icon?: FC<IconProps> | React.ReactElement
id?: string
ref?: React.Ref<unknown>
} & SxProp
} & React.ComponentPropsWithoutRef<As extends 'a' ? 'a' : As extends 'button' ? 'button' : As>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😵‍💫


export const UnderlineItem = forwardRef(
(
{
as = 'a',
children,
counter,
icon: Icon,
iconsVisible,
loadingCounters,
className,
...rest
}: PropsWithChildren<UnderlineItemProps>,
forwardedRef,
) => {
return (
<BoxWithFallback ref={forwardedRef} as={as} className={clsx(classes.UnderlineItem, className)} {...rest}>
{iconsVisible && Icon && <span data-component="icon">{isElement(Icon) ? Icon : <Icon />}</span>}
{children && (
<span data-component="text" data-content={children}>
{children}
export const UnderlineItem = React.forwardRef((props, ref) => {
const {as: Component = 'a', children, counter, icon: Icon, iconsVisible, loadingCounters, className, ...rest} = props
Comment on lines +59 to +60
Copy link
Preview

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The forwardRef should include proper TypeScript generics and parameter names for better type inference and debugging. Consider using the same pattern as UnderlineWrapper with explicit generic parameters.

See below for a potential fix:

export const UnderlineItem = React.forwardRef<
  HTMLElement,
  UnderlineItemProps<ElementType>
>(
  (props, ref) => {
    const {as: Component = 'a', children, counter, icon: Icon, iconsVisible, loadingCounters, className, ...rest} = props
    return (
      <Component {...rest} ref={ref} className={clsx(classes.UnderlineItem, className)}>
        {iconsVisible && Icon && <span data-component="icon">{isElement(Icon) ? Icon : <Icon />}</span>}
        {children && (
          <span data-component="text" data-content={children}>
            {children}
          </span>
        )}
        {counter !== undefined ? (
          loadingCounters ? (
            <span data-component="counter">
              <LoadingCounter />
            </span>
          ) : (
            <span data-component="counter">
              <CounterLabel>{counter}</CounterLabel>
            </span>
          )
        ) : null}
      </Component>
    )
  }
)

Copilot uses AI. Check for mistakes.

return (
<Component {...rest} ref={ref} className={clsx(classes.UnderlineItem, className)}>
{iconsVisible && Icon && <span data-component="icon">{isElement(Icon) ? Icon : <Icon />}</span>}
{children && (
<span data-component="text" data-content={children}>
{children}
</span>
)}
{counter !== undefined ? (
loadingCounters ? (
<span data-component="counter">
<LoadingCounter />
</span>
)}
{counter !== undefined ? (
loadingCounters ? (
<span data-component="counter">
<LoadingCounter />
</span>
) : (
<span data-component="counter">
<CounterLabel>{counter}</CounterLabel>
</span>
)
) : null}
</BoxWithFallback>
)
},
) as PolymorphicForwardRefComponent<'a', UnderlineItemProps>
) : (
<span data-component="counter">
<CounterLabel>{counter}</CounterLabel>
</span>
)
) : null}
</Component>
)
}) as PolymorphicForwardRefComponent<ElementType, UnderlineItemProps<ElementType>>
Copy link
Preview

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type assertion uses ElementType as both the constraint and the argument, which may not provide the intended polymorphic behavior. Consider using a more specific constraint like keyof JSX.IntrinsicElements | React.ComponentType<any> to ensure proper component props are inferred.

Suggested change
}) as PolymorphicForwardRefComponent<ElementType, UnderlineItemProps<ElementType>>
}) as PolymorphicForwardRefComponent<keyof JSX.IntrinsicElements | React.ComponentType<any>, UnderlineItemProps<any>>

Copilot uses AI. Check for mistakes.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {render, screen} from '@testing-library/react'
import {describe, expect, test} from 'vitest'
import {Dialog, PageHeader, Table, Tooltip, UnderlinePanels} from '../experimental'
import {Dialog, PageHeader, Table, Tooltip} from '../experimental'

describe('@primer/react/experimental', () => {
test('Dialog supports `sx` prop', () => {
Expand All @@ -26,26 +26,4 @@ describe('@primer/react/experimental', () => {
)
expect(window.getComputedStyle(screen.getByRole('tooltip', {hidden: true})).backgroundColor).toBe('rgb(255, 0, 0)')
})

test('UnderlinePanels supports `sx` prop', () => {
render(
<UnderlinePanels data-testid="component" sx={{background: 'red'}}>
<UnderlinePanels.Tab>tab</UnderlinePanels.Tab>
<UnderlinePanels.Panel>panel</UnderlinePanels.Panel>
</UnderlinePanels>,
)
expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)')
})

test('UnderlinePanels.Panel supports `sx` prop', () => {
render(
<UnderlinePanels>
<UnderlinePanels.Tab>tab</UnderlinePanels.Tab>
<UnderlinePanels.Panel data-testid="component" sx={{background: 'red'}}>
panel
</UnderlinePanels.Panel>
</UnderlinePanels>,
)
expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)')
})
})
19 changes: 0 additions & 19 deletions packages/styled-react/src/__tests__/primer-react.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
Token,
Tooltip,
Truncate,
UnderlineNav,
} from '../'

describe('@primer/react', () => {
Expand Down Expand Up @@ -422,22 +421,4 @@ describe('@primer/react', () => {
render(<Truncate data-testid="component" sx={{background: 'red'}} title="test" />)
expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)')
})

test('UnderlineNav supports `sx` prop', () => {
render(
<UnderlineNav aria-label="navigation" data-testid="component" sx={{background: 'red'}}>
<UnderlineNav.Item>test</UnderlineNav.Item>
</UnderlineNav>,
)
expect(window.getComputedStyle(screen.getByLabelText('navigation')).backgroundColor).toBe('rgb(255, 0, 0)')
})

test('UnderlineNav.Item supports `sx` prop', () => {
render(
<UnderlineNav.Item data-testid="component" sx={{background: 'red'}}>
test
</UnderlineNav.Item>,
)
expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)')
})
Comment on lines 476 to 492
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these test for UnderlineNav should not be removed.

Instead, we can wrap it as in this guide in styled-react, and ensure these tests continue to pass. This way, other repos that still use sx props can import from styled-react. Here’s the UnderlineNav usage with sx: https://primer-query.githubapp.com/?query=attribute%3A%22sx%22+name%3AUnderlineNav+package%3A%22%40primer%2Freact%22+version%3A%3E%3D37.x

For UnderlinePanels, I guess it’s fine to remove the tests since there are no usages of it with sx props:
https://primer-query.githubapp.com/?query=attribute%3A%22sx%22+name%3AUnderlinePanels+package%3A%22%40primer%2Freact%22+version%3A%3E%3D37.x

Curious what you think 👀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is true! nice catch, I totally missed this 😳

})
Loading