From fe8e302053f980d97a4daf49e7c48e670e143a2d Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 6 Dec 2024 10:11:30 -0600 Subject: [PATCH 01/14] feat: add internal tabs component --- .../react/src/Tabs/Tabs.features.stories.tsx | 107 +++++++++ packages/react/src/Tabs/Tabs.stories.tsx | 22 ++ packages/react/src/Tabs/Tabs.test.tsx | 20 ++ packages/react/src/Tabs/Tabs.tsx | 224 ++++++++++++++++++ packages/react/src/Tabs/index.ts | 2 + 5 files changed, 375 insertions(+) create mode 100644 packages/react/src/Tabs/Tabs.features.stories.tsx create mode 100644 packages/react/src/Tabs/Tabs.stories.tsx create mode 100644 packages/react/src/Tabs/Tabs.test.tsx create mode 100644 packages/react/src/Tabs/Tabs.tsx create mode 100644 packages/react/src/Tabs/index.ts diff --git a/packages/react/src/Tabs/Tabs.features.stories.tsx b/packages/react/src/Tabs/Tabs.features.stories.tsx new file mode 100644 index 00000000000..65e32c4b4ea --- /dev/null +++ b/packages/react/src/Tabs/Tabs.features.stories.tsx @@ -0,0 +1,107 @@ +import type {Meta} from '@storybook/react' +import React from 'react' +import styled from 'styled-components' +import {get} from '../constants' +import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles' +import {Tabs, TabList, Tab, TabPanel} from '../Tabs' + +const meta = { + title: 'Private/Components/Tabs/Features', + component: Tabs, +} satisfies Meta + +export default meta + +export const Controlled = () => { + const [value, setValue] = React.useState('one') + return ( + <> + { + setValue(value) + }} + > + + One + Two + Three + + Panel one + Panel two + Panel three + + + + ) +} + +const TabPanels = styled(Tabs)`` + +const StyledTabList = styled(TabList)` + margin-top: 0; + margin-bottom: 16px; + border-bottom: 1px solid ${get('colors.border.default')}; +` + +const StyledTab = styled(Tab)` + padding: 8px 16px 9px 16px; + font-size: ${get('fontSizes.1')}; + line-height: 23px; + color: ${get('colors.fg.muted')}; + text-decoration: none; + background-color: transparent; + border: 1px solid transparent; + border-bottom: 0; + margin-bottom: -1px; + cursor: pointer; + + ${getGlobalFocusStyles('-6px')}; + + &:hover, + &:focus { + color: ${get('colors.fg.default')}; + text-decoration: none; + } + + &:hover { + transition-duration: 0.1s; + transition-property: color; + } + + &[aria-selected='true'] { + 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')}; + } +` + +const StyledTabPanel = styled(TabPanel)`` + +export const TabPanelsStory = { + name: 'TabPanels', + render: () => { + return ( + + + One + Two + Three + + Panel one + Panel two + Panel three + + ) + }, +} diff --git a/packages/react/src/Tabs/Tabs.stories.tsx b/packages/react/src/Tabs/Tabs.stories.tsx new file mode 100644 index 00000000000..49c0752a0e4 --- /dev/null +++ b/packages/react/src/Tabs/Tabs.stories.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {Tabs, TabList, Tab, TabPanel} from '../Tabs' + +export default { + title: 'Private/Components/Tabs', + component: Tabs, +} + +export const Default = () => { + return ( + + + One + Two + Three + + Panel one + Panel two + Panel three + + ) +} diff --git a/packages/react/src/Tabs/Tabs.test.tsx b/packages/react/src/Tabs/Tabs.test.tsx new file mode 100644 index 00000000000..2c017bc8c17 --- /dev/null +++ b/packages/react/src/Tabs/Tabs.test.tsx @@ -0,0 +1,20 @@ +import {render, screen} from '@testing-library/react' +import React from 'react' +import {Tabs, TabList, Tab, TabPanel} from '../Tabs' + +describe('Tabs', () => { + test('defaultValue', () => { + render( + + + a + b + c + + Panel a + Panel b + Panel c + , + ) + }) +}) diff --git a/packages/react/src/Tabs/Tabs.tsx b/packages/react/src/Tabs/Tabs.tsx new file mode 100644 index 00000000000..0175105d3c7 --- /dev/null +++ b/packages/react/src/Tabs/Tabs.tsx @@ -0,0 +1,224 @@ +import React, {createContext, useContext, useId, useMemo, useRef, type ElementRef} from 'react' +import {useEffectCallback} from '../internal/hooks/useEffectCallback' + +type TabsProps = React.PropsWithChildren<{ + /** + * Specify the selected tab + */ + value?: string + + /** + * Specify the default selected tab + */ + defaultValue: string + + /** + * Provide an optional callback that is called when the selected tab changes + */ + onValueChange?: ({value}: {value: string}) => void +}> + +function Tabs({children, defaultValue, onValueChange}: TabsProps) { + const groupId = useId() + const [selectedValue, setSelectedValue] = React.useState(defaultValue) + const savedOnValueChange = useEffectCallback(value => { + onValueChange?.({value}) + }) + const value: TabsContextValue = useMemo(() => { + return { + groupId, + selectedValue, + selectTab(value: string) { + setSelectedValue(value) + savedOnValueChange(value) + }, + } + }, [selectedValue, savedOnValueChange]) + + return {children} +} + +type Label = { + 'aria-label': string +} + +type LabelledBy = { + 'aria-labelledby': string +} + +type Labelled = Label | LabelledBy + +type TabListProps = Labelled & React.ComponentPropsWithoutRef<'div'> + +function TabList({'aria-label': label, 'aria-labelledby': labelledby, children}: TabListProps) { + const ref = useRef>(null) + + function onKeyDown(event: React.KeyboardEvent) { + const {current: tablist} = ref + if (tablist === null) { + return + } + + if (event.key === 'ArrowRight') { + event.preventDefault() + + const tabs = getFocusableTabs(tablist) + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (selectedTabIndex + 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + + const tabs = getFocusableTabs(tablist) + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (tabs.length + selectedTabIndex - 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === 'Home') { + event.preventDefault() + const tabs = getFocusableTabs(tablist) + if (tabs[0]) { + tabs[0].focus() + } + } else if (event.key === 'End') { + event.preventDefault() + const tabs = getFocusableTabs(tablist) + if (tabs.length > 0) { + tabs[tabs.length - 1].focus() + } + } + } + + return ( +
+ {children} +
+ ) +} + +function getFocusableTabs(tablist: HTMLElement): Array { + return Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')) +} + +type TabProps = React.ComponentPropsWithoutRef<'button'> & { + /** + * Specify whether the tab is disabled + */ + disabled?: boolean + + /** + * Provide a value that uniquely identities the tab. This should mirror the + * value provided to the corresponding TabPanel + */ + value: string +} + +const Tab = React.forwardRef, TabProps>(function Tab(props, forwardRef) { + const {children, disabled, value, ...rest} = props + const tabs = useTabs() + const selected = tabs.selectedValue === value + const id = `${tabs.groupId}-tab-${value}` + const panelId = `${tabs.groupId}-panel-${value}` + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === ' ' || event.key === 'Enter') { + tabs.selectTab(value) + } + } + + function onMouseDown(event: React.MouseEvent) { + if (!disabled && event.button === 0 && event.ctrlKey === false) { + tabs.selectTab(value) + } else { + event.preventDefault() + } + } + + function onFocus() { + if (!selected && !disabled) { + tabs.selectTab(value) + } + } + + return ( + + ) +}) + +type TabPanelProps = React.ComponentPropsWithoutRef<'div'> & { + /** + * Provide a value that uniquely identities the tab panel. This should mirror + * the value set for the corresponding tab + */ + value: string +} + +function TabPanel({children, value, ...rest}: TabPanelProps) { + const tabs = useTabs() + const id = `${tabs.groupId}-panel-${value}` + const tabId = `${tabs.groupId}-tab-${value}` + + return ( +
+ {children} +
+ ) +} + +type TabsContextValue = { + groupId: string + selectedValue: string + selectTab(value: string): void +} + +const TabsContext = createContext(null) + +function useTabs(): TabsContextValue { + const context = useContext(TabsContext) + if (context) { + return context + } + throw new Error('Component must be used within a component') +} + +type Handler = (event: E) => void + +function composeEventHandlers(...handlers: Array | null | undefined>) { + return function handleEvent(event: E) { + for (const handler of handlers) { + handler?.(event) + if ((event as unknown as Event).defaultPrevented) { + break + } + } + } +} + +export {Tabs, TabList, Tab, TabPanel} +export type {TabsProps, TabListProps, TabProps, TabPanelProps} diff --git a/packages/react/src/Tabs/index.ts b/packages/react/src/Tabs/index.ts new file mode 100644 index 00000000000..61fef8fb387 --- /dev/null +++ b/packages/react/src/Tabs/index.ts @@ -0,0 +1,2 @@ +export {Tabs, TabList, Tab, TabPanel} from './Tabs' +export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' From 39ef7ff920b38e048abe15cd0713ffdeb4ec62f5 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Thu, 10 Apr 2025 15:57:44 -0500 Subject: [PATCH 02/14] chore: check-in work --- packages/react/src/Tabs/Tabs.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/react/src/Tabs/Tabs.tsx b/packages/react/src/Tabs/Tabs.tsx index 0175105d3c7..8343496961f 100644 --- a/packages/react/src/Tabs/Tabs.tsx +++ b/packages/react/src/Tabs/Tabs.tsx @@ -1,7 +1,7 @@ import React, {createContext, useContext, useId, useMemo, useRef, type ElementRef} from 'react' -import {useEffectCallback} from '../internal/hooks/useEffectCallback' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' -type TabsProps = React.PropsWithChildren<{ +type TabsProps = React.HTMLAttributes & { /** * Specify the selected tab */ @@ -16,24 +16,26 @@ type TabsProps = React.PropsWithChildren<{ * Provide an optional callback that is called when the selected tab changes */ onValueChange?: ({value}: {value: string}) => void -}> +} function Tabs({children, defaultValue, onValueChange}: TabsProps) { const groupId = useId() const [selectedValue, setSelectedValue] = React.useState(defaultValue) - const savedOnValueChange = useEffectCallback(value => { - onValueChange?.({value}) - }) + const savedOnValueChange = React.useRef(onValueChange) const value: TabsContextValue = useMemo(() => { return { groupId, selectedValue, selectTab(value: string) { setSelectedValue(value) - savedOnValueChange(value) + savedOnValueChange.current?.({value}) }, } - }, [selectedValue, savedOnValueChange]) + }, [selectedValue]) + + useIsomorphicLayoutEffect(() => { + savedOnValueChange.current = onValueChange + }, [onValueChange]) return {children} } @@ -47,8 +49,7 @@ type LabelledBy = { } type Labelled = Label | LabelledBy - -type TabListProps = Labelled & React.ComponentPropsWithoutRef<'div'> +type TabListProps = Labelled & React.HTMLAttributes function TabList({'aria-label': label, 'aria-labelledby': labelledby, children}: TabListProps) { const ref = useRef>(null) @@ -171,7 +172,7 @@ const Tab = React.forwardRef, TabProps>(function Tab(props, ) }) -type TabPanelProps = React.ComponentPropsWithoutRef<'div'> & { +type TabPanelProps = React.HTMLAttributes & { /** * Provide a value that uniquely identities the tab panel. This should mirror * the value set for the corresponding tab From 5afd880f8adf64ec1003dee9fdd4f7c78905ce41 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 30 May 2025 16:06:31 -0500 Subject: [PATCH 03/14] chore: update tabs implementation --- .../react/src/Tabs/Tabs.features.stories.tsx | 65 ------------------- packages/react/src/Tabs/Tabs.tsx | 12 +++- 2 files changed, 9 insertions(+), 68 deletions(-) diff --git a/packages/react/src/Tabs/Tabs.features.stories.tsx b/packages/react/src/Tabs/Tabs.features.stories.tsx index 65e32c4b4ea..70a0a68a5b5 100644 --- a/packages/react/src/Tabs/Tabs.features.stories.tsx +++ b/packages/react/src/Tabs/Tabs.features.stories.tsx @@ -1,8 +1,5 @@ import type {Meta} from '@storybook/react' import React from 'react' -import styled from 'styled-components' -import {get} from '../constants' -import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles' import {Tabs, TabList, Tab, TabPanel} from '../Tabs' const meta = { @@ -43,65 +40,3 @@ export const Controlled = () => { ) } - -const TabPanels = styled(Tabs)`` - -const StyledTabList = styled(TabList)` - margin-top: 0; - margin-bottom: 16px; - border-bottom: 1px solid ${get('colors.border.default')}; -` - -const StyledTab = styled(Tab)` - padding: 8px 16px 9px 16px; - font-size: ${get('fontSizes.1')}; - line-height: 23px; - color: ${get('colors.fg.muted')}; - text-decoration: none; - background-color: transparent; - border: 1px solid transparent; - border-bottom: 0; - margin-bottom: -1px; - cursor: pointer; - - ${getGlobalFocusStyles('-6px')}; - - &:hover, - &:focus { - color: ${get('colors.fg.default')}; - text-decoration: none; - } - - &:hover { - transition-duration: 0.1s; - transition-property: color; - } - - &[aria-selected='true'] { - 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')}; - } -` - -const StyledTabPanel = styled(TabPanel)`` - -export const TabPanelsStory = { - name: 'TabPanels', - render: () => { - return ( - - - One - Two - Three - - Panel one - Panel two - Panel three - - ) - }, -} diff --git a/packages/react/src/Tabs/Tabs.tsx b/packages/react/src/Tabs/Tabs.tsx index 8343496961f..728edeceaba 100644 --- a/packages/react/src/Tabs/Tabs.tsx +++ b/packages/react/src/Tabs/Tabs.tsx @@ -51,7 +51,7 @@ type LabelledBy = { type Labelled = Label | LabelledBy type TabListProps = Labelled & React.HTMLAttributes -function TabList({'aria-label': label, 'aria-labelledby': labelledby, children}: TabListProps) { +function TabList({'aria-label': label, 'aria-labelledby': labelledby, children, ...rest}: TabListProps) { const ref = useRef>(null) function onKeyDown(event: React.KeyboardEvent) { @@ -102,7 +102,7 @@ function TabList({'aria-label': label, 'aria-labelledby': labelledby, children}: } return ( -
+
{children}
) @@ -186,7 +186,13 @@ function TabPanel({children, value, ...rest}: TabPanelProps) { const tabId = `${tabs.groupId}-tab-${value}` return ( -
+
{children}
) From 85cd1f7fa4497c2d812985a5ae5c11403fd774a8 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Mon, 3 Nov 2025 11:04:03 -0600 Subject: [PATCH 04/14] test: scaffold initial test suite --- packages/react/src/Tabs/Tabs.test.tsx | 541 +++++++++++++++++++++++++- 1 file changed, 533 insertions(+), 8 deletions(-) diff --git a/packages/react/src/Tabs/Tabs.test.tsx b/packages/react/src/Tabs/Tabs.test.tsx index 2c017bc8c17..ffa1ea3bbc9 100644 --- a/packages/react/src/Tabs/Tabs.test.tsx +++ b/packages/react/src/Tabs/Tabs.test.tsx @@ -1,20 +1,545 @@ import {render, screen} from '@testing-library/react' +import {userEvent} from '@vitest/browser/context' import React from 'react' +import {describe, test, expect, vi} from 'vitest' import {Tabs, TabList, Tab, TabPanel} from '../Tabs' describe('Tabs', () => { - test('defaultValue', () => { + test('`defaultValue` sets the default selected tab', () => { render( - - a - b - c + + Tab A + Tab B + Tab C - Panel a - Panel b - Panel c + Panel A + Panel B + Panel C , ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + expect(tabA).toHaveAttribute('aria-selected', 'true') + expect(tabB).toHaveAttribute('aria-selected', 'false') + expect(tabC).toHaveAttribute('aria-selected', 'false') + }) + + test('`value` prop controls the selected tab when provided', async () => { + function Wrapper() { + const [value, setValue] = React.useState('a') + return ( + setValue(value)}> + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + + ) + } + + render() + }) + + test('onValueChange is called when tab changes', async () => { + const user = userEvent.setup() + const onValueChange = vi.fn() + + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + await user.click(tabB) + + expect(onValueChange).toHaveBeenCalledWith({value: 'b'}) + expect(onValueChange).toHaveBeenCalledTimes(1) + }) + + describe('TabList', () => { + test('renders with role="tablist"', () => { + render( + + + Tab A + + Panel A + , + ) + + const tablist = screen.getByRole('tablist') + expect(tablist).toBeInTheDocument() + expect(tablist).toHaveAttribute('aria-label', 'Test tabs') + }) + + test('supports labeling with aria-label', () => { + render( +
+ + + Tab A + + Panel A + +
, + ) + + expect( + screen.getByRole('tablist', { + name: 'test', + }), + ).toBeInTheDocument() + }) + + test('supports labeling with aria-labelledby', () => { + render( +
+ test + + + Tab A + + Panel A + +
, + ) + + expect( + screen.getByRole('tablist', { + name: 'test', + }), + ).toBeInTheDocument() + }) + }) + + describe('Tab', () => { + test('renders with default tabIndex based on selection', () => { + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + expect(tabA).toHaveAttribute('tabindex', '0') + expect(tabB).toHaveAttribute('tabindex', '-1') + }) + + test('sets `aria-disabled` to true when `disabled`', () => { + render( + + + Tab A + + Tab B + + + Panel A + Panel B + , + ) + + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + expect(tabB).toHaveAttribute('aria-disabled', 'true') + }) + }) + + describe('TabPanel', () => { + test('renders with role="tabpanel"', () => { + render( + + + Tab A + + Panel A + , + ) + + const panel = screen.getByRole('tabpanel') + expect(panel).toBeInTheDocument() + expect(panel).toHaveTextContent('Panel A') + }) + }) + + test('Tab and TabPanel are properly associated with aria attributes', () => { + render( + + + Tab A + + Panel A + , + ) + + const tab = screen.getByRole('tab', {name: 'Tab A'}) + const panel = screen.getByRole('tabpanel') + + const tabId = tab.getAttribute('id') + const panelId = panel.getAttribute('id') + const tabControls = tab.getAttribute('aria-controls') + const panelLabelledBy = panel.getAttribute('aria-labelledby') + + expect(tabId).toBeTruthy() + expect(panelId).toBeTruthy() + expect(tabControls).toBe(panelId) + expect(panelLabelledBy).toBe(tabId) + }) + + test('TabPanel has data-selected attribute when selected', () => { + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const panelA = screen.getByText('Panel A') + const panelB = screen.getByText('Panel B') + + expect(panelA).toHaveAttribute('data-selected', '') + expect(panelB).not.toHaveAttribute('data-selected') + }) + + test('clicking a tab selects it', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + await user.click(tabB) + + expect(tabB).toHaveAttribute('aria-selected', 'true') + expect(tabB).toHaveAttribute('tabindex', '0') + }) + + test('ArrowRight navigates to next tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + tabA.focus() + await user.keyboard('{ArrowRight}') + + expect(tabB).toHaveFocus() + }) + + test('ArrowRight wraps from last tab to first tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + tabC.focus() + await user.keyboard('{ArrowRight}') + + expect(tabA).toHaveFocus() + }) + + test('ArrowLeft navigates to previous tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + tabB.focus() + await user.keyboard('{ArrowLeft}') + + expect(tabA).toHaveFocus() + }) + + test('ArrowLeft wraps from first tab to last tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + tabA.focus() + await user.keyboard('{ArrowLeft}') + + expect(tabC).toHaveFocus() + }) + + test('Home key navigates to first tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + tabC.focus() + await user.keyboard('{Home}') + + expect(tabA).toHaveFocus() + }) + + test('End key navigates to last tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + tabA.focus() + await user.keyboard('{End}') + + expect(tabC).toHaveFocus() + }) + + test('Space key activates focused tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + tabA.focus() + await user.keyboard('{ArrowRight}') + await user.keyboard(' ') + + expect(tabB).toHaveAttribute('aria-selected', 'true') + }) + + test('Enter key activates focused tab', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + tabA.focus() + await user.keyboard('{ArrowRight}') + await user.keyboard('{Enter}') + + expect(tabB).toHaveAttribute('aria-selected', 'true') + }) + + test('focusing a non-selected tab selects it', async () => { + const user = userEvent.setup() + + render( + + + Tab A + Tab B + + Panel A + Panel B + , + ) + + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + await user.tab() + await user.tab() + + expect(tabB).toHaveAttribute('aria-selected', 'true') + }) + + test('disabled tabs are skipped during keyboard navigation', async () => { + const user = userEvent.setup() + + render( + + + Tab A + + Tab B + + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + tabA.focus() + await user.keyboard('{ArrowRight}') + + expect(tabC).toHaveFocus() + }) + + test('clicking disabled tab does not select it', async () => { + const user = userEvent.setup() + + render( + + + Tab A + + Tab B + + + Panel A + Panel B + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + + await user.click(tabB) + + expect(tabA).toHaveAttribute('aria-selected', 'true') + expect(tabB).toHaveAttribute('aria-selected', 'false') + }) + + test('only selected tab is included in tab sequence', () => { + render( + + + Tab A + Tab B + Tab C + + Panel A + Panel B + Panel C + , + ) + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + const tabB = screen.getByRole('tab', {name: 'Tab B'}) + const tabC = screen.getByRole('tab', {name: 'Tab C'}) + + expect(tabA).toHaveAttribute('tabindex', '-1') + expect(tabB).toHaveAttribute('tabindex', '0') + expect(tabC).toHaveAttribute('tabindex', '-1') }) }) From df67e7213916cbe8572d9d20465c25911b2622ce Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Mon, 3 Nov 2025 22:41:50 +0000 Subject: [PATCH 05/14] Add utils for custom components --- packages/react/src/Tabs/Tabs.tsx | 231 ------------- packages/react/src/Tabs/index.ts | 2 - .../components/Tabs/Tabs.examples.stories.tsx | 80 +++++ .../Tabs/Tabs.features.stories.tsx | 6 +- .../components}/Tabs/Tabs.stories.tsx | 9 +- .../components}/Tabs/Tabs.test.tsx | 11 +- .../src/internal/components/Tabs/Tabs.tsx | 303 ++++++++++++++++++ .../src/internal/components/Tabs/index.ts | 2 + 8 files changed, 402 insertions(+), 242 deletions(-) delete mode 100644 packages/react/src/Tabs/Tabs.tsx delete mode 100644 packages/react/src/Tabs/index.ts create mode 100644 packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx rename packages/react/src/{ => internal/components}/Tabs/Tabs.features.stories.tsx (83%) rename packages/react/src/{ => internal/components}/Tabs/Tabs.stories.tsx (75%) rename packages/react/src/{ => internal/components}/Tabs/Tabs.test.tsx (98%) create mode 100644 packages/react/src/internal/components/Tabs/Tabs.tsx create mode 100644 packages/react/src/internal/components/Tabs/index.ts diff --git a/packages/react/src/Tabs/Tabs.tsx b/packages/react/src/Tabs/Tabs.tsx deleted file mode 100644 index 728edeceaba..00000000000 --- a/packages/react/src/Tabs/Tabs.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import React, {createContext, useContext, useId, useMemo, useRef, type ElementRef} from 'react' -import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' - -type TabsProps = React.HTMLAttributes & { - /** - * Specify the selected tab - */ - value?: string - - /** - * Specify the default selected tab - */ - defaultValue: string - - /** - * Provide an optional callback that is called when the selected tab changes - */ - onValueChange?: ({value}: {value: string}) => void -} - -function Tabs({children, defaultValue, onValueChange}: TabsProps) { - const groupId = useId() - const [selectedValue, setSelectedValue] = React.useState(defaultValue) - const savedOnValueChange = React.useRef(onValueChange) - const value: TabsContextValue = useMemo(() => { - return { - groupId, - selectedValue, - selectTab(value: string) { - setSelectedValue(value) - savedOnValueChange.current?.({value}) - }, - } - }, [selectedValue]) - - useIsomorphicLayoutEffect(() => { - savedOnValueChange.current = onValueChange - }, [onValueChange]) - - return {children} -} - -type Label = { - 'aria-label': string -} - -type LabelledBy = { - 'aria-labelledby': string -} - -type Labelled = Label | LabelledBy -type TabListProps = Labelled & React.HTMLAttributes - -function TabList({'aria-label': label, 'aria-labelledby': labelledby, children, ...rest}: TabListProps) { - const ref = useRef>(null) - - function onKeyDown(event: React.KeyboardEvent) { - const {current: tablist} = ref - if (tablist === null) { - return - } - - if (event.key === 'ArrowRight') { - event.preventDefault() - - const tabs = getFocusableTabs(tablist) - const selectedTabIndex = tabs.findIndex(tab => { - return tab.getAttribute('aria-selected') === 'true' - }) - if (selectedTabIndex === -1) { - return - } - - const nextTabIndex = (selectedTabIndex + 1) % tabs.length - tabs[nextTabIndex].focus() - } else if (event.key === 'ArrowLeft') { - event.preventDefault() - - const tabs = getFocusableTabs(tablist) - const selectedTabIndex = tabs.findIndex(tab => { - return tab.getAttribute('aria-selected') === 'true' - }) - if (selectedTabIndex === -1) { - return - } - - const nextTabIndex = (tabs.length + selectedTabIndex - 1) % tabs.length - tabs[nextTabIndex].focus() - } else if (event.key === 'Home') { - event.preventDefault() - const tabs = getFocusableTabs(tablist) - if (tabs[0]) { - tabs[0].focus() - } - } else if (event.key === 'End') { - event.preventDefault() - const tabs = getFocusableTabs(tablist) - if (tabs.length > 0) { - tabs[tabs.length - 1].focus() - } - } - } - - return ( -
- {children} -
- ) -} - -function getFocusableTabs(tablist: HTMLElement): Array { - return Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')) -} - -type TabProps = React.ComponentPropsWithoutRef<'button'> & { - /** - * Specify whether the tab is disabled - */ - disabled?: boolean - - /** - * Provide a value that uniquely identities the tab. This should mirror the - * value provided to the corresponding TabPanel - */ - value: string -} - -const Tab = React.forwardRef, TabProps>(function Tab(props, forwardRef) { - const {children, disabled, value, ...rest} = props - const tabs = useTabs() - const selected = tabs.selectedValue === value - const id = `${tabs.groupId}-tab-${value}` - const panelId = `${tabs.groupId}-panel-${value}` - - function onKeyDown(event: React.KeyboardEvent) { - if (event.key === ' ' || event.key === 'Enter') { - tabs.selectTab(value) - } - } - - function onMouseDown(event: React.MouseEvent) { - if (!disabled && event.button === 0 && event.ctrlKey === false) { - tabs.selectTab(value) - } else { - event.preventDefault() - } - } - - function onFocus() { - if (!selected && !disabled) { - tabs.selectTab(value) - } - } - - return ( - - ) -}) - -type TabPanelProps = React.HTMLAttributes & { - /** - * Provide a value that uniquely identities the tab panel. This should mirror - * the value set for the corresponding tab - */ - value: string -} - -function TabPanel({children, value, ...rest}: TabPanelProps) { - const tabs = useTabs() - const id = `${tabs.groupId}-panel-${value}` - const tabId = `${tabs.groupId}-tab-${value}` - - return ( -
- {children} -
- ) -} - -type TabsContextValue = { - groupId: string - selectedValue: string - selectTab(value: string): void -} - -const TabsContext = createContext(null) - -function useTabs(): TabsContextValue { - const context = useContext(TabsContext) - if (context) { - return context - } - throw new Error('Component must be used within a component') -} - -type Handler = (event: E) => void - -function composeEventHandlers(...handlers: Array | null | undefined>) { - return function handleEvent(event: E) { - for (const handler of handlers) { - handler?.(event) - if ((event as unknown as Event).defaultPrevented) { - break - } - } - } -} - -export {Tabs, TabList, Tab, TabPanel} -export type {TabsProps, TabListProps, TabProps, TabPanelProps} diff --git a/packages/react/src/Tabs/index.ts b/packages/react/src/Tabs/index.ts deleted file mode 100644 index 61fef8fb387..00000000000 --- a/packages/react/src/Tabs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {Tabs, TabList, Tab, TabPanel} from './Tabs' -export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' diff --git a/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx b/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx new file mode 100644 index 00000000000..026c3dec7fb --- /dev/null +++ b/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx @@ -0,0 +1,80 @@ +import type {Meta} from '@storybook/react-vite' +import {action} from 'storybook/actions' +import React from 'react' +import {Tabs, TabPanel, useTabList, useTab} from '.' +import {ActionList} from '../../../ActionList' + +const meta = { + title: 'Private/Components/Tabs/Examples', + component: Tabs, +} satisfies Meta + +export default meta + +const CustomTabList = (props: React.PropsWithChildren) => { + const tabListProps = useTabList({'aria-label': 'Tabs', 'aria-orientation': 'vertical'}) + + return ( +
+ {props.children} +
+ ) +} + +const CustomTab = (props: React.PropsWithChildren<{value: string}>) => { + const tabProps = useTab({value: props.value}) + + return ( + + {props.children} + + ) +} + +export const WithCustomComponents = () => { + const [value, setValue] = React.useState('one') + return ( + <> +

+ This example shows how to use the `Tabs` component with custom Components for the TabList and Tabs. Here we are + using `ActionList` and `ActionList.Item` +

+

+ The direction is also set to `vertical` to demonstrate the `aria-orientation` prop handling. Which also changes + the keyboard navigation to Up/Down arrows. +

+
+ { + action('onValueChange')({value}) + setValue(value) + }} + > + + One + Two + Three + + Panel one + Panel two + Panel three + +
+ + + ) +} diff --git a/packages/react/src/Tabs/Tabs.features.stories.tsx b/packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx similarity index 83% rename from packages/react/src/Tabs/Tabs.features.stories.tsx rename to packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx index 70a0a68a5b5..467a19cbad2 100644 --- a/packages/react/src/Tabs/Tabs.features.stories.tsx +++ b/packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx @@ -1,6 +1,7 @@ -import type {Meta} from '@storybook/react' +import type {Meta} from '@storybook/react-vite' +import {action} from 'storybook/actions' import React from 'react' -import {Tabs, TabList, Tab, TabPanel} from '../Tabs' +import {Tabs, TabList, Tab, TabPanel} from '.' const meta = { title: 'Private/Components/Tabs/Features', @@ -17,6 +18,7 @@ export const Controlled = () => { defaultValue="one" value={value} onValueChange={({value}) => { + action('onValueChange')({value}) setValue(value) }} > diff --git a/packages/react/src/Tabs/Tabs.stories.tsx b/packages/react/src/internal/components/Tabs/Tabs.stories.tsx similarity index 75% rename from packages/react/src/Tabs/Tabs.stories.tsx rename to packages/react/src/internal/components/Tabs/Tabs.stories.tsx index 49c0752a0e4..9b50360ad21 100644 --- a/packages/react/src/Tabs/Tabs.stories.tsx +++ b/packages/react/src/internal/components/Tabs/Tabs.stories.tsx @@ -1,10 +1,13 @@ +import type {Meta} from '@storybook/react-vite' import React from 'react' -import {Tabs, TabList, Tab, TabPanel} from '../Tabs' +import {Tabs, TabList, Tab, TabPanel} from '.' -export default { +const meta = { title: 'Private/Components/Tabs', component: Tabs, -} +} satisfies Meta + +export default meta export const Default = () => { return ( diff --git a/packages/react/src/Tabs/Tabs.test.tsx b/packages/react/src/internal/components/Tabs/Tabs.test.tsx similarity index 98% rename from packages/react/src/Tabs/Tabs.test.tsx rename to packages/react/src/internal/components/Tabs/Tabs.test.tsx index ffa1ea3bbc9..82908463fa1 100644 --- a/packages/react/src/Tabs/Tabs.test.tsx +++ b/packages/react/src/internal/components/Tabs/Tabs.test.tsx @@ -1,8 +1,8 @@ import {render, screen} from '@testing-library/react' -import {userEvent} from '@vitest/browser/context' +import {userEvent} from 'vitest/browser' import React from 'react' import {describe, test, expect, vi} from 'vitest' -import {Tabs, TabList, Tab, TabPanel} from '../Tabs' +import {Tabs, TabList, Tab, TabPanel} from '.' describe('Tabs', () => { test('`defaultValue` sets the default selected tab', () => { @@ -33,7 +33,7 @@ describe('Tabs', () => { const [value, setValue] = React.useState('a') return ( setValue(value)}> - + Tab A Tab B Tab C @@ -46,6 +46,9 @@ describe('Tabs', () => { } render() + + const tabA = screen.getByRole('tab', {name: 'Tab A'}) + expect(tabA).toHaveAttribute('aria-selected', 'true') }) test('onValueChange is called when tab changes', async () => { @@ -90,7 +93,7 @@ describe('Tabs', () => { render(
- + Tab A Panel A diff --git a/packages/react/src/internal/components/Tabs/Tabs.tsx b/packages/react/src/internal/components/Tabs/Tabs.tsx new file mode 100644 index 00000000000..f0e923fbefc --- /dev/null +++ b/packages/react/src/internal/components/Tabs/Tabs.tsx @@ -0,0 +1,303 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + type ElementRef, + type PropsWithChildren, +} from 'react' +import useIsomorphicLayoutEffect from '../../../utils/useIsomorphicLayoutEffect' + +type TabsProps = PropsWithChildren<{ + /** + * Specify the selected tab + */ + value?: string + + /** + * Specify the default selected tab + */ + defaultValue: string + + /** + * Provide an optional callback that is called when the selected tab changes + */ + onValueChange?: ({value}: {value: string}) => void +}> + +function Tabs({children, defaultValue, onValueChange, value}: TabsProps) { + const groupId = useId() + const [selectedValue, setSelectedValue] = React.useState(defaultValue) + const savedOnValueChange = React.useRef(onValueChange) + const contextValue: TabsContextValue = useMemo(() => { + return { + groupId, + selectedValue, + selectTab(value: string) { + setSelectedValue(value) + savedOnValueChange.current?.({value}) + }, + } + }, [groupId, selectedValue]) + + useIsomorphicLayoutEffect(() => { + savedOnValueChange.current = onValueChange + }, [onValueChange]) + + useEffect(() => { + if (value !== undefined) { + setSelectedValue(value) + } + }, [value]) + + return {children} +} + +type Label = { + 'aria-label': string +} + +type LabelledBy = { + 'aria-labelledby': string +} + +type Labelled = Label | LabelledBy +type TabListProps = Labelled & React.HTMLAttributes + +export function useTabList( + props: TabListProps, +): Pick, 'aria-label' | 'aria-labelledby'> & + Required, 'aria-orientation' | 'onKeyDown'>> & { + ref: React.RefObject + role: 'tablist' + } { + const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props + + const ref = useRef(null) + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const {current: tablist} = ref + if (tablist === null) { + return + } + + const isVertical = ariaOrientation === 'vertical' + + if ((!isVertical && event.key === 'ArrowRight') || (isVertical && event.key === 'ArrowDown')) { + event.preventDefault() + + const tabs = getFocusableTabs(tablist) + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (selectedTabIndex + 1) % tabs.length + tabs[nextTabIndex].focus() + } else if ((!isVertical && event.key === 'ArrowLeft') || (isVertical && event.key === 'ArrowUp')) { + event.preventDefault() + + const tabs = getFocusableTabs(tablist) + const selectedTabIndex = tabs.findIndex(tab => { + return tab.getAttribute('aria-selected') === 'true' + }) + if (selectedTabIndex === -1) { + return + } + + const nextTabIndex = (tabs.length + selectedTabIndex - 1) % tabs.length + tabs[nextTabIndex].focus() + } else if (event.key === 'Home') { + event.preventDefault() + const tabs = getFocusableTabs(tablist) + if (tabs[0]) { + tabs[0].focus() + } + } else if (event.key === 'End') { + event.preventDefault() + const tabs = getFocusableTabs(tablist) + if (tabs.length > 0) { + tabs[tabs.length - 1].focus() + } + } + }, + [ref, ariaOrientation], + ) + + return { + ref, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-orientation': ariaOrientation ?? 'horizontal', + role: 'tablist', + onKeyDown, + } +} + +function TabList({children, ...rest}: TabListProps) { + const tabListProps = useTabList(rest) + + return ( +
+ {children} +
+ ) +} + +function getFocusableTabs(tablist: HTMLElement): Array { + return Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')) +} + +type TabProps = React.ComponentPropsWithoutRef<'button'> & { + /** + * Specify whether the tab is disabled + */ + disabled?: boolean + + /** + * Provide a value that uniquely identities the tab. This should mirror the + * value provided to the corresponding TabPanel + */ + value: string +} + +/** + * A custom hook that provides the props needed for a tab component. + * The props returned should be spread onto the component (typically a button) with the `role=tab`, under a `tablist`. + * @param props The props for the tab component. + * @returns The props needed for the tab component. + */ +export function useTab(props: Pick): Pick< + React.HTMLProps, + 'aria-controls' | 'aria-disabled' | 'aria-selected' | 'id' | 'tabIndex' | 'onKeyDown' | 'onMouseDown' | 'onFocus' +> & { + role: 'tab' +} { + const {disabled, value} = props + const tabs = useTabs() + const selected = tabs.selectedValue === value + const id = `${tabs.groupId}-tab-${value}` + const panelId = `${tabs.groupId}-panel-${value}` + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === ' ' || event.key === 'Enter') { + tabs.selectTab(value) + } + } + + function onMouseDown(event: React.MouseEvent) { + if (!disabled && event.button === 0 && event.ctrlKey === false) { + tabs.selectTab(value) + } else { + event.preventDefault() + } + } + + function onFocus() { + if (!selected && !disabled) { + tabs.selectTab(value) + } + } + + return { + 'aria-controls': panelId, + 'aria-disabled': disabled, + 'aria-selected': selected, + onKeyDown, + onMouseDown, + onFocus, + id, + role: 'tab', + tabIndex: selected ? 0 : -1, + } +} + +const Tab = React.forwardRef, TabProps>(function Tab(props, forwardRef) { + const {children, disabled, value, ...rest} = props + const tabProps = useTab({disabled, value}) + + return ( + + ) +}) + +type TabPanelProps = React.HTMLAttributes & { + /** + * Provide a value that uniquely identities the tab panel. This should mirror + * the value set for the corresponding tab + */ + value: string +} + +export function useTabPanel(props: Pick) { + const {value} = props + const tabs = useTabs() + const id = `${tabs.groupId}-panel-${value}` + const tabId = `${tabs.groupId}-tab-${value}` + + return { + 'aria-labelledby': tabId, + 'data-selected': tabs.selectedValue === value ? '' : undefined, + id, + hidden: tabs.selectedValue !== value, + role: 'tabpanel', + } +} + +function TabPanel({children, value, ...rest}: TabPanelProps) { + const tabPanelProps = useTabPanel({value}) + + return ( +
+ {children} +
+ ) +} + +type TabsContextValue = { + groupId: string + selectedValue: string + selectTab(value: string): void +} + +const TabsContext = createContext(null) + +function useTabs(): TabsContextValue { + const context = useContext(TabsContext) + if (context) { + return context + } + throw new Error('Component must be used within a component') +} + +type Handler = (event: E) => void + +function composeEventHandlers(...handlers: Array | null | undefined>) { + return function handleEvent(event: E) { + for (const handler of handlers) { + handler?.(event) + if ((event as unknown as Event).defaultPrevented) { + break + } + } + } +} + +export {Tabs, TabList, Tab, TabPanel} +export type {TabsProps, TabListProps, TabProps, TabPanelProps} diff --git a/packages/react/src/internal/components/Tabs/index.ts b/packages/react/src/internal/components/Tabs/index.ts new file mode 100644 index 00000000000..b8dcd2bc731 --- /dev/null +++ b/packages/react/src/internal/components/Tabs/index.ts @@ -0,0 +1,2 @@ +export {Tabs, TabList, Tab, TabPanel, useTab, useTabList, useTabPanel} from './Tabs' +export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' From 085d292fa35210324ac4be081968b1a2399067e6 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Mon, 3 Nov 2025 22:47:35 +0000 Subject: [PATCH 06/14] Add example of disabled tab --- .../internal/components/Tabs/Tabs.examples.stories.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx b/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx index 026c3dec7fb..62359a841a4 100644 --- a/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx +++ b/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx @@ -21,8 +21,8 @@ const CustomTabList = (props: React.PropsWithChildren) => { ) } -const CustomTab = (props: React.PropsWithChildren<{value: string}>) => { - const tabProps = useTab({value: props.value}) +const CustomTab = (props: React.PropsWithChildren<{value: string; disabled?: boolean}>) => { + const tabProps = useTab({value: props.value, disabled: props.disabled}) return ( @@ -61,10 +61,14 @@ export const WithCustomComponents = () => { One Two Three + + Four + Panel one Panel two Panel three + Panel four
+} + +function TabList({children, ...props}) { + const tabListProps = useTabList(props) + return
{children}
+} + +export function MyTabs() { + return ( + + + Tab 1 + Tab 2 + + +

This is the content for Tab 1.

+
+ +

This is the content for Tab 2.

+
+
+ ) +} +``` + +All styling and layout is left up to you! + +This approach provides maximum flexibility, allowing you to create tabbed interfaces that fit seamlessly into your application's design while leveraging the robust functionality provided by the `Tabs` component. + +### Example: `ActionList` + + diff --git a/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx similarity index 89% rename from packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx rename to packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx index 59456bd3081..df2d7a518e4 100644 --- a/packages/react/src/internal/components/Tabs/Tabs.examples.stories.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.examples.stories.tsx @@ -1,11 +1,12 @@ import type {Meta} from '@storybook/react-vite' import {action} from 'storybook/actions' import React from 'react' -import {Tabs, TabPanel, useTabList, useTab} from '.' -import {ActionList} from '../../../ActionList' +import {Tabs, TabPanel, useTabList, useTab} from './Tabs' +import {ActionList} from '../../ActionList' +import Flash from '../../Flash' const meta = { - title: 'Private/Components/Tabs/Examples', + title: 'Experimental/Components/Tabs/Examples', component: Tabs, } satisfies Meta @@ -35,14 +36,14 @@ export const WithCustomComponents = () => { const [value, setValue] = React.useState('one') return ( <> -

+ This example shows how to use the `Tabs` component with custom Components for the TabList and Tabs. Here we are using `ActionList` and `ActionList.Item` -

-

+
The direction is also set to `vertical` to demonstrate the `aria-orientation` prop handling. Which also changes the keyboard navigation to Up/Down arrows. -

+ +
{ }} > { action('onValueChange')({value}) diff --git a/packages/react/src/experimental/Tabs/Tabs.features.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.features.stories.tsx new file mode 100644 index 00000000000..3a196b7d3b7 --- /dev/null +++ b/packages/react/src/experimental/Tabs/Tabs.features.stories.tsx @@ -0,0 +1,100 @@ +import type {Meta} from '@storybook/react-vite' +import {action} from 'storybook/actions' +import React from 'react' +import {Tabs, TabList, Tab, TabPanel} from './Tabs' +import Flash from '../../Flash' + +const meta = { + title: 'Experimental/Components/Tabs/Features', + component: Tabs, +} satisfies Meta + +export default meta + +export const Uncontrolled = () => ( + { + action('onValueChange')({value}) + }} + > + + One + Two + Three + + Panel one + Panel two + Panel three + +) + +export const Controlled = () => { + const [value, setValue] = React.useState('one') + return ( + <> + { + action('onValueChange')({value}) + setValue(value) + }} + > + + One + Two + Three + + Panel one + Panel two + Panel three + + + + ) +} + +export const Vertical = () => ( + <> + + This example shows the `Tabs` component with `aria-orientation` set to `vertical`, which changes the keyboard + navigation to Up/Down arrows. + +
+ { + action('onValueChange')({value}) + }} + > + + One + Two + Three + + Panel one + Panel two + Panel three + +
+ +) diff --git a/packages/react/src/internal/components/Tabs/Tabs.stories.tsx b/packages/react/src/experimental/Tabs/Tabs.stories.tsx similarity index 85% rename from packages/react/src/internal/components/Tabs/Tabs.stories.tsx rename to packages/react/src/experimental/Tabs/Tabs.stories.tsx index 9b50360ad21..eb3f001e53b 100644 --- a/packages/react/src/internal/components/Tabs/Tabs.stories.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.stories.tsx @@ -1,9 +1,9 @@ import type {Meta} from '@storybook/react-vite' import React from 'react' -import {Tabs, TabList, Tab, TabPanel} from '.' +import {Tabs, TabList, Tab, TabPanel} from './Tabs' const meta = { - title: 'Private/Components/Tabs', + title: 'Experimental/Components/Tabs', component: Tabs, } satisfies Meta diff --git a/packages/react/src/internal/components/Tabs/Tabs.test.tsx b/packages/react/src/experimental/Tabs/Tabs.test.tsx similarity index 99% rename from packages/react/src/internal/components/Tabs/Tabs.test.tsx rename to packages/react/src/experimental/Tabs/Tabs.test.tsx index 1906937fa07..d5fb493df9e 100644 --- a/packages/react/src/internal/components/Tabs/Tabs.test.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.test.tsx @@ -2,7 +2,7 @@ import {render, screen, fireEvent, act} from '@testing-library/react' import {userEvent} from 'vitest/browser' import React from 'react' import {describe, test, expect, vi} from 'vitest' -import {Tabs, TabList, Tab, TabPanel} from '.' +import {Tabs, TabList, Tab, TabPanel} from './Tabs' describe('Tabs', () => { test('`defaultValue` sets the default selected tab', () => { @@ -32,7 +32,7 @@ describe('Tabs', () => { function Wrapper() { const [value, setValue] = React.useState('a') return ( - setValue(value)}> + setValue(value)}> Tab A Tab B diff --git a/packages/react/src/internal/components/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx similarity index 68% rename from packages/react/src/internal/components/Tabs/Tabs.tsx rename to packages/react/src/experimental/Tabs/Tabs.tsx index f04e0e1eed7..cc120f49ea8 100644 --- a/packages/react/src/internal/components/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -1,41 +1,75 @@ import React, { createContext, - useCallback, useContext, useId, useMemo, - useRef, + type AriaAttributes, type ElementRef, type PropsWithChildren, } from 'react' -import useIsomorphicLayoutEffect from '../../../utils/useIsomorphicLayoutEffect' -import {useControllableState} from '../../../hooks/useControllableState' -import {useProvidedRefOrCreate} from '../../../hooks' +import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' +import {useControllableState} from '../../hooks/useControllableState' +import {useProvidedRefOrCreate} from '../../hooks' -type TabsProps = PropsWithChildren<{ +/** + * Props to be used when the Tabs component's state is controlled by the parent + */ +type ControlledTabsProps = { /** * Specify the selected tab */ - value?: string + value: string + /** + * `defaultValue` can only be used in the uncontrolled variant of the component + * If you need to use `defaultValue`, please switch to the uncontrolled variant by removing the `value` prop. + */ + defaultValue?: never + + /** + * Provide an optional callback that is called when the selected tab changes + */ + onValueChange: ({value}: {value: string}) => void +} + +/** + * Props to be used when the Tabs component is managing its own state + */ +type UncontrolledTabsProps = { /** * Specify the default selected tab */ defaultValue: string + /** + * `value` can only be used in the controlled variant of the component + * If you need to use `value`, please switch to the controlled variant by removing the `defaultValue` prop. + */ + value?: never + /** * Provide an optional callback that is called when the selected tab changes */ onValueChange?: ({value}: {value: string}) => void -}> +} -function Tabs({children, defaultValue, onValueChange, value}: TabsProps) { +type TabsProps = PropsWithChildren + +/** + * The Tabs component provides the base structure for a tabbed interface, without providing any formal requirement on DOM structure or styling. + * It manages the state of the selected tab, handles tab ordering/selection and provides context to its child components to ensure an accessible experience. + * + * This is intended to be used in conjunction with the `useTab`, `useTabList`, and `useTabPanel` hooks to build a fully accessible tabs component. + * The `Tab`, `TabList`, and `TabPanel` components are provided for convenience to showcase the API & implementation. + */ +function Tabs(props: TabsProps) { + const {children, onValueChange} = props const groupId = useId() - const [selectedValue, setSelectedValue] = useControllableState({ + const [selectedValue, setSelectedValue] = useControllableState({ name: 'tab-selection', - defaultValue, - value, + defaultValue: props.defaultValue ?? props.value, + value: props.value, }) const savedOnValueChange = React.useRef(onValueChange) @@ -68,17 +102,21 @@ type LabelledBy = { type Labelled = Label | LabelledBy type TabListProps = Labelled & React.HTMLAttributes -export function useTabList( +function useTabList( props: TabListProps & { /** Optional ref to use for the tablist. If none if provided, one will be generated automatically */ ref?: React.RefObject }, ): { - tabListProps: Pick, 'aria-label' | 'aria-labelledby'> & - Required, 'aria-orientation' | 'onKeyDown'>> & { - ref: React.RefObject - role: 'tablist' - } + /** Props to be spread onto the tablist element */ + tabListProps: { + onKeyDown: React.KeyboardEventHandler + 'aria-orientation': AriaAttributes['aria-orientation'] + 'aria-label': AriaAttributes['aria-label'] + 'aria-labelledby': AriaAttributes['aria-labelledby'] + ref: React.RefObject + role: 'tablist' + } } { const {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': ariaOrientation} = props @@ -170,12 +208,13 @@ type TabProps = React.ComponentPropsWithoutRef<'button'> & { /** * A custom hook that provides the props needed for a tab component. * The props returned should be spread onto the component (typically a button) with the `role=tab`, under a `tablist`. - * @param props The props for the tab component. - * @returns The props needed for the tab component. */ -export function useTab(props: Pick): { +function useTab( + props: Pick, +): { + /** Props to be spread onto the tab component */ tabProps: Pick< - React.HTMLProps, + React.HTMLProps, 'aria-controls' | 'aria-disabled' | 'aria-selected' | 'id' | 'tabIndex' | 'onKeyDown' | 'onMouseDown' | 'onFocus' > & { role: 'tab' @@ -187,13 +226,13 @@ export function useTab(props: Pick): { const id = `${tabs.groupId}-tab-${value}` const panelId = `${tabs.groupId}-panel-${value}` - function onKeyDown(event: React.KeyboardEvent) { + function onKeyDown(event: React.KeyboardEvent) { if (event.key === ' ' || event.key === 'Enter') { tabs.selectTab(value) } } - function onMouseDown(event: React.MouseEvent) { + function onMouseDown(event: React.MouseEvent) { if (!disabled && event.button === 0 && event.ctrlKey === false) { tabs.selectTab(value) } else { @@ -241,7 +280,7 @@ const Tab = React.forwardRef, TabProps>(function Tab(props, ) }) -type TabPanelProps = React.HTMLAttributes & { +type TabPanelProps = { /** * Provide a value that uniquely identities the tab panel. This should mirror * the value set for the corresponding tab @@ -249,7 +288,19 @@ type TabPanelProps = React.HTMLAttributes & { value: string } -export function useTabPanel(props: Pick) { +/** Utility hook for tab panels */ +function useTabPanel( + props: TabPanelProps, +): { + /** Props to be spread onto the tabpanel component */ + tabPanelProps: Pick, 'aria-labelledby' | 'id' | 'hidden'> & { + /** + * An identifier to aide in styling when this panel is selected & active + */ + 'data-selected': string | undefined + role: 'tabpanel' + } +} { const {value} = props const tabs = useTabs() const id = `${tabs.groupId}-panel-${value}` @@ -266,7 +317,7 @@ export function useTabPanel(props: Pick) { } } -function TabPanel({children, value, ...rest}: TabPanelProps) { +function TabPanel({children, value, ...rest}: React.HTMLAttributes & TabPanelProps) { const {tabPanelProps} = useTabPanel({value}) return ( @@ -305,5 +356,5 @@ function composeEventHandlers(...handlers: Array | null | undefine } } -export {Tabs, TabList, Tab, TabPanel} +export {Tabs, TabList, Tab, TabPanel, useTab, useTabList, useTabPanel} export type {TabsProps, TabListProps, TabProps, TabPanelProps} diff --git a/packages/react/src/experimental/Tabs/index.ts b/packages/react/src/experimental/Tabs/index.ts new file mode 100644 index 00000000000..ffe015d6a6b --- /dev/null +++ b/packages/react/src/experimental/Tabs/index.ts @@ -0,0 +1,2 @@ +export {Tabs, useTab, useTabList, useTabPanel} from './Tabs' +export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index a23fc344fcc..c706f004efd 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -93,3 +93,4 @@ export {IssueLabel} from './IssueLabel' export type {IssueLabelProps} from './IssueLabel' export * from '../KeybindingHint' +export * from './Tabs' diff --git a/packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx b/packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx deleted file mode 100644 index 467a19cbad2..00000000000 --- a/packages/react/src/internal/components/Tabs/Tabs.features.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type {Meta} from '@storybook/react-vite' -import {action} from 'storybook/actions' -import React from 'react' -import {Tabs, TabList, Tab, TabPanel} from '.' - -const meta = { - title: 'Private/Components/Tabs/Features', - component: Tabs, -} satisfies Meta - -export default meta - -export const Controlled = () => { - const [value, setValue] = React.useState('one') - return ( - <> - { - action('onValueChange')({value}) - setValue(value) - }} - > - - One - Two - Three - - Panel one - Panel two - Panel three - - - - ) -} diff --git a/packages/react/src/internal/components/Tabs/index.ts b/packages/react/src/internal/components/Tabs/index.ts deleted file mode 100644 index b8dcd2bc731..00000000000 --- a/packages/react/src/internal/components/Tabs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {Tabs, TabList, Tab, TabPanel, useTab, useTabList, useTabPanel} from './Tabs' -export type {TabsProps, TabListProps, TabProps, TabPanelProps} from './Tabs' From dd1cdeb8c0be36ac2c1eb03ca0d2cc7b42f7e117 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 5 Nov 2025 16:11:52 +0000 Subject: [PATCH 10/14] Add changeset --- .changeset/crazy-bees-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/crazy-bees-travel.md diff --git a/.changeset/crazy-bees-travel.md b/.changeset/crazy-bees-travel.md new file mode 100644 index 00000000000..f345ee258dc --- /dev/null +++ b/.changeset/crazy-bees-travel.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Adds an experimental `Tabs` utility component & associated hooks From 9827263755cb56efe225449b29a27637baab9a1f Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 5 Nov 2025 16:21:19 +0000 Subject: [PATCH 11/14] Update exports snapshot --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index db884036c2a..d648ec8f759 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -346,6 +346,11 @@ exports[`@primer/react/experimental > should not update exports without a semver "type TableRowProps", "type TableSubtitleProps", "type TableTitleProps", + "type TabListProps", + "type TabPanelProps", + "type TabProps", + "Tabs", + "type TabsProps", "type TitleProps", "Tooltip", "type TooltipProps", @@ -357,5 +362,8 @@ exports[`@primer/react/experimental > should not update exports without a semver "useFeatureFlag", "useOverflow", "useSlots", + "useTab", + "useTabList", + "useTabPanel", ] `; From a50f67c40a12d2b03ce76ee1c9049204fd550517 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 5 Nov 2025 19:12:09 +0000 Subject: [PATCH 12/14] Update Axe tests to skip non-story files --- e2e/components/Axe.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/components/Axe.test.ts b/e2e/components/Axe.test.ts index 62d02344d79..74b17d88f6b 100644 --- a/e2e/components/Axe.test.ts +++ b/e2e/components/Axe.test.ts @@ -18,13 +18,14 @@ const SKIPPED_TESTS = [ type Component = { name: string + type: 'story' | 'docs' } const {entries} = componentsConfig test.describe('Axe tests', () => { for (const [id, entry] of Object.entries(entries as Record)) { - if (SKIPPED_TESTS.includes(id)) { + if (SKIPPED_TESTS.includes(id) || entry.type !== 'story') { continue } From 9f47063e7da94ffc1f27f75df6224a7f8273a251 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 5 Nov 2025 14:29:04 -0500 Subject: [PATCH 13/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/react/src/experimental/Tabs/Tabs.test.tsx | 2 +- packages/react/src/experimental/Tabs/Tabs.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/experimental/Tabs/Tabs.test.tsx b/packages/react/src/experimental/Tabs/Tabs.test.tsx index d5fb493df9e..b6e2ac35897 100644 --- a/packages/react/src/experimental/Tabs/Tabs.test.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.test.tsx @@ -533,7 +533,7 @@ describe('Tabs', () => { const tabA = screen.getByRole('tab', {name: 'Tab A'}) const tabB = screen.getByRole('tab', {name: 'Tab B'}) - // Verify the disabled tab has disabled + // Verify the disabled tab has aria-disabled attribute expect(tabB).toHaveAttribute('aria-disabled', 'true') await act(() => { diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index cc120f49ea8..bc80e58769f 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -104,7 +104,7 @@ type TabListProps = Labelled & React.HTMLAttributes function useTabList( props: TabListProps & { - /** Optional ref to use for the tablist. If none if provided, one will be generated automatically */ + /** Optional ref to use for the tablist. If none is provided, one will be generated automatically */ ref?: React.RefObject }, ): { @@ -199,7 +199,7 @@ type TabProps = React.ComponentPropsWithoutRef<'button'> & { disabled?: boolean /** - * Provide a value that uniquely identities the tab. This should mirror the + * Provide a value that uniquely identifies the tab. This should mirror the * value provided to the corresponding TabPanel */ value: string @@ -282,7 +282,7 @@ const Tab = React.forwardRef, TabProps>(function Tab(props, type TabPanelProps = { /** - * Provide a value that uniquely identities the tab panel. This should mirror + * Provide a value that uniquely identifies the tab panel. This should mirror * the value set for the corresponding tab */ value: string @@ -295,7 +295,7 @@ function useTabPanel( /** Props to be spread onto the tabpanel component */ tabPanelProps: Pick, 'aria-labelledby' | 'id' | 'hidden'> & { /** - * An identifier to aide in styling when this panel is selected & active + * An identifier to aid in styling when this panel is selected & active */ 'data-selected': string | undefined role: 'tabpanel' From 94c168f8d726c057fefceade5a3ecce97c1905f9 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 5 Nov 2025 19:34:24 +0000 Subject: [PATCH 14/14] Update preventDefault() call --- packages/react/src/experimental/Tabs/Tabs.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/experimental/Tabs/Tabs.tsx b/packages/react/src/experimental/Tabs/Tabs.tsx index bc80e58769f..71fe02c0e4b 100644 --- a/packages/react/src/experimental/Tabs/Tabs.tsx +++ b/packages/react/src/experimental/Tabs/Tabs.tsx @@ -128,13 +128,17 @@ function useTabList( return } - event.preventDefault() const tabs = getFocusableTabs(tablist) const isVertical = ariaOrientation === 'vertical' const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight' const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft' + if (event.key === nextKey || event.key === prevKey || event.key === 'Home' || event.key === 'End') { + event.preventDefault() + event.stopPropagation() + } + if (event.key === nextKey) { const selectedTabIndex = tabs.findIndex(tab => { return tab.getAttribute('aria-selected') === 'true'