Skip to content

Migrate the TabNav component to use CSS modules #6176

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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/thin-bears-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Migrate the TabNav component to use CSS modules
39 changes: 39 additions & 0 deletions packages/react/src/TabNav/TabNav.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.TabNavTabList {
display: flex;
/* stylelint-disable-next-line primer/spacing */
margin-bottom: -1px;
overflow: auto;
}

.TabNavNav {
margin-top: 0;
border-bottom: var(--borderWidth-thin) solid var(--borderColor-default);
}

.TabNavLink {
padding: var(--base-size-8) var(--base-size-12);
font-size: var(--text-body-size-medium);
/* stylelint-disable-next-line primer/typography */
line-height: 20px;
color: var(--fgColor-default);
text-decoration: none;
background-color: transparent;
border: var(--borderWidth-thin) solid transparent;
border-bottom: 0;

&:hover,
&:focus {
color: var(--fgColor-default);
text-decoration: none;

@mixin focusOutline -6px;
}

&.Selected {
color: var(--fgColor-default);
background-color: var(--bgColor-default);
border-color: var(--borderColor-default);
border-top-left-radius: var(--borderRadius-medium);
border-top-right-radius: var(--borderRadius-medium);
}
}
89 changes: 28 additions & 61 deletions packages/react/src/TabNav/TabNav.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,17 @@
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {clsx} from 'clsx'
import type {To} from 'history'
import React, {useRef, useState} from 'react'
import styled from 'styled-components'
import {get} from '../constants'
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
import type {SxProp} from '../sx'
import sx from '../sx'
import type {ComponentProps} from '../utils/types'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'

const ITEM_CLASS = 'TabNav-item'
const SELECTED_CLASS = 'selected'

const TabNavBase = styled.div<SxProp>`
${sx}
`

const TabNavTabList = styled.div`
display: flex;
margin-bottom: -1px;
overflow: auto;
`

const TabNavNav = styled.nav`
margin-top: 0;
border-bottom: 1px solid ${get('colors.border.default')};
`
import styles from './TabNav.module.css'
import {BoxWithFallback} from '../internal/components/BoxWithFallback'

/**
* @deprecated
*/
export type TabNavProps = ComponentProps<typeof TabNavBase>
export type TabNavProps = ComponentProps<typeof BoxWithFallback>

/**
* @deprecated
Expand Down Expand Up @@ -73,11 +53,13 @@ function TabNav({children, 'aria-label': ariaLabel, ...rest}: TabNavProps) {
)

return (
<TabNavBase {...rest} ref={navRef as React.RefObject<HTMLDivElement>}>
<TabNavNav aria-label={ariaLabel}>
<TabNavTabList role="tablist">{children}</TabNavTabList>
</TabNavNav>
</TabNavBase>
<BoxWithFallback {...rest} ref={navRef as React.RefObject<HTMLDivElement>}>
<nav aria-label={ariaLabel} className={styles.TabNavNav}>
<div role="tablist" className={styles.TabNavTabList}>
{children}
</div>
</nav>
</BoxWithFallback>
)
}

Expand All @@ -88,44 +70,29 @@ export type TabNavLinkProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLA
to?: To
selected?: boolean
href?: string
className?: string
as?: React.ElementType | 'a' | 'button' | 'div'
} & SxProp

/**
* @deprecated
*/
const TabNavLink = styled.a.attrs<TabNavLinkProps>(props => ({
className: clsx(ITEM_CLASS, props.selected && SELECTED_CLASS, props.className),
role: 'tab',
'aria-selected': !!props.selected,
tabIndex: -1,
}))<TabNavLinkProps>`
padding: 8px 12px;
font-size: ${get('fontSizes.1')};
line-height: 20px;
color: ${get('colors.fg.default')};
text-decoration: none;
background-color: transparent;
border: 1px solid transparent;
border-bottom: 0;

${getGlobalFocusStyles('-6px')};

&:hover,
&:focus {
color: ${get('colors.fg.default')};
text-decoration: none;
}

&.selected {
color: ${get('colors.fg.default')};
border-color: ${get('colors.border.default')};
border-top-right-radius: ${get('radii.2')};
border-top-left-radius: ${get('radii.2')};
background-color: ${get('colors.canvas.default')};
}

${sx};
` as PolymorphicForwardRefComponent<'a', TabNavLinkProps>
const TabNavLink = React.forwardRef<HTMLAnchorElement, TabNavLinkProps>(function TabNavLink(
{selected, className, as = 'a', ...rest}: TabNavLinkProps,
ref,
) {
return (
<BoxWithFallback
as={as}
ref={ref}
role="tab"
tabIndex={-1}
aria-selected={selected ? true : undefined}
className={clsx('TabNav-item', styles.TabNavLink, selected && 'selected', selected && styles.Selected, className)}
Copy link
Preview

Copilot AI Jun 6, 2025

Choose a reason for hiding this comment

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

For role="tab", aria-selected should always be present with a boolean value (true or false). Change to aria-selected={selected} so it does not get omitted.

Suggested change
aria-selected={selected ? true : undefined}
aria-selected={selected}

Copilot uses AI. Check for mistakes.

{...rest}
/>
)
})

TabNavLink.displayName = 'TabNav.Link'

Expand Down
5 changes: 2 additions & 3 deletions packages/react/src/TabNav/__tests__/TabNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import TabNav from '..'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {Button} from '../../Button'
import Box from '../../Box'

describe('TabNav', () => {
const tabNavMarkup = (
<Box>
<div>
<TabNav>
<TabNav.Link id="first" href="#" as="div">
First
Expand All @@ -21,7 +20,7 @@ describe('TabNav', () => {
</TabNav.Link>
</TabNav>
<Button id="my-button">My Button</Button>
</Box>
</div>
)

describe('TabNav.Link', () => {
Expand Down
11 changes: 0 additions & 11 deletions packages/react/src/TabNav/__tests__/TabNav.types.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,6 @@ export function shouldAcceptCallWithNoProps() {
)
}

export function shouldNotAcceptSystemProps() {
return (
<>
{/* @ts-expect-error system props should not be accepted */}
<TabNav backgroundColor="maroon" />
{/* @ts-expect-error system props should not be accepted */}
<TabNav.Link backgroundColor="fuchsia" />
</>
)
}

export function shouldAcceptButtonAsProps() {
return <TabNav.Link as={Button} />
}
Expand Down
Loading