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( + <> +

Select a tab

+ + , + ) + + 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>