diff --git a/.changeset/rotten-apples-hope.md b/.changeset/rotten-apples-hope.md
new file mode 100644
index 00000000000..d7bd72dd33c
--- /dev/null
+++ b/.changeset/rotten-apples-hope.md
@@ -0,0 +1,7 @@
+---
+'@primer/react': minor
+---
+
+Adds UnderlinePanels component. It's like UnderlineNav, but for rendering semantic tabs instead of links.
+
+
diff --git a/e2e/components/UnderlinePanels.test.ts b/e2e/components/UnderlinePanels.test.ts
new file mode 100644
index 00000000000..b18417d7edd
--- /dev/null
+++ b/e2e/components/UnderlinePanels.test.ts
@@ -0,0 +1,245 @@
+import {test, expect} from '@playwright/test'
+import {visit} from '../test-helpers/storybook'
+import {themes} from '../test-helpers/themes'
+
+test.describe('UnderlinePanels', () => {
+ test.describe('Default', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels--default',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.Default.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels--default',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('Labelled By External Element', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--labelled-by-external-element',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.Labelled By External Element.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--labelled-by-external-element',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('Selected Tab', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--selected-tab',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.Selected Tab.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--selected-tab',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('With Counters', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-counters',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.With Counters.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-counters',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('With Counters In Loading State', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-counters-in-loading-state',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.With Counters In Loading State.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-counters-in-loading-state',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('With Icons', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-icons',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(`UnderlineNav.With Icons.${theme}.png`)
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-icons',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+
+ test.describe('With Icons Hidden On Narrow Screen', () => {
+ for (const theme of themes) {
+ test.describe(theme, () => {
+ test('default @vrt', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-icons-hidden-on-narrow-screen',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+
+ // Default state
+ expect(await page.screenshot()).toMatchSnapshot(
+ `UnderlineNav.With Icons Hidden On Narrow Screen.${theme}.png`,
+ )
+ })
+
+ test('axe @aat', async ({page}) => {
+ await visit(page, {
+ id: 'drafts-components-underlinepanels-features--with-icons-hidden-on-narrow-screen',
+ globals: {
+ colorScheme: theme,
+ },
+ })
+ await expect(page).toHaveNoViolations({
+ rules: {
+ 'color-contrast': {
+ enabled: theme !== 'dark_dimmed',
+ },
+ },
+ })
+ })
+ })
+ }
+ })
+})
diff --git a/package-lock.json b/package-lock.json
index 66fe23780f3..31d79eb7e2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -534,7 +534,7 @@
"version": "0.0.0",
"dependencies": {
"@primer/octicons-react": "^18.2.0",
- "@primer/react": "36.16.0",
+ "@primer/react": "36.17.0",
"next": "^14.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -4851,9 +4851,9 @@
"integrity": "sha512-zL79nlhZVCg7x2Pf/HT5MB0mowmErE71VXpF10/3Wy8dQwkninNO1M9aOizh2wKC5LkSpDXqNYjDZwbH0/bcSg=="
},
"node_modules/@github/tab-container-element": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-4.5.0.tgz",
- "integrity": "sha512-8pzFJVg7AyPFqOjKFoiHwVQbo4MdTPpUfQwW91Hgj+OOvySZVmw4PU8ejU4qTHbb2oA2ajYMRuXuAvhfMgnS1Q=="
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-4.7.0.tgz",
+ "integrity": "sha512-yAtsVLFTR+jGgsUH9qbmSKppdKirC2Oe2hKZzXAVzvXI6pyp+KMRzMQcic+E62beKH5g+qL7tlzQ8+CTLr3M2A=="
},
"node_modules/@graphql-tools/batch-execute": {
"version": "7.1.2",
@@ -62135,14 +62135,14 @@
},
"packages/react": {
"name": "@primer/react",
- "version": "36.16.0",
+ "version": "36.17.0",
"license": "MIT",
"dependencies": {
"@github/combobox-nav": "^2.1.5",
"@github/markdown-toolbar-element": "^2.1.0",
"@github/paste-markdown": "^1.4.0",
"@github/relative-time-element": "^4.1.2",
- "@github/tab-container-element": "4.5.0",
+ "@github/tab-container-element": "4.7.0",
"@lit-labs/react": "1.2.1",
"@oddbird/popover-polyfill": "^0.3.1",
"@primer/behaviors": "^1.5.1",
diff --git a/packages/react/package.json b/packages/react/package.json
index a979bdb0e88..1919e4831a0 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -89,7 +89,7 @@
"@github/markdown-toolbar-element": "^2.1.0",
"@github/paste-markdown": "^1.4.0",
"@github/relative-time-element": "^4.1.2",
- "@github/tab-container-element": "4.5.0",
+ "@github/tab-container-element": "4.7.0",
"@lit-labs/react": "1.2.1",
"@oddbird/popover-polyfill": "^0.3.1",
"@primer/behaviors": "^1.5.1",
diff --git a/packages/react/src/UnderlineNav/LoadingCounter.tsx b/packages/react/src/UnderlineNav/LoadingCounter.tsx
deleted file mode 100644
index 7e7d67b4366..00000000000
--- a/packages/react/src/UnderlineNav/LoadingCounter.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import styled, {keyframes} from 'styled-components'
-import {get} from '../constants'
-
-const loading = keyframes`
- from { opacity: 1; }
- to { opacity: 0.2; }
-`
-
-export const LoadingCounter = styled.span`
- animation: ${loading} 1.2s ease-in-out infinite alternate;
- background-color: ${get('colors.neutral.muted')};
- border-color: ${get('colors.border.default')};
- width: 1.5rem;
- height: 1rem; // 16px
- display: inline-block;
- border-radius: 20px;
-`
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.docs.json b/packages/react/src/UnderlineNav/UnderlineNav.docs.json
index a45740f0ff7..ab94da9a414 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.docs.json
+++ b/packages/react/src/UnderlineNav/UnderlineNav.docs.json
@@ -7,7 +7,7 @@
"importPath": "@primer/react",
"props": [
{
- "name": "afterSelect",
+ "name": "onSelect",
"type": "(event) => void",
"defaultValue": "",
"description": "The handler that gets called when a nav link child is selected"
@@ -48,7 +48,7 @@
},
{
"name": "counter",
- "type": "number",
+ "type": "number | string",
"defaultValue": "",
"description": "The number to display in the counter label."
},
diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx
index 1143c1a67b1..5ddf04b711f 100644
--- a/packages/react/src/UnderlineNav/UnderlineNav.tsx
+++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx
@@ -1,25 +1,21 @@
import type {MutableRefObject, RefObject} from 'react'
import React, {useRef, forwardRef, useCallback, useState, useEffect} from 'react'
import Box from '../Box'
-import type {BetterSystemStyleObject, SxProp} from '../sx'
-import sx, {merge} from '../sx'
+import type {SxProp} from '../sx'
+import sx from '../sx'
import {UnderlineNavContext} from './UnderlineNavContext'
import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
import {useResizeObserver} from '../hooks/useResizeObserver'
import {useTheme} from '../ThemeProvider'
import type {ChildWidthArray, ResponsiveProps, ChildSize} from './types'
import VisuallyHidden from '../_VisuallyHidden'
+import {moreBtnStyles, getDividerStyle, menuStyles, menuItemStyles, baseMenuStyles, baseMenuMinWidth} from './styles'
import {
- moreBtnStyles,
- getDividerStyle,
- getNavStyles,
- ulStyles,
- menuStyles,
- menuItemStyles,
+ StyledUnderlineTabList,
+ StyledUnderlineWrapper,
+ LoadingCounter,
GAP,
- baseMenuStyles,
- baseMenuMinWidth,
-} from './styles'
+} from '../internal/components/UnderlineTabbedInterface'
import styled from 'styled-components'
import {Button} from '../Button'
import {TriangleDownIcon} from '@primer/octicons-react'
@@ -29,7 +25,6 @@ import {useId} from '../hooks/useId'
import {ActionList} from '../ActionList'
import {defaultSxProp} from '../utils/defaultSxProp'
import CounterLabel from '../CounterLabel'
-import {LoadingCounter} from './LoadingCounter'
import {invariant} from '../utils/invariant'
export type UnderlineNavProps = {
@@ -316,13 +311,8 @@ export const UnderlineNav = forwardRef(
}}
>
{ariaLabel && {`${ariaLabel} navigation`}}
- (getNavStyles(theme), sxProp)}
- aria-label={ariaLabel}
- ref={navRef}
- >
-
+
+
{listItems}
{menuItems.length > 0 && (
@@ -414,8 +404,8 @@ export const UnderlineNav = forwardRef(
)}
-
-
+
+
)
},
diff --git a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx
index 08af4afc317..7ee43e075bc 100644
--- a/packages/react/src/UnderlineNav/UnderlineNavItem.tsx
+++ b/packages/react/src/UnderlineNav/UnderlineNavItem.tsx
@@ -1,17 +1,13 @@
import type {MutableRefObject, RefObject} from 'react'
import React, {forwardRef, useRef, useContext} from 'react'
import Box from '../Box'
-import type {SxProp, BetterSystemStyleObject} from '../sx'
-import {merge} from '../sx'
+import type {SxProp} from '../sx'
import type {IconProps} from '@primer/octicons-react'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {UnderlineNavContext} from './UnderlineNavContext'
-import CounterLabel from '../CounterLabel'
-import {getLinkStyles, iconWrapStyles, counterStyles} from './styles'
-import {LoadingCounter} from './LoadingCounter'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import {defaultSxProp} from '../utils/defaultSxProp'
-import Link from '../Link'
+import {UnderlineTab} from '../internal/components/UnderlineTabbedInterface'
// adopted from React.AnchorHTMLAttributes
export type LinkProps = {
@@ -71,8 +67,7 @@ export const UnderlineNavItem = forwardRef(
) => {
const backupRef = useRef(null)
const ref = (forwardedRef ?? backupRef) as RefObject
- const {theme, setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} =
- useContext(UnderlineNavContext)
+ const {setChildrenWidth, setNoIconChildrenWidth, loadingCounters, iconsVisible} = useContext(UnderlineNavContext)
useLayoutEffect(() => {
if (ref.current) {
@@ -117,43 +112,22 @@ export const UnderlineNavItem = forwardRef(
return (
- (getLinkStyles(theme, ariaCurrent), sxProp as SxProp)}
+ counter={counter}
+ icon={Icon}
+ loadingCounters={loadingCounters}
+ iconsVisible={iconsVisible}
+ sx={sxProp}
{...props}
>
- {iconsVisible && Icon && (
-
-
-
- )}
- {children && (
-
- {children}
-
- )}
- {loadingCounters ? (
-
-
-
- ) : (
- counter !== undefined && (
-
- {counter}
-
- )
- )}
-
+ {children}
+
)
},
diff --git a/packages/react/src/UnderlineNav/styles.ts b/packages/react/src/UnderlineNav/styles.ts
index 9edf51942a7..227a66e20fa 100644
--- a/packages/react/src/UnderlineNav/styles.ts
+++ b/packages/react/src/UnderlineNav/styles.ts
@@ -2,45 +2,6 @@ import type {Theme} from '../ThemeProvider'
import type {BetterSystemStyleObject} from '../sx'
import {getAnchoredPosition} from '@primer/behaviors'
-// 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
-
-export const iconWrapStyles = {
- alignItems: 'center',
- display: 'inline-flex',
- marginRight: 2,
-}
-
-export const counterStyles = {
- marginLeft: 2,
- display: 'flex',
- alignItems: 'center',
-}
-
-export const getNavStyles = (theme?: Theme) => ({
- display: 'flex',
- paddingX: 3,
- justifyContent: 'flex-start',
- borderBottom: '1px solid',
- borderBottomColor: `${theme?.colors.border.muted}`,
- align: 'row',
- alignItems: 'center',
- minHeight: '48px',
-})
-
-export const ulStyles = {
- display: 'flex',
- listStyle: 'none',
- whiteSpace: 'nowrap',
- paddingY: 0,
- paddingX: 0,
- margin: 0,
- marginBottom: '-1px',
- alignItems: 'center',
- gap: `${GAP}px`,
- position: 'relative',
-}
-
export const getDividerStyle = (theme?: Theme) => ({
display: 'inline-block',
borderLeft: '1px solid',
@@ -64,71 +25,6 @@ export const moreBtnStyles = {
},
}
-export const getLinkStyles = (theme?: Theme, ariaCurrent?: string | boolean) => ({
- position: 'relative',
- display: 'inline-flex',
- color: 'fg.default',
- textAlign: 'center',
- textDecoration: 'none',
- lineHeight: 'calc(20/14)',
- '& span[data-component="icon"]': {
- color: 'fg.muted',
- },
- borderRadius: 2,
- fontSize: 1,
- paddingX: 2,
- paddingY: 'calc((2rem - 1.25rem) / 2)',
- '@media (hover:hover)': {
- '&:hover ': {
- backgroundColor: theme?.colors.neutral.muted,
- transition: 'background .12s ease-out',
- textDecoration: 'none',
- },
- },
- '&:focus': {
- outline: '2px solid transparent',
- '&': {
- boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}`,
- },
- // where focus-visible is supported, remove the focus box-shadow
- '&:not(:focus-visible)': {
- boxShadow: 'none',
- },
- },
- '&:focus-visible': {
- outline: '2px solid transparent',
- boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}`,
- },
- // renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected
- '& span[data-content]::before': {
- content: 'attr(data-content)',
- display: 'block',
- height: 0,
- fontWeight: '600',
- visibility: 'hidden',
- whiteSpace: 'nowrap',
- },
- // selected state styles
- '&::after': {
- position: 'absolute',
- right: '50%',
- bottom: 'calc(50% - 25px)', // 48px total height / 2 (24px) + 1px
- width: '100%',
- height: 2,
- content: '""',
- backgroundColor:
- Boolean(ariaCurrent) && ariaCurrent !== 'false' ? theme?.colors.primer.border.active : 'transparent',
- borderRadius: 0,
- transform: 'translate(50%, -50%)',
- },
- '@media (forced-colors: active)': {
- '::after': {
- // Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast
- backgroundColor: Boolean(ariaCurrent) && ariaCurrent ? 'LinkText' : 'transparent',
- },
- },
-})
-
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': {
diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
index c29dc006f94..a26a2b182e4 100644
--- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -254,6 +254,7 @@ exports[`@primer/react/drafts should not update exports without a semver change
"default",
"default",
"default",
+ "default",
"Dialog",
"type DialogButtonProps",
"type DialogHeaderProps",
@@ -322,6 +323,8 @@ exports[`@primer/react/drafts should not update exports without a semver change
"type TooltipProps",
"type Trigger",
"type TriggerPropsType",
+ "type UnderlineTabbedInterfaceProps",
+ "type UnderlineTabButtonProps",
"useCombobox",
"useDynamicTextareaHeight",
"useIgnoreKeyboardActionsWhileComposing",
@@ -350,6 +353,7 @@ exports[`@primer/react/experimental should not update exports without a semver c
"default",
"default",
"default",
+ "default",
"Dialog",
"type DialogButtonProps",
"type DialogHeaderProps",
@@ -420,6 +424,8 @@ exports[`@primer/react/experimental should not update exports without a semver c
"type TooltipProps",
"type Trigger",
"type TriggerPropsType",
+ "type UnderlineTabbedInterfaceProps",
+ "type UnderlineTabButtonProps",
"useCombobox",
"useDynamicTextareaHeight",
"useIgnoreKeyboardActionsWhileComposing",
diff --git a/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.docs.json b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.docs.json
new file mode 100644
index 00000000000..847eaf71a6e
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.docs.json
@@ -0,0 +1,104 @@
+{
+ "id": "underline_panels",
+ "name": "UnderlinePanels",
+ "status": "draft",
+ "a11yReviewed": false,
+ "stories": [
+ {
+ "id": "drafts-components-underlinepanels--default"
+ },
+ {
+ "id": "'drafts-components-underlinepanels-features--labelled-by-external-element"
+ },
+ {
+ "id": "drafts-components-underlinepanels-features--selected-tab"
+ },
+ {
+ "id": "drafts-components-underlinepanels-features--with-counters"
+ },
+ {
+ "id": "drafts-components-underlinepanels-features--with-counters-in-loading-state"
+ },
+ {
+ "id": "drafts-components-underlinepanels-features--with-icons"
+ },
+ {
+ "id": "drafts-components-underlinepanels-features--with-icons-hidden-on-narrow-screen"
+ }
+ ],
+ "importPath": "@primer/react",
+ "props": [
+ {
+ "name": "aria-label",
+ "type": "string",
+ "defaultValue": "",
+ "description": "Accessible name for the tab list"
+ },
+ {
+ "name": "aria-labelledby",
+ "type": "string",
+ "defaultValue": "",
+ "description": "ID of the element containing the name for the tab list"
+ },
+ {
+ "name": "children",
+ "type": "Array",
+ "defaultValue": "",
+ "required": true,
+ "description": "Tabs (UnderlinePanels.Tab) and panels (UnderlinePanels.Panel) to render"
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "defaultValue": "",
+ "description": "Custom string to use when generating the IDs of tabs and `aria-labelledby` for the panels"
+ },
+ {
+ "name": "loadingCounters",
+ "type": "boolean",
+ "defaultValue": "false",
+ "description": "Loading state for all counters. It displays loading animation for individual counters until all are resolved. It is needed to prevent multiple layout shift."
+ },
+ {
+ "name": "sx",
+ "type": "SystemStyleObject"
+ }
+ ],
+ "subcomponents": [
+ {
+ "name": "UnderlinePanels.Tab",
+ "props": [
+ {
+ "name": "aria-selected",
+ "type": "| boolean | 'true' | 'false'",
+ "defaultValue": "false",
+ "description": "Whether this is the selected tab. For more information about `aria-current`, see [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-selected)."
+ },
+ {
+ "name": "counter",
+ "type": "number | string",
+ "defaultValue": "",
+ "description": "Content of CounterLabel rendered after tab text label"
+ },
+ {
+ "name": "icon",
+ "type": "Component",
+ "defaultValue": "",
+ "description": "Icon rendered before the tab text label"
+ },
+ {
+ "name": "sx",
+ "type": "SystemStyleObject"
+ }
+ ]
+ },
+ {
+ "name": "UnderlinePanels.Tab",
+ "props": [],
+ "passthrough": {
+ "element": "div",
+ "url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#Attributes"
+ }
+ }
+ ]
+}
diff --git a/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.features.stories.tsx b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.features.stories.tsx
new file mode 100644
index 00000000000..c643b4d2953
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.features.stories.tsx
@@ -0,0 +1,118 @@
+import React from 'react'
+import type {Meta} from '@storybook/react'
+import {INITIAL_VIEWPORTS} from '@storybook/addon-viewport'
+import UnderlinePanels from './UnderlinePanels'
+import type {ComponentProps} from '../../utils/types'
+import {
+ CodeIcon,
+ CommentDiscussionIcon,
+ EyeIcon,
+ GearIcon,
+ GitPullRequestIcon,
+ GraphIcon,
+ PlayIcon,
+ ProjectIcon,
+ ShieldLockIcon,
+} from '@primer/octicons-react'
+
+export default {
+ title: 'Drafts/Components/UnderlinePanels/Features',
+ component: UnderlinePanels,
+} as Meta>
+
+export const SelectedTab = () => (
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ Panel 3
+
+)
+
+export const LabelledByExternalElement = () => (
+ <>
+ UnderlinePanels example
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ Panel 3
+
+ >
+)
+
+export const WithIcons = () => (
+
+ Code
+ Issues
+ Pull requests
+ Code panel
+ Issues panel
+ Pull requests panel
+
+)
+
+export const WithIconsHiddenOnNarrowScreen = () => (
+
+ Code
+ Issues
+ Pull requests
+ Discussions
+ Actions
+ Projects
+ Insights
+ Settings
+ Security
+ Code panel
+ Issues panel
+ Pull requests panel
+ Discussions panel
+ Actions panel
+ Projects panel
+ Insights panel
+ Settings panel
+ Security panel
+
+)
+
+WithIconsHiddenOnNarrowScreen.parameters = {
+ viewport: {
+ viewports: {
+ ...INITIAL_VIEWPORTS,
+ narrowScreen: {
+ name: 'Narrow Screen',
+ styles: {
+ width: '800px',
+ height: '100%',
+ },
+ },
+ },
+ defaultViewport: 'narrowScreen',
+ },
+}
+
+export const WithCounters = () => {
+ return (
+
+ Code
+ Issues
+ Code panel
+ Issues panel
+
+ )
+}
+
+export const WithCountersInLoadingState = () => {
+ return (
+
+ Code
+ Issues
+ Code panel
+ Issues panel
+
+ )
+}
diff --git a/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.stories.tsx b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.stories.tsx
new file mode 100644
index 00000000000..3e748c627db
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.stories.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import type {Meta} from '@storybook/react'
+import UnderlinePanels from './UnderlinePanels'
+import type {ComponentProps} from '../../utils/types'
+
+export default {
+ title: 'Drafts/Components/UnderlinePanels',
+ component: UnderlinePanels,
+} as Meta>
+
+export const Default = () => (
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ Panel 3
+
+)
diff --git a/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.test.tsx b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.test.tsx
new file mode 100644
index 00000000000..9a63ac4bec3
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.test.tsx
@@ -0,0 +1,117 @@
+// Most of the functionality is already tested in [@github/tab-container-element](https://github.com/github/tab-container-element)
+
+import React from 'react'
+import {render, screen} from '@testing-library/react'
+import UnderlinePanels from './UnderlinePanels'
+import {behavesAsComponent, checkExports} from '../../utils/testing'
+import TabContainerElement from '@github/tab-container-element'
+
+TabContainerElement.prototype.selectTab = jest.fn()
+
+const UnderlinePanelsMockComponent = (props: {'aria-label'?: string; 'aria-labelledby'?: string; id?: string}) => (
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ Panel 3
+
+)
+
+describe('UnderlinePanels', () => {
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ checkExports('drafts/UnderlinePanels', {
+ default: UnderlinePanels,
+ })
+
+ behavesAsComponent({Component: UnderlinePanels, options: {skipAs: true}})
+
+ behavesAsComponent({Component: UnderlinePanels.Tab})
+
+ it('renders without errors', () => {
+ render()
+ })
+
+ it('renders with a custom ID', () => {
+ render()
+
+ const firstTab = screen.getByRole('tab', {name: 'Tab 1'})
+ const firstPanel = screen.getByText('Panel 1')
+
+ expect(firstTab).toHaveAttribute('id', 'custom-id-tab-0')
+ expect(firstPanel).toHaveAttribute('aria-labelledby', 'custom-id-tab-0')
+ })
+ it('renders aria-label', () => {
+ render()
+
+ const tabList = screen.getByRole('tablist')
+ expect(tabList).toHaveAccessibleName('Select a tab')
+ })
+ it('renders aria-labelledby', () => {
+ render(
+ <>
+
+
+ >,
+ )
+
+ const tabList = screen.getByRole('tablist')
+ expect(tabList).toHaveAccessibleName('Select a tab')
+ })
+ it('throws an error when the neither aria-label nor aria-labelledby are passed', () => {
+ render()
+ })
+ it('throws an error when the number of tabs does not match the number of panels', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation()
+ expect(() => {
+ render(
+
+ Tab 1
+ Tab 2
+ Panel 1
+ Panel 2
+ Panel 3
+ ,
+ )
+ }).toThrow('The number of tabs and panels must be equal. Counted 2 tabs and 3 panels.')
+ expect(spy).toHaveBeenCalled()
+ spy.mockRestore()
+ })
+ it('throws an error when the number of panels does not match the number of tabs', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation()
+ expect(() => {
+ render(
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ ,
+ )
+ }).toThrow('The number of tabs and panels must be equal. Counted 3 tabs and 2 panels.')
+ expect(spy).toHaveBeenCalled()
+ spy.mockRestore()
+ })
+ it('throws an error when there are multiple items that have aria-selected', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation()
+ expect(() => {
+ render(
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Panel 1
+ Panel 2
+ Panel 3
+ ,
+ )
+ }).toThrow('Only one tab can be selected at a time.')
+ expect(spy).toHaveBeenCalled()
+ spy.mockRestore()
+ })
+})
diff --git a/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.tsx
new file mode 100644
index 00000000000..09f568083f3
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/UnderlinePanels.tsx
@@ -0,0 +1,188 @@
+import React, {Children, isValidElement, cloneElement, useState, useRef, type FC, type PropsWithChildren} from 'react'
+import {TabContainerElement} from '@github/tab-container-element'
+import createComponent from '../../utils/custom-element'
+import {
+ StyledUnderlineTabList,
+ StyledUnderlineWrapper,
+ UnderlineTab,
+ type UnderlineTabProps,
+} from '../../internal/components/UnderlineTabbedInterface'
+import {useId} from '../../hooks'
+import {invariant} from '../../utils/invariant'
+import type {IconProps} from '@primer/octicons-react'
+import {merge, type BetterSystemStyleObject, type SxProp} from '../../sx'
+import {defaultSxProp} from '../../utils/defaultSxProp'
+import {useResizeObserver, type ResizeObserverEntry} from '../../hooks/useResizeObserver'
+import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect'
+
+export type UnderlineTabButtonProps = {
+ /**
+ * Whether this is the selected tab
+ */
+ 'aria-selected'?: boolean
+ /**
+ * Content of CounterLabel rendered after tab text label
+ */
+ counter?: number | string
+ /**
+ * Icon rendered before the tab text label
+ */
+ icon?: FC
+} & SxProp
+
+export type UnderlineTabbedInterfaceProps = {
+ /**
+ * Tabs (UnderlinePanels.Tab) and panels (UnderlinePanels.Panel) to render
+ */
+ children: React.ReactNode
+ /**
+ * Accessible name for the tab list
+ */
+ 'aria-label'?: React.AriaAttributes['aria-label']
+ /**
+ * ID of the element containing the name for the tab list
+ */
+ 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']
+ /**
+ * Custom string to use when generating the IDs of tabs and `aria-labelledby` for the panels
+ */
+ id?: string
+ /**
+ * Loading state for all counters. It displays loading animation for individual counters until all are resolved. It is needed to prevent multiple layout shift.
+ */
+ loadingCounters?: boolean
+} & SxProp
+
+const TabContainerComponent = createComponent(TabContainerElement, 'tab-container')
+
+const UnderlinePanels: FC> = ({
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledBy,
+ children,
+ loadingCounters,
+ sx: sxProp = defaultSxProp,
+ ...props
+}) => {
+ const [iconsVisible, setIconsVisible] = useState(true)
+ const wrapperRef = useRef(null)
+ const listRef = useRef(null)
+ // We need to always call useId() because React Hooks must be
+ // called in the exact same order in every component render
+ const defaultId = useId()
+ const parentId = props.id ?? defaultId
+
+ // Loop through the chidren, if it's a tab, then add id="{id}-tab-{index}"
+ // If it's a panel, then add aria-labelledby="{id}-tab-{index}"
+ let tabIndex = 0
+ let panelIndex = 0
+
+ const childrenWithProps = Children.map(children, child => {
+ if (isValidElement(child) && child.type === Tab) {
+ return cloneElement(child, {id: `${parentId}-tab-${tabIndex++}`, loadingCounters, iconsVisible})
+ }
+
+ if (isValidElement(child) && child.type === Panel) {
+ return cloneElement(child, {'aria-labelledby': `${parentId}-tab-${panelIndex++}`})
+ }
+ return child
+ })
+
+ // `tabs` and `tabPanels` need to be refs because `child.type === {type}` will become false
+ // after the elements are cloned by `childrenWithProps` on the first render
+ const tabs = useRef(
+ Children.toArray(childrenWithProps).filter(child => {
+ return isValidElement(child) && child.type === Tab
+ }),
+ )
+ const tabPanels = useRef(
+ Children.toArray(childrenWithProps).filter(child => isValidElement(child) && child.type === Panel),
+ )
+ const tabsHaveIcons = tabs.current.some(tab => React.isValidElement(tab) && tab.props.icon)
+
+ // this is a workaround to get the list's width on the first render
+ const [listWidth, setListWidth] = useState(0)
+ useIsomorphicLayoutEffect(() => {
+ if (!tabsHaveIcons) {
+ return
+ }
+
+ setListWidth(listRef.current?.getBoundingClientRect().width ?? 0)
+ }, [tabsHaveIcons])
+
+ // when the wrapper resizes, check if the icons should be visible
+ // by comparing the wrapper width to the list width
+ useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => {
+ if (!tabsHaveIcons) {
+ return
+ }
+
+ const wrapperWidth = resizeObserverEntries[0].contentRect.width
+
+ setIconsVisible(wrapperWidth > listWidth)
+ }, wrapperRef)
+
+ if (__DEV__) {
+ // only one tab can be selected at a time
+ const selectedTabs = tabs.current.filter(tab => {
+ const ariaSelected = React.isValidElement(tab) && tab.props['aria-selected']
+
+ return ariaSelected === true || ariaSelected === 'true'
+ })
+
+ invariant(selectedTabs.length <= 1, 'Only one tab can be selected at a time.')
+
+ // every tab has its panel
+ invariant(
+ tabs.current.length === tabPanels.current.length,
+ `The number of tabs and panels must be equal. Counted ${tabs.current.length} tabs and ${tabPanels.current.length} panels.`,
+ )
+ }
+
+ return (
+
+ (
+ {
+ width: '100%',
+ overflowX: 'auto',
+ overflowY: 'hidden',
+ '-webkit-overflow-scrolling': 'auto',
+ '&[data-icons-visible="false"] [data-component="icon"]': {
+ display: 'none',
+ },
+ },
+ sxProp as SxProp,
+ )}
+ {...props}
+ >
+
+ {tabs.current}
+
+
+ {tabPanels.current}
+
+ )
+}
+
+type TabProps = PropsWithChildren
+
+const Tab: FC = ({'aria-selected': ariaSelected, sx: sxProp = defaultSxProp, ...props}) => {
+ const tabIndex = ariaSelected ? 0 : -1
+
+ return
+}
+
+Tab.displayName = 'UnderlinePanels.Tab'
+
+type PanelProps = PropsWithChildren, 'role'>>
+
+const Panel: FC = props => {
+ return
+}
+
+Panel.displayName = 'UnderlinePanels.Panel'
+
+export default Object.assign(UnderlinePanels, {Panel, Tab})
diff --git a/packages/react/src/drafts/UnderlinePanels/index.ts b/packages/react/src/drafts/UnderlinePanels/index.ts
new file mode 100644
index 00000000000..6c9e98406af
--- /dev/null
+++ b/packages/react/src/drafts/UnderlinePanels/index.ts
@@ -0,0 +1,2 @@
+export {default} from './UnderlinePanels'
+export type {UnderlineTabButtonProps, UnderlineTabbedInterfaceProps} from './UnderlinePanels'
diff --git a/packages/react/src/drafts/index.ts b/packages/react/src/drafts/index.ts
index b26c9a7abfa..adf88743ce9 100644
--- a/packages/react/src/drafts/index.ts
+++ b/packages/react/src/drafts/index.ts
@@ -72,3 +72,5 @@ export * from './ActionBar'
export {Stack} from '../Stack'
export type {StackProps, StackItemProps} from '../Stack'
+
+export * from './UnderlinePanels'
diff --git a/packages/react/src/internal/components/UnderlineTabbedInterface.tsx b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx
new file mode 100644
index 00000000000..44c9cf71284
--- /dev/null
+++ b/packages/react/src/internal/components/UnderlineTabbedInterface.tsx
@@ -0,0 +1,239 @@
+// Used for UnderlineNav and UnderlinePanels components
+
+import React, {forwardRef, type FC, type PropsWithChildren} from 'react'
+import type {IconProps} from '@primer/octicons-react'
+import styled, {keyframes} from 'styled-components'
+import CounterLabel from '../../CounterLabel'
+import sx, {type SxProp} from '../../sx'
+import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../../utils/polymorphic'
+import {defaultSxProp} from '../../utils/defaultSxProp'
+
+// 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
+
+export const StyledUnderlineWrapper = styled.div`
+ display: flex;
+ padding-inline: var(--stack-padding-normal);
+ justify-content: flex-start;
+ align-items: center;
+ /* make space for the underline */
+ min-height: var(--control-xlarge-size);
+ /* using a box-shadow instead of a border to accomodate 'overflow-y: hidden' on UnderlinePanels */
+ box-shadow: inset 0px -1px var(--borderColor-muted);
+
+ ${sx};
+`
+
+export const StyledUnderlineTabList = styled.ul`
+ display: flex;
+ list-style: none;
+ white-space: nowrap;
+ padding: 0;
+ margin: 0;
+ align-items: center;
+ gap: ${GAP}px;
+ position: relative;
+`
+
+export const StyledUnderlineTab = styled.div`
+ /* button resets */
+ appearance: none;
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+ font: inherit;
+
+ /* underline tab specific styles */
+ position: relative;
+ display: inline-flex;
+ color: var(--fgColor-default);
+ text-align: center;
+ text-decoration: none;
+ line-height: var(--text-body-lineHeight-medium);
+ border-radius: var(--borderRadius-medium);
+ font-size: var(--text-body-size-medium);
+ padding-inline: var(--control-medium-paddingInline-condensed);
+ padding-block: var(--control-medium-paddingBlock);
+ align-items: center;
+
+ @media (hover: hover) : {
+ &:hover {
+ background-color: var(--bgColor-neutral-muted);
+ transition: background 0.12s ease-out;
+ text-decoration: none;
+ }
+ }
+
+ &:focus: {
+ outline: 2px solid transparent;
+ box-shadow: inset 0 0 0 2px var(--fgColor-accent);
+
+ /* where focus-visible is supported, remove the focus box-shadow */
+ &:not(:focus-visible) {
+ box-shadow: none;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid transparent;
+ box-shadow: inset 0 0 0 2px var(--fgColor-accent);
+ }
+
+ /* renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected */
+ [data-content]::before {
+ content: attr(data-content);
+ display: block;
+ height: 0;
+ font-weight: var(--base-text-weight-semibold);
+ visibility: hidden;
+ white-space: nowrap;
+ }
+
+ [data-component='icon'] {
+ color: var(--fgColor-muted);
+ align-items: center;
+ display: inline-flex;
+ margin-inline-end: var(--control-medium-gap);
+ }
+
+ [data-component='counter'] {
+ margin-inline-start: var(--control-medium-gap);
+ display: flex;
+ align-items: center;
+ }
+
+ /* selected state styles */
+ &::after {
+ position: absolute;
+ right: 50%;
+ /* TODO: see if we can simplify this positioning */
+ /* 48px total height / 2 (24px) + 1px */
+ bottom: calc(50% - calc(var(--control-xlarge-size) / 2 + 1px));
+ width: 100%;
+ height: 2px;
+ content: '';
+ background-color: transparent;
+ border-radius: 0;
+ transform: translate(50%, -50%);
+ }
+
+ &[aria-current]:not([aria-current='false']),
+ &[aria-selected='true'] {
+ [data-component='text'] {
+ font-weight: var(--base-text-weight-semibold);
+ }
+
+ &::after {
+ background-color: var(--underlineNav-borderColor-active, var(--color-primer-border-active, #fd8c73));
+ }
+ }
+
+ @media (forced-colors: active) {
+ &[aria-current]:not([aria-current='false']),
+ &[aria-selected='true'] {
+ ::after {
+ // Support for Window Force Color Mode https://learn.microsoft.com/en-us/fluent-ui/web-components/design-system/high-contrast
+ background-color: LinkText;
+ }
+ }
+ }
+ ${sx};
+`
+
+const loadingKeyframes = keyframes`
+ from { opacity: 1; }
+ to { opacity: 0.2; }
+`
+
+export const LoadingCounter = styled.span`
+ animation: ${loadingKeyframes} 1.2s ease-in-out infinite alternate;
+ background-color: var(--bgColor-neutral-muted);
+ border-color: var(--borderColor-default);
+ width: 1.5rem;
+ height: 1rem; /*16px*/
+ display: inline-block;
+ border-radius: 20px;
+`
+
+// We can uncomment these when/if we add overflow behavior
+// to the UnderlinePanels component
+//
+// export const StyledMoreButton = styled(Button)`
+// margin: 0;
+// border: 0;
+// background: transparent;
+// font-weight: normal;
+// box-shadow: none;
+// padding-block: var(--control-small-paddingBlock);
+// padding-inline: var(--control-small-paddingInline-condensed);
+
+// > span[data-component='trailingVisual'] {
+// margin-left: 0;
+// }
+// `
+
+// export const StyledOverflowDivider = styled.span`
+// display: inline-block;
+// border-left: 1px solid var(--borderColor-muted);
+// width: 1px;
+// margin-right: var(--control-xsmall-gap);
+// /* The height of the divider - reference from Figma */
+// height: 24px;
+// `
+
+// export const StyledMoreMenuListItem = styled.li`
+// display: flex;
+// align-items: center;
+// height: 45px;
+// `
+
+export type UnderlineTabProps = {
+ as?: React.ElementType | 'a' | 'button'
+ iconsVisible?: boolean
+ loadingCounters?: boolean
+ counter?: number | string
+ icon?: FC
+ id?: string
+} & SxProp
+
+export const UnderlineTab = forwardRef(
+ (
+ {
+ as = 'a',
+ children,
+ counter,
+ icon: Icon,
+ iconsVisible,
+ loadingCounters,
+ sx: sxProp = defaultSxProp,
+ ...rest
+ }: PropsWithChildren,
+ forwardedRef,
+ ) => {
+ return (
+
+ {iconsVisible && Icon && (
+
+
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+ {loadingCounters ? (
+
+
+
+ ) : (
+ counter !== undefined && (
+
+ {counter}
+
+ )
+ )}
+
+ )
+ },
+) as PolymorphicForwardRefComponent<'a', UnderlineTabProps>